From fc989dd6117a03b29011f1013a978e07a47a436d Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 02:58:16 +0600 Subject: [PATCH 01/65] feat(google-workspace): add Google Drive integration (OAuth + Picker) Free feature, always on. Lets each user connect their own Google account and attach Drive files to tasks by reference (drive.file scope). - OAuth2 connect/disconnect via WP HTTP API (no SDK); per-user tokens encrypted at rest (AES-256-CBC, key from WP salts), auto-refresh. - Admin sets site-level OAuth credentials + Picker keys (Client ID/Secret, API key, App ID). Settings_Page_Access gated. - Google Picker (client-side) for browsing under least-privilege drive.file; setOrigin + z-index/pointer-events handling for in-admin embedding. - Task detail "Google Drive" section: attach/list/detach file references. - DB tables wp_pm_google_tokens, wp_pm_google_drive_files (idempotent install + last_used_at self-heal). - 60-day stale-token purge via daily cron. - uninstall.php removes Google Workspace tables/options/cron only. - Graceful reconnect when site salts rotate (undecryptable tokens purged). - Browser guidance for third-party-cookie blocking (Chrome/Brave/Safari). Files: src/Google_Workspace/* (Loader, Google_Client, Google_Service, Models, Controllers), routes/google-workspace.php, React store slice + components/google-workspace/*, registrations in index.jsx, sidebar nav, TaskDetailSheet picker-aware outside-close guard, Create_Table + start.php bootstrap, uninstall.php. --- bootstrap/start.php | 3 + db/Create_Table.php | 1 + routes/google-workspace.php | 40 +++ .../Controllers/Drive_Controller.php | 120 ++++++++ .../Controllers/OAuth_Controller.php | 48 +++ .../Controllers/Settings_Controller.php | 51 ++++ src/Google_Workspace/Google_Client.php | 105 +++++++ src/Google_Workspace/Google_Service.php | 278 ++++++++++++++++++ src/Google_Workspace/Loader.php | 167 +++++++++++ .../Models/Google_Drive_File.php | 22 ++ src/Google_Workspace/Models/Google_Token.php | 19 ++ uninstall.php | 29 ++ .../google-workspace/DrivePickerModal.jsx | 139 +++++++++ .../GoogleDriveTaskSection.jsx | 171 +++++++++++ .../google-workspace/GoogleWorkspacePage.jsx | 214 ++++++++++++++ .../src/components/layout/AppSidebar.jsx | 4 +- .../tasks/TaskDetailSheet/index.jsx | 16 + views/assets/src/index.jsx | 6 + .../assets/src/store/googleWorkspaceSlice.js | 132 +++++++++ views/assets/src/store/index.js | 2 + 20 files changed, 1566 insertions(+), 1 deletion(-) create mode 100644 routes/google-workspace.php create mode 100644 src/Google_Workspace/Controllers/Drive_Controller.php create mode 100644 src/Google_Workspace/Controllers/OAuth_Controller.php create mode 100644 src/Google_Workspace/Controllers/Settings_Controller.php create mode 100644 src/Google_Workspace/Google_Client.php create mode 100644 src/Google_Workspace/Google_Service.php create mode 100644 src/Google_Workspace/Loader.php create mode 100644 src/Google_Workspace/Models/Google_Drive_File.php create mode 100644 src/Google_Workspace/Models/Google_Token.php create mode 100644 uninstall.php create mode 100644 views/assets/src/components/google-workspace/DrivePickerModal.jsx create mode 100644 views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx create mode 100644 views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx create mode 100644 views/assets/src/store/googleWorkspaceSlice.js diff --git a/bootstrap/start.php b/bootstrap/start.php index a72a565b8..3b7a502cf 100644 --- a/bootstrap/start.php +++ b/bootstrap/start.php @@ -21,6 +21,9 @@ wedevs_pm_load_routes(); wedevs_pm_register_routes(); wedevs_pm_clean_svg(); + +( new \WeDevs\PM\Google_Workspace\Loader() )->boot(); + do_action( 'wedevs_pm_loaded' ); add_action('init', 'wedevs_pm_init_tracker'); 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..bf3e003f4 --- /dev/null +++ b/routes/google-workspace.php @@ -0,0 +1,40 @@ +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 ] ); + +// ── 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 ] ); + +// ── Task attachments ───────────────────────────────────────────────── +$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 ] ); + +$wedevs_pm_router->delete( 'projects/{project_id}/tasks/{task_id}/google-drive/{id}', $gw_base . 'Drive_Controller@destroy' ) + ->permission( [ $gw_access ] ); diff --git a/src/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php new file mode 100644 index 000000000..9884cce05 --- /dev/null +++ b/src/Google_Workspace/Controllers/Drive_Controller.php @@ -0,0 +1,120 @@ + 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 */ + public function index( WP_REST_Request $request ) { + $task_id = (int) $request->get_param( 'task_id' ); + + $files = Google_Drive_File::where( 'task_id', $task_id ) + ->orderBy( 'created_at', 'desc' ) + ->get(); + + return [ 'data' => $this->transform_collection( $files ) ]; + } + + /** POST projects/{project_id}/tasks/{task_id}/google-drive */ + public function attach( WP_REST_Request $request ) { + $project_id = (int) $request->get_param( 'project_id' ); + $task_id = (int) $request->get_param( 'task_id' ); + $file = $request->get_param( 'file' ); + + 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( 'task_id', $task_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' => $task_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, $task_id, $project_id ); + + return [ 'data' => $this->transform( $row ) ]; + } + + /** DELETE projects/{project_id}/tasks/{task_id}/google-drive/{id} */ + public function destroy( WP_REST_Request $request ) { + $id = (int) $request->get_param( 'id' ); + $task_id = (int) $request->get_param( 'task_id' ); + + $row = Google_Drive_File::where( 'id', $id )->where( 'task_id', $task_id )->first(); + if ( $row ) { + do_action( 'pm_google_drive_file_detached', $row, $task_id ); + $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 ) { + 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, + ]; + } +} diff --git a/src/Google_Workspace/Controllers/OAuth_Controller.php b/src/Google_Workspace/Controllers/OAuth_Controller.php new file mode 100644 index 000000000..8f2dc6489 --- /dev/null +++ b/src/Google_Workspace/Controllers/OAuth_Controller.php @@ -0,0 +1,48 @@ + [ + 'configured' => Google_Service::is_configured(), + 'picker_ready' => Google_Service::picker_ready(), + 'connected' => $conn['connected'], + 'account_email' => $conn['account_email'], + 'expired' => $conn['expired'], + ], + ]; + } + + 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 ); + + return [ + 'data' => [ + 'auth_url' => Google_Service::client()->get_auth_url( $state ), + ], + ]; + } + + 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..989a6445d --- /dev/null +++ b/src/Google_Workspace/Controllers/Settings_Controller.php @@ -0,0 +1,51 @@ + [ + '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'] : '', + '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' ) ); + + // 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..4c16ef92c --- /dev/null +++ b/src/Google_Workspace/Google_Client.php @@ -0,0 +1,105 @@ +client_id = $client_id; + $this->client_secret = $client_secret; + $this->redirect_uri = $redirect_uri; + } + + public function get_auth_url( $state ) { + return add_query_arg( [ + 'client_id' => rawurlencode( $this->client_id ), + 'redirect_uri' => rawurlencode( $this->redirect_uri ), + 'response_type' => 'code', + 'scope' => rawurlencode( self::SCOPES ), + '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..02ad89663 --- /dev/null +++ b/src/Google_Workspace/Google_Service.php @@ -0,0 +1,278 @@ +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; + } + + $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 : 'pm-google-fallback'; + $salt .= defined( 'AUTH_SALT' ) ? AUTH_SALT : 'pm-google-fallback-salt'; + 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..36298939a --- /dev/null +++ b/src/Google_Workspace/Loader.php @@ -0,0 +1,167 @@ + Google_Service::is_configured(), + 'picker_ready' => Google_Service::picker_ready(), + 'connected' => $conn['connected'], + 'account_email' => $conn['account_email'], + 'expired' => $conn['expired'], + '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.1' ) { + return; + } + self::install(); + } + + /** + * Idempotently add columns missing from an existing tokens table. + * Works on both MySQL and MariaDB (no ADD COLUMN IF NOT EXISTS). + */ + public static function ensure_columns() { + global $wpdb; + $table = $wpdb->prefix . 'pm_google_tokens'; + + // Bail if the table doesn't exist yet (fresh install creates it with the column). + $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + if ( ! $exists ) { + return; + } + + $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, 'last_used_at' + ) ); + + if ( empty( $has ) ) { + $wpdb->query( "ALTER TABLE {$table} ADD COLUMN `last_used_at` datetime DEFAULT NULL AFTER `expires_at`" ); + self::log( 'Added missing last_used_at column to ' . $table ); + } + } + + 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, + `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`) + ) DEFAULT CHARSET=utf8" ); + + update_option( 'pm_google_workspace_db_version', '1.1' ); + } +} 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..a76d78d3d --- /dev/null +++ b/src/Google_Workspace/Models/Google_Drive_File.php @@ -0,0 +1,22 @@ +query( "DROP TABLE IF EXISTS {$wpdb->prefix}pm_google_tokens" ); +$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}pm_google_drive_files" ); + +// Google Workspace options. +delete_option( 'pm_google_workspace_settings' ); +delete_option( 'pm_google_workspace_db_version' ); + +// Scheduled cleanup cron. +$timestamp = wp_next_scheduled( 'pm_google_workspace_cleanup' ); +if ( $timestamp ) { + wp_unschedule_event( $timestamp, 'pm_google_workspace_cleanup' ); +} +wp_clear_scheduled_hook( 'pm_google_workspace_cleanup' ); 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..c0a1c293f --- /dev/null +++ b/views/assets/src/components/google-workspace/DrivePickerModal.jsx @@ -0,0 +1,139 @@ +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, attachFile } 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, taskId, attachedIds = [], onClose }) { + 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 || [] + let attached = 0 + for (const doc of docs) { + if (attachedIds.includes(doc.id)) continue + const file = { + id: doc.id, + name: doc.name, + mimeType: doc.mimeType, + iconLink: doc.iconUrl, + webViewLink: doc.url, + modifiedTime: doc.lastEditedUtc ? new Date(doc.lastEditedUtc).toISOString() : '', + } + const res = await dispatch(attachFile({ projectId, taskId, file })) + if (attachFile.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/GoogleDriveTaskSection.jsx b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx new file mode 100644 index 000000000..9e4346458 --- /dev/null +++ b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx @@ -0,0 +1,171 @@ +import { __ } from '@wordpress/i18n' +/** + * GoogleDriveTaskSection — lists Drive files attached to a task and opens the + * Google Picker. Fills the task.detail.subtasks slot in the task detail sheet. + */ +import React, { useEffect, useState } from 'react' +import { useAppDispatch, useAppSelector } from '@store/index' +import { fetchStatus, fetchAttachments, detachFile } from '@store/googleWorkspaceSlice' +import { Button } from '@components/ui/button' +import { FileText, Plus, ExternalLink, Trash2, Link2, Info, Chrome } from 'lucide-react' +import { toast } from 'sonner' +import DrivePickerModal from './DrivePickerModal' + +// Modern multicolor Google Drive logo. +const DriveLogo = ({ className = 'h-4 w-4' }) => ( + +) + +export default function GoogleDriveTaskSection({ taskId, projectId }) { + const dispatch = useAppDispatch() + const status = useAppSelector(s => s.googleWorkspace.status) + const attachments = useAppSelector(s => s.googleWorkspace.attachmentsByTask[taskId] || []) + const [pickerOpen, setPickerOpen] = useState(false) + const [showHelp, setShowHelp] = useState(false) + + // Option 3: BEFORE the Picker/sign-in loads, ask the browser to grant + // third-party storage *for Google* from this top-level page (the correct API + // for unblocking an embedded Google iframe). Falls back to requestStorageAccess. + // Option 2: if everything is unavailable/denied, reveal the guidance note. + async function openPicker() { + try { + if (document.requestStorageAccessFor) { + // Pre-grant for the Picker + auth origins so no sign-in/401 appears. + 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() + } else { + setShowHelp(true) // no storage-access API → likely blocked + } + } catch (e) { + setShowHelp(true) // denied → Picker likely blocked; show how to fix + } + setPickerOpen(true) + } + + useEffect(() => { + if (!status.configured && !status.connected) dispatch(fetchStatus()) + }, [dispatch]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (taskId && projectId) dispatch(fetchAttachments({ projectId, taskId })) + }, [dispatch, taskId, projectId]) + + async function onDetach(id) { + const res = await dispatch(detachFile({ projectId, taskId, id })) + if (detachFile.fulfilled.match(res)) toast.success(__('File removed.', 'wedevs-project-manager')) + } + + if (!status.configured) return null + + return ( +
+
+
+ + {__('Google Drive', 'wedevs-project-manager')} + {attachments.length > 0 && ({attachments.length})} +
+ + {status.connected ? ( + + ) : ( + + {__('Connect Google', 'wedevs-project-manager')} + + )} +
+ + {status.connected && status.picker_ready && ( +
+ + {showHelp && ( +
+

{__('Browser is blocking the Google picker', 'wedevs-project-manager')}

+

{__('Google Drive opens in a secure pop-in that needs third-party cookies. If it stays blank or frozen, try one of these:', 'wedevs-project-manager')}

+
    +
  • + + {__('Open this page in Google Chrome (usually works by default)', 'wedevs-project-manager')} +
  • +
  • + + {__('Chrome: click the eye / cookie icon in the address bar → allow third-party cookies for this site', 'wedevs-project-manager')} +
  • +
  • + + {__('Brave: click the lion (Shields) icon → turn Shields off for this site', 'wedevs-project-manager')} +
  • +
  • + + {__('Safari: Settings → Privacy → uncheck “Prevent cross-site tracking”', 'wedevs-project-manager')} +
  • +
+
+ )} +
+ )} + + {attachments.length === 0 ? ( +

{__('No Drive files attached.', 'wedevs-project-manager')}

+ ) : ( + + )} + + {pickerOpen && ( + f.file_id)} + onClose={() => setPickerOpen(false)} + /> + )} +
+ ) +} 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..4c288727c --- /dev/null +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -0,0 +1,214 @@ +import { __ } from '@wordpress/i18n' +import React, { useEffect, useState } from 'react' +import { useAppDispatch, useAppSelector } from '@store/index' +import { + fetchStatus, fetchSettings, saveSettings, getAuthUrl, disconnect, +} from '@store/googleWorkspaceSlice' +import { pmHasManageCapability } from '@hooks/usePermissions' +import { Button } from '@components/ui/button' +import { Input } from '@components/ui/input' +import { Skeleton } from '@components/ui/skeleton' +import { Copy, Check, ShieldCheck, Link2, Unlink, ExternalLink } from 'lucide-react' +import { toast } from 'sonner' + +const GoogleGlyph = (props) => ( + + + + + + +) + +export default function GoogleWorkspacePage() { + const dispatch = useAppDispatch() + const isManager = pmHasManageCapability() + + const { status, settings, statusLoading, settingsLoading, saving } = + useAppSelector(s => s.googleWorkspace) + + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') + const [apiKey, setApiKey] = useState('') + const [appId, setAppId] = useState('') + const [copied, setCopied] = useState(false) + const [connecting, setConnecting] = 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()) + if (isManager) dispatch(fetchSettings()) + }, [dispatch, isManager]) + + useEffect(() => { + setClientId(settings.client_id || '') + setApiKey(settings.api_key || '') + setAppId(settings.app_id || '') + }, [settings.client_id, settings.api_key, settings.app_id]) + + 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 onSave(e) { + e.preventDefault() + const res = await dispatch(saveSettings({ client_id: clientId, client_secret: clientSecret, api_key: apiKey, app_id: appId })) + if (saveSettings.fulfilled.match(res)) { + setClientSecret('') + toast.success(__('Settings saved.', 'wedevs-project-manager')) + } else { + toast.error(res.payload || __('Failed to save settings.', 'wedevs-project-manager')) + } + } + + 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 ( +
+
+ +
+

{__('Google Workspace', 'wedevs-project-manager')}

+

{__('Connect Google Drive to attach files to your tasks.', 'wedevs-project-manager')}

+
+
+ +
+

+ {__('Your connection', 'wedevs-project-manager')} +

+ + {status.expired && ( +

+ {__('Your Google connection expired (site security keys changed). Please reconnect — your attached files are unaffected.', 'wedevs-project-manager')} +

+ )} + + {statusLoading ? ( + + ) : !status.configured ? ( +

+ {__('Google is not configured yet. An administrator must add the Client ID and Secret below.', 'wedevs-project-manager')} +

+ ) : status.connected ? ( +
+
+ + {__('Connected as', 'wedevs-project-manager')} {status.account_email || __('Google account', 'wedevs-project-manager')} +
+ +
+ ) : ( +
+

{__('Connect your Google account to browse and attach Drive files.', 'wedevs-project-manager')}

+ +
+ )} +
+ + {isManager && ( +
+

{__('Google Cloud OAuth credentials', 'wedevs-project-manager')}

+

+ {__('Create an OAuth 2.0 Web client in Google Cloud Console, then paste the credentials here.', 'wedevs-project-manager')} +

+ +
+ +
+ {redirectUri} + +
+

{__('Add this exact URI to your Google OAuth client.', 'wedevs-project-manager')}

+
+ + {settingsLoading ? ( + + ) : ( +
+
+ + setClientId(e.target.value)} placeholder="xxxxxxxx.apps.googleusercontent.com" /> +
+
+ + setClientSecret(e.target.value)} + placeholder={settings.has_secret ? '•••••••••••• (' + __('saved — leave blank to keep', 'wedevs-project-manager') + ')' : 'GOCSPX-…'} + /> +
+
+ + setApiKey(e.target.value)} placeholder="AIza…" /> +

{__('Used by the Google Picker. Create under Credentials → API key, and enable the Picker API.', 'wedevs-project-manager')}

+
+
+ + setAppId(e.target.value)} placeholder="123456789012" /> +

{__('Your Google Cloud project number (Dashboard → Project info).', 'wedevs-project-manager')}

+
+ {!settings.picker_ready && (settings.client_id || apiKey) && ( +

{__('Drive attach needs all four: Client ID, Secret, API key, and App ID.', 'wedevs-project-manager')}

+ )} +
+ + + {__('Open Google Cloud Console', 'wedevs-project-manager')} + +
+
+ )} +
+ )} +
+ ) +} diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index 21ffc2442..505766292 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -10,7 +10,7 @@ import { Settings, ArrowLeft, PanelLeftClose, PanelLeftOpen, ChevronDown, Star, LayoutList, Layout, MessageSquare, Milestone, FileText, Activity, Tag, Crown, Layers, - Columns3, GitBranch, Receipt, Timer, Shield, Wrench, + Columns3, GitBranch, Receipt, Timer, Shield, Wrench, HardDrive, } from 'lucide-react' import { cn } from '@lib/utils' @@ -238,6 +238,8 @@ 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 (each user connects their own Google account). + items.push({ key: 'google-workspace', label: __('Google Workspace', 'wedevs-project-manager'), short: __('Drive', 'wedevs-project-manager'), icon: HardDrive, route: '/google-workspace' }) return items }, [__, isPro, activeModulePaths, canManage, isManagerAnywhere]) diff --git a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx index fe0210303..fd2a8a995 100644 --- a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx +++ b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx @@ -89,6 +89,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() @@ -481,6 +494,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 ? (
diff --git a/views/assets/src/index.jsx b/views/assets/src/index.jsx index fa0d882fa..143e6dc19 100644 --- a/views/assets/src/index.jsx +++ b/views/assets/src/index.jsx @@ -53,6 +53,11 @@ 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). +registerSlot('task.detail.subtasks', GoogleDriveTaskSection) // ── Free placeholder pages (shown when Pro does NOT replace them) ── const CalendarPlaceholder = React.lazy(() => import('@components/projects/CalendarPage')) @@ -122,6 +127,7 @@ function AppRoutes() { {!isFrontend && } />} {!isFrontend && } />} } /> + } /> {!isFrontend && isProInstalled && } />} {/* ── Replaceable pages — Pro overrides via registerFilter() ── */} diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js new file mode 100644 index 000000000..b8f4b620e --- /dev/null +++ b/views/assets/src/store/googleWorkspaceSlice.js @@ -0,0 +1,132 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import { useApi } from '@hooks/useApi' + +const api = useApi() + +const initialState = { + status: { configured: false, picker_ready: false, connected: false, account_email: '', expired: false }, + settings: { client_id: '', has_secret: false, api_key: '', app_id: '', picker_ready: false, redirect_uri: '' }, + statusLoading: false, + settingsLoading: false, + saving: false, + attachmentsByTask: {}, + attachLoading: false, +} + +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 }, { rejectWithValue }) => { + try { const res = await api.post('google-workspace/settings', { client_id, client_secret, api_key, app_id }); return res.data ?? res } + catch (e) { return rejectWithValue(e.message) } + }, +) + +export const getAuthUrl = createAsyncThunk( + 'googleWorkspace/getAuthUrl', + async (_, { rejectWithValue }) => { + try { const res = await api.get('google-workspace/auth-url'); return (res.data ?? res).auth_url } + 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) } + }, +) + +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.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.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready }) + .addCase(saveSettings.rejected, (s) => { s.saving = false }) + + .addCase(disconnect.fulfilled, (s) => { s.status = { ...s.status, connected: false, account_email: '', expired: false } }) + + .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 0c0305cf5..da7407aea 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' // Re-export for convenience export { resetProjectState } from './actions' @@ -16,6 +17,7 @@ const staticReducers = { taskLists: taskListsReducer, tasks: tasksReducer, milestones: milestonesReducer, + googleWorkspace: googleWorkspaceReducer, } // Dynamic reducers — pro plugin injects its slices at runtime From 9539800c5de1f9a1f200944939a4c37406aeb629 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 03:03:20 +0600 Subject: [PATCH 02/65] refactor(google-workspace): split admin setup from per-user connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move site-level OAuth credentials into Settings → Google Workspace tab (new GoogleWorkspaceSettingsTab; admin-only via the Settings page gate). - Google Workspace page is now per-user account connection only (connect/disconnect/status) + a features overview (Drive available; Calendar/Meet coming soon) so future features slot in cleanly. - One account connection powers all Google Workspace features. - Sidebar nav uses the Google Drive logo instead of the disk icon. No backend changes; reuses existing settings/status/auth endpoints. --- .../admin-settings/SettingsPage.jsx | 13 ++ .../tabs/GoogleWorkspaceSettingsTab.jsx | 124 ++++++++++++ .../google-workspace/GoogleWorkspacePage.jsx | 183 ++++++------------ .../src/components/layout/AppSidebar.jsx | 16 +- 4 files changed, 211 insertions(+), 125 deletions(-) create mode 100644 views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx diff --git a/views/assets/src/components/admin-settings/SettingsPage.jsx b/views/assets/src/components/admin-settings/SettingsPage.jsx index 929314fd7..89782452e 100644 --- a/views/assets/src/components/admin-settings/SettingsPage.jsx +++ b/views/assets/src/components/admin-settings/SettingsPage.jsx @@ -26,6 +26,16 @@ const LoomNavIcon = (props) => ( ) +const GoogleWorkspaceNavIcon = (props) => ( + + + + + + + + +) // ── Lazy-loaded tab components ─────────────────────────────── const GeneralTab = lazy(() => import('./tabs/GeneralTab')) @@ -38,6 +48,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 +60,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 @@ -98,6 +110,7 @@ const SettingsPage = () => { { key: 'github', label: __('GitHub', 'wedevs-project-manager'), icon: GitHubNavIcon }, { key: 'notion', label: __('Notion', 'wedevs-project-manager'), icon: NotionNavIcon }, { key: 'loom', label: __('Loom', 'wedevs-project-manager'), icon: LoomNavIcon }, + { key: 'google-workspace', label: __('Google Workspace', 'wedevs-project-manager'), icon: GoogleWorkspaceNavIcon }, ] if (showWooTab) { 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..075bfe675 --- /dev/null +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -0,0 +1,124 @@ +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 { Skeleton } from '@components/ui/skeleton' +import { Copy, Check, ExternalLink, ShieldCheck } from 'lucide-react' +import { useToast } from '@hooks/useToast' + +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 [copied, setCopied] = useState(false) + + useEffect(() => { dispatch(fetchSettings()) }, [dispatch]) + + useEffect(() => { + setClientId(settings.client_id || '') + setApiKey(settings.api_key || '') + setAppId(settings.app_id || '') + }, [settings.client_id, settings.api_key, settings.app_id]) + + 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 onSave(e) { + e.preventDefault() + const res = await dispatch(saveSettings({ client_id: clientId, client_secret: clientSecret, api_key: apiKey, app_id: appId })) + 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')}

+

+ {__('Connect your Google Cloud project once. These credentials power all Google Workspace features (Drive now; Calendar and Meet coming soon). Each user then connects their own Google account from the Google Workspace page.', 'wedevs-project-manager')} +

+
+ + {settings.picker_ready && ( +
+ {__('Google Workspace is configured. Users can now connect their accounts.', 'wedevs-project-manager')} +
+ )} + +
+
+ +
+ {redirectUri} + +
+

{__('Add this exact URI to your Google OAuth client (Web application).', 'wedevs-project-manager')}

+
+ + {settingsLoading ? ( + + ) : ( +
+
+ + setClientId(e.target.value)} placeholder="xxxxxxxx.apps.googleusercontent.com" /> +
+
+ + setClientSecret(e.target.value)} + placeholder={settings.has_secret ? '•••••••••••• (' + __('saved — leave blank to keep', 'wedevs-project-manager') + ')' : 'GOCSPX-…'} + /> +
+
+ + setApiKey(e.target.value)} placeholder="AIza…" /> +

{__('Used by the Google Picker. Create under Credentials → API key, and enable the Picker API.', 'wedevs-project-manager')}

+
+
+ + setAppId(e.target.value)} placeholder="123456789012" /> +

{__('Your Google Cloud project number (Dashboard → Project info).', 'wedevs-project-manager')}

+
+
+ + + {__('Open Google Cloud Console', 'wedevs-project-manager')} + +
+
+ )} +
+
+ ) +} diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx index 4c288727c..ec09badd7 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -1,14 +1,17 @@ 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, fetchSettings, saveSettings, getAuthUrl, disconnect, -} from '@store/googleWorkspaceSlice' -import { pmHasManageCapability } from '@hooks/usePermissions' +import { fetchStatus, getAuthUrl, disconnect } from '@store/googleWorkspaceSlice' import { Button } from '@components/ui/button' -import { Input } from '@components/ui/input' import { Skeleton } from '@components/ui/skeleton' -import { Copy, Check, ShieldCheck, Link2, Unlink, ExternalLink } from 'lucide-react' +import { ShieldCheck, Unlink, HardDrive, Calendar, Video, Settings as SettingsIcon } from 'lucide-react' import { toast } from 'sonner' const GoogleGlyph = (props) => ( @@ -20,25 +23,26 @@ const GoogleGlyph = (props) => ( ) +const DriveLogo = (props) => ( + + + + + + + + +) + export default function GoogleWorkspacePage() { const dispatch = useAppDispatch() - const isManager = pmHasManageCapability() - - const { status, settings, statusLoading, settingsLoading, saving } = - useAppSelector(s => s.googleWorkspace) - - const [clientId, setClientId] = useState('') - const [clientSecret, setClientSecret] = useState('') - const [apiKey, setApiKey] = useState('') - const [appId, setAppId] = useState('') - const [copied, setCopied] = useState(false) + const { status, statusLoading } = useAppSelector(s => s.googleWorkspace) const [connecting, setConnecting] = 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'], @@ -47,51 +51,19 @@ export default function GoogleWorkspacePage() { } 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()) - if (isManager) dispatch(fetchSettings()) - }, [dispatch, isManager]) - - useEffect(() => { - setClientId(settings.client_id || '') - setApiKey(settings.api_key || '') - setAppId(settings.app_id || '') - }, [settings.client_id, settings.api_key, settings.app_id]) - - 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 onSave(e) { - e.preventDefault() - const res = await dispatch(saveSettings({ client_id: clientId, client_secret: clientSecret, api_key: apiKey, app_id: appId })) - if (saveSettings.fulfilled.match(res)) { - setClientSecret('') - toast.success(__('Settings saved.', 'wedevs-project-manager')) - } else { - toast.error(res.payload || __('Failed to save settings.', 'wedevs-project-manager')) - } - } + 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')) - } + 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() { @@ -106,15 +78,11 @@ export default function GoogleWorkspacePage() {

{__('Google Workspace', 'wedevs-project-manager')}

-

{__('Connect Google Drive to attach files to your tasks.', 'wedevs-project-manager')}

+

{__('Connect your Google account to use Google features inside Project Manager.', 'wedevs-project-manager')}

-

- {__('Your connection', 'wedevs-project-manager')} -

- {status.expired && (

{__('Your Google connection expired (site security keys changed). Please reconnect — your attached files are unaffected.', 'wedevs-project-manager')} @@ -122,11 +90,12 @@ export default function GoogleWorkspacePage() { )} {statusLoading ? ( - + ) : !status.configured ? ( -

- {__('Google is not configured yet. An administrator must add the Client ID and Secret below.', 'wedevs-project-manager')} -

+
+ + {__('Google Workspace isn’t set up yet. An administrator needs to add the credentials under Settings → Google Workspace.', 'wedevs-project-manager')} +
) : status.connected ? (
@@ -139,7 +108,7 @@ export default function GoogleWorkspacePage() {
) : (
-

{__('Connect your Google account to browse and attach Drive files.', 'wedevs-project-manager')}

+

{__('Connect your Google account to browse and attach Drive files to tasks.', 'wedevs-project-manager')}

@@ -147,68 +116,36 @@ export default function GoogleWorkspacePage() { )}
- {isManager && ( -
-

{__('Google Cloud OAuth credentials', 'wedevs-project-manager')}

-

- {__('Create an OAuth 2.0 Web client in Google Cloud Console, then paste the credentials here.', 'wedevs-project-manager')} -

- -
- -
- {redirectUri} - + {/* Features overview — grows as Calendar/Meet land. */} +
+

{__('Features', 'wedevs-project-manager')}

+
    +
  • + +
    +
    {__('Google Drive', 'wedevs-project-manager')}
    +
    {__('Browse and attach Drive files to tasks.', 'wedevs-project-manager')}
    -

    {__('Add this exact URI to your Google OAuth client.', 'wedevs-project-manager')}

    -
- - {settingsLoading ? ( - - ) : ( -
-
- - setClientId(e.target.value)} placeholder="xxxxxxxx.apps.googleusercontent.com" /> -
-
- - setClientSecret(e.target.value)} - placeholder={settings.has_secret ? '•••••••••••• (' + __('saved — leave blank to keep', 'wedevs-project-manager') + ')' : 'GOCSPX-…'} - /> -
-
- - setApiKey(e.target.value)} placeholder="AIza…" /> -

{__('Used by the Google Picker. Create under Credentials → API key, and enable the Picker API.', 'wedevs-project-manager')}

-
-
- - setAppId(e.target.value)} placeholder="123456789012" /> -

{__('Your Google Cloud project number (Dashboard → Project info).', 'wedevs-project-manager')}

-
- {!settings.picker_ready && (settings.client_id || apiKey) && ( -

{__('Drive attach needs all four: Client ID, Secret, API key, and App ID.', 'wedevs-project-manager')}

- )} -
- - - {__('Open Google Cloud Console', 'wedevs-project-manager')} - -
-
- )} -
- )} + {__('Available', 'wedevs-project-manager')} + +
  • + +
    +
    {__('Google Calendar', 'wedevs-project-manager')}
    +
    {__('Create calendar events from tasks.', 'wedevs-project-manager')}
    +
    + {__('Coming soon', 'wedevs-project-manager')} +
  • +
  • +
  • + +
    ) } diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index 505766292..9cdcbdbbc 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -10,10 +10,22 @@ import { Settings, ArrowLeft, PanelLeftClose, PanelLeftOpen, ChevronDown, Star, LayoutList, Layout, MessageSquare, Milestone, FileText, Activity, Tag, Crown, Layers, - Columns3, GitBranch, Receipt, Timer, Shield, Wrench, HardDrive, + Columns3, GitBranch, Receipt, Timer, Shield, Wrench, } from 'lucide-react' import { cn } from '@lib/utils' +// Multicolor Google Drive logo for the nav. +const GoogleDriveNavIcon = (props) => ( + + + + + + + + +) + function statusColor(p) { const s = p.status if (s === 'complete' || s === '1' || s === 1) return '#10b981' @@ -239,7 +251,7 @@ export function AppSidebar() { } } // Google Workspace — free feature, shown to everyone (each user connects their own Google account). - items.push({ key: 'google-workspace', label: __('Google Workspace', 'wedevs-project-manager'), short: __('Drive', 'wedevs-project-manager'), icon: HardDrive, route: '/google-workspace' }) + items.push({ key: 'google-workspace', label: __('Google Workspace', 'wedevs-project-manager'), short: __('Drive', 'wedevs-project-manager'), icon: GoogleDriveNavIcon, route: '/google-workspace' }) return items }, [__, isPro, activeModulePaths, canManage, isManagerAnywhere]) From 75551163515665d4f22d46a18ba8d609050f8587 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 03:09:20 +0600 Subject: [PATCH 03/65] style(google-workspace): monochrome nav/settings logo; sidebar label Google Drive (free) / Google Workspace (pro) --- .../components/admin-settings/SettingsPage.jsx | 14 +++++++------- .../src/components/layout/AppSidebar.jsx | 18 +++++++++--------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/views/assets/src/components/admin-settings/SettingsPage.jsx b/views/assets/src/components/admin-settings/SettingsPage.jsx index 89782452e..499e276ea 100644 --- a/views/assets/src/components/admin-settings/SettingsPage.jsx +++ b/views/assets/src/components/admin-settings/SettingsPage.jsx @@ -27,13 +27,13 @@ const LoomNavIcon = (props) => ( ) const GoogleWorkspaceNavIcon = (props) => ( - - - - - - - + + + + + + + ) diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index 9cdcbdbbc..9069177e5 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -14,15 +14,15 @@ import { } from 'lucide-react' import { cn } from '@lib/utils' -// Multicolor Google Drive logo for the nav. +// Google Drive logo, single-tone (currentColor) to match the other nav icons. const GoogleDriveNavIcon = (props) => ( - - - - - - - + + + + + + + ) @@ -251,7 +251,7 @@ export function AppSidebar() { } } // Google Workspace — free feature, shown to everyone (each user connects their own Google account). - items.push({ key: 'google-workspace', label: __('Google Workspace', 'wedevs-project-manager'), short: __('Drive', 'wedevs-project-manager'), icon: GoogleDriveNavIcon, route: '/google-workspace' }) + items.push({ key: 'google-workspace', label: isPro ? __('Google Workspace', 'wedevs-project-manager') : __('Google Drive', 'wedevs-project-manager'), short: __('Drive', 'wedevs-project-manager'), icon: GoogleDriveNavIcon, route: '/google-workspace' }) return items }, [__, isPro, activeModulePaths, canManage, isManagerAnywhere]) From f79b10f0f95194b26c72c5bce0cdb8e21d1baef6 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 10:25:28 +0600 Subject: [PATCH 04/65] feat(google-workspace): admin toggle to enable/disable Google Drive (hidden everywhere when off) --- .../Controllers/OAuth_Controller.php | 1 + .../Controllers/Settings_Controller.php | 22 ++++++++------- src/Google_Workspace/Google_Service.php | 6 ++++ src/Google_Workspace/Loader.php | 1 + .../tabs/GoogleWorkspaceSettingsTab.jsx | 28 +++++++++++++++++-- .../src/components/layout/AppSidebar.jsx | 7 +++-- views/assets/src/index.jsx | 7 +++-- .../assets/src/store/googleWorkspaceSlice.js | 10 +++---- 8 files changed, 60 insertions(+), 22 deletions(-) diff --git a/src/Google_Workspace/Controllers/OAuth_Controller.php b/src/Google_Workspace/Controllers/OAuth_Controller.php index 8f2dc6489..750dbcb78 100644 --- a/src/Google_Workspace/Controllers/OAuth_Controller.php +++ b/src/Google_Workspace/Controllers/OAuth_Controller.php @@ -18,6 +18,7 @@ public function status( WP_REST_Request $request ) { 'data' => [ 'configured' => Google_Service::is_configured(), 'picker_ready' => Google_Service::picker_ready(), + 'drive_enabled' => Google_Service::drive_enabled(), 'connected' => $conn['connected'], 'account_email' => $conn['account_email'], 'expired' => $conn['expired'], diff --git a/src/Google_Workspace/Controllers/Settings_Controller.php b/src/Google_Workspace/Controllers/Settings_Controller.php index 989a6445d..58bccb735 100644 --- a/src/Google_Workspace/Controllers/Settings_Controller.php +++ b/src/Google_Workspace/Controllers/Settings_Controller.php @@ -17,13 +17,14 @@ public function get( WP_REST_Request $request ) { return [ 'data' => [ - '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'] : '', - 'configured' => Google_Service::is_configured(), - 'picker_ready' => Google_Service::picker_ready(), - 'redirect_uri' => Loader::redirect_uri(), + '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(), + 'configured' => Google_Service::is_configured(), + 'picker_ready' => Google_Service::picker_ready(), + 'redirect_uri' => Loader::redirect_uri(), ], ]; } @@ -34,9 +35,10 @@ public function save( WP_REST_Request $request ) { $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['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 ); // Only overwrite the secret when a fresh value is sent (UI sends blank to keep). // Stored encrypted at rest. diff --git a/src/Google_Workspace/Google_Service.php b/src/Google_Workspace/Google_Service.php index 02ad89663..8abbf9197 100644 --- a/src/Google_Workspace/Google_Service.php +++ b/src/Google_Workspace/Google_Service.php @@ -41,6 +41,12 @@ public static function is_configured() { return ! empty( $settings['client_id'] ) && ! empty( $settings['client_secret'] ); } + /** 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'] ); + } + /** Picker also needs an API key + App ID (project number). */ public static function picker_ready() { $settings = get_option( 'pm_google_workspace_settings', [] ); diff --git a/src/Google_Workspace/Loader.php b/src/Google_Workspace/Loader.php index 36298939a..1c1834642 100644 --- a/src/Google_Workspace/Loader.php +++ b/src/Google_Workspace/Loader.php @@ -40,6 +40,7 @@ public function localize( $localize ) { $localize['google_workspace'] = [ 'configured' => Google_Service::is_configured(), 'picker_ready' => Google_Service::picker_ready(), + 'drive_enabled' => Google_Service::drive_enabled(), 'connected' => $conn['connected'], 'account_email' => $conn['account_email'], 'expired' => $conn['expired'], diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx index 075bfe675..5afc6bece 100644 --- a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -10,6 +10,7 @@ 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 } from 'lucide-react' import { useToast } from '@hooks/useToast' @@ -23,6 +24,7 @@ export default function GoogleWorkspaceSettingsTab() { const [clientSecret, setClientSecret] = useState('') const [apiKey, setApiKey] = useState('') const [appId, setAppId] = useState('') + const [driveEnabled, setDriveEnabled] = useState(false) const [copied, setCopied] = useState(false) useEffect(() => { dispatch(fetchSettings()) }, [dispatch]) @@ -31,7 +33,8 @@ export default function GoogleWorkspaceSettingsTab() { setClientId(settings.client_id || '') setApiKey(settings.api_key || '') setAppId(settings.app_id || '') - }, [settings.client_id, settings.api_key, settings.app_id]) + setDriveEnabled(!!settings.drive_enabled) + }, [settings.client_id, settings.api_key, settings.app_id, settings.drive_enabled]) const redirectUri = settings.redirect_uri || (window.PM_Vars?.google_workspace?.redirect_uri ?? '') @@ -41,9 +44,20 @@ export default function GoogleWorkspaceSettingsTab() { 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 onSave(e) { e.preventDefault() - const res = await dispatch(saveSettings({ client_id: clientId, client_secret: clientSecret, api_key: apiKey, app_id: appId })) + 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')) @@ -61,7 +75,15 @@ export default function GoogleWorkspaceSettingsTab() {

    - {settings.picker_ready && ( +
    +
    +
    {__('Enable Google Drive', 'wedevs-project-manager')}
    +
    {__('Show Google Drive in the sidebar and on tasks. Turn off to hide it everywhere.', 'wedevs-project-manager')}
    +
    + +
    + + {settings.picker_ready && driveEnabled && (
    {__('Google Workspace is configured. Users can now connect their accounts.', 'wedevs-project-manager')}
    diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index 9069177e5..0d8c861ef 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -250,8 +250,11 @@ 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 (each user connects their own Google account). - items.push({ key: 'google-workspace', label: isPro ? __('Google Workspace', 'wedevs-project-manager') : __('Google Drive', 'wedevs-project-manager'), short: __('Drive', 'wedevs-project-manager'), icon: GoogleDriveNavIcon, route: '/google-workspace' }) + // Google Workspace — free feature, shown to everyone when the admin has + // enabled Google Drive in Settings → Google Workspace. + if (typeof PM_Vars !== 'undefined' && PM_Vars.google_workspace?.drive_enabled) { + items.push({ key: 'google-workspace', label: isPro ? __('Google Workspace', 'wedevs-project-manager') : __('Google Drive', 'wedevs-project-manager'), short: __('Drive', 'wedevs-project-manager'), icon: GoogleDriveNavIcon, route: '/google-workspace' }) + } return items }, [__, isPro, activeModulePaths, canManage, isManagerAnywhere]) diff --git a/views/assets/src/index.jsx b/views/assets/src/index.jsx index 143e6dc19..9572c0c8d 100644 --- a/views/assets/src/index.jsx +++ b/views/assets/src/index.jsx @@ -56,8 +56,11 @@ const LicensePage = React.lazy(() => import('@components/projects/LicensePag 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). -registerSlot('task.detail.subtasks', 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')) diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js index b8f4b620e..5db4cd32b 100644 --- a/views/assets/src/store/googleWorkspaceSlice.js +++ b/views/assets/src/store/googleWorkspaceSlice.js @@ -4,8 +4,8 @@ import { useApi } from '@hooks/useApi' const api = useApi() const initialState = { - status: { configured: false, picker_ready: false, connected: false, account_email: '', expired: false }, - settings: { client_id: '', has_secret: false, api_key: '', app_id: '', picker_ready: false, redirect_uri: '' }, + status: { configured: false, picker_ready: false, drive_enabled: false, connected: false, account_email: '', expired: false }, + settings: { client_id: '', has_secret: false, api_key: '', app_id: '', drive_enabled: false, picker_ready: false, redirect_uri: '' }, statusLoading: false, settingsLoading: false, saving: false, @@ -31,8 +31,8 @@ export const fetchSettings = createAsyncThunk( export const saveSettings = createAsyncThunk( 'googleWorkspace/saveSettings', - async ({ client_id, client_secret, api_key, app_id }, { rejectWithValue }) => { - try { const res = await api.post('google-workspace/settings', { client_id, client_secret, api_key, app_id }); return res.data ?? res } + async ({ client_id, client_secret, api_key, app_id, drive_enabled }, { rejectWithValue }) => { + try { const res = await api.post('google-workspace/settings', { client_id, client_secret, api_key, app_id, drive_enabled }); return res.data ?? res } catch (e) { return rejectWithValue(e.message) } }, ) @@ -106,7 +106,7 @@ const slice = createSlice({ .addCase(fetchSettings.rejected, (s) => { s.settingsLoading = false }) .addCase(saveSettings.pending, (s) => { s.saving = true }) - .addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready }) + .addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready; s.status.drive_enabled = a.payload.drive_enabled }) .addCase(saveSettings.rejected, (s) => { s.saving = false }) .addCase(disconnect.fulfilled, (s) => { s.status = { ...s.status, connected: false, account_email: '', expired: false } }) From 103749941e2f434a5fe45e78ad5d4570d880dfb1 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 10:37:09 +0600 Subject: [PATCH 05/65] style(google-workspace): literal G Drive/G Workspace labels + outlined nav icons --- .../components/admin-settings/SettingsPage.jsx | 13 +++++-------- views/assets/src/components/layout/AppSidebar.jsx | 15 ++++++--------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/views/assets/src/components/admin-settings/SettingsPage.jsx b/views/assets/src/components/admin-settings/SettingsPage.jsx index 499e276ea..f38481a6d 100644 --- a/views/assets/src/components/admin-settings/SettingsPage.jsx +++ b/views/assets/src/components/admin-settings/SettingsPage.jsx @@ -27,13 +27,10 @@ const LoomNavIcon = (props) => ( ) const GoogleWorkspaceNavIcon = (props) => ( - - - - - - - + + + + ) @@ -110,7 +107,7 @@ const SettingsPage = () => { { key: 'github', label: __('GitHub', 'wedevs-project-manager'), icon: GitHubNavIcon }, { key: 'notion', label: __('Notion', 'wedevs-project-manager'), icon: NotionNavIcon }, { key: 'loom', label: __('Loom', 'wedevs-project-manager'), icon: LoomNavIcon }, - { key: 'google-workspace', label: __('Google Workspace', 'wedevs-project-manager'), icon: GoogleWorkspaceNavIcon }, + { key: 'google-workspace', label: __('G Workspace', 'wedevs-project-manager'), icon: GoogleWorkspaceNavIcon }, ] if (showWooTab) { diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index 0d8c861ef..981955914 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -14,15 +14,12 @@ import { } from 'lucide-react' import { cn } from '@lib/utils' -// Google Drive logo, single-tone (currentColor) to match the other nav icons. +// Google Drive glyph, outlined (stroke) to match the lucide nav icons. const GoogleDriveNavIcon = (props) => ( - - - - - - - + + + + ) @@ -253,7 +250,7 @@ export function AppSidebar() { // Google Workspace — free feature, shown to everyone when the admin has // enabled Google Drive in Settings → Google Workspace. if (typeof PM_Vars !== 'undefined' && PM_Vars.google_workspace?.drive_enabled) { - items.push({ key: 'google-workspace', label: isPro ? __('Google Workspace', 'wedevs-project-manager') : __('Google Drive', 'wedevs-project-manager'), short: __('Drive', 'wedevs-project-manager'), icon: GoogleDriveNavIcon, route: '/google-workspace' }) + items.push({ key: 'google-workspace', label: isPro ? __('G Workspace', 'wedevs-project-manager') : __('G Drive', 'wedevs-project-manager'), short: __('Drive', 'wedevs-project-manager'), icon: GoogleDriveNavIcon, route: '/google-workspace' }) } return items }, [__, isPro, activeModulePaths, canManage, isManagerAnywhere]) From ce4a0fffda200f2c43580d451267fe00171d5678 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 10:46:21 +0600 Subject: [PATCH 06/65] style(google-workspace): refine monochrome Drive nav/settings glyph --- .../assets/src/components/admin-settings/SettingsPage.jsx | 6 +++--- views/assets/src/components/layout/AppSidebar.jsx | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/views/assets/src/components/admin-settings/SettingsPage.jsx b/views/assets/src/components/admin-settings/SettingsPage.jsx index f38481a6d..1b34491e3 100644 --- a/views/assets/src/components/admin-settings/SettingsPage.jsx +++ b/views/assets/src/components/admin-settings/SettingsPage.jsx @@ -28,9 +28,9 @@ const LoomNavIcon = (props) => ( ) const GoogleWorkspaceNavIcon = (props) => ( - - - + + + ) diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index 981955914..fce89ad71 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -14,12 +14,12 @@ import { } from 'lucide-react' import { cn } from '@lib/utils' -// Google Drive glyph, outlined (stroke) to match the lucide nav icons. +// Google Drive glyph, monochrome outline to match the lucide nav icons. const GoogleDriveNavIcon = (props) => ( - - - + + + ) From a9ec5f394874aa6116c892fdf0d26269282dfdfd Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 10:51:00 +0600 Subject: [PATCH 07/65] =?UTF-8?q?style(google-workspace):=20adopt=202026?= =?UTF-8?q?=20Drive=20icon=20=E2=80=94=20monochrome=20in=20nav/settings,?= =?UTF-8?q?=20full=20color=20in=20task/connection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/admin-settings/SettingsPage.jsx | 8 ++++---- .../GoogleDriveTaskSection.jsx | 18 ++++++++++-------- .../google-workspace/GoogleWorkspacePage.jsx | 16 +++++++++------- .../src/components/layout/AppSidebar.jsx | 10 +++++----- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/views/assets/src/components/admin-settings/SettingsPage.jsx b/views/assets/src/components/admin-settings/SettingsPage.jsx index 1b34491e3..4b38e5127 100644 --- a/views/assets/src/components/admin-settings/SettingsPage.jsx +++ b/views/assets/src/components/admin-settings/SettingsPage.jsx @@ -27,10 +27,10 @@ const LoomNavIcon = (props) => ( ) const GoogleWorkspaceNavIcon = (props) => ( - - - - + + + + ) diff --git a/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx index 9e4346458..1620eba23 100644 --- a/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx +++ b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx @@ -11,15 +11,17 @@ import { FileText, Plus, ExternalLink, Trash2, Link2, Info, Chrome } from 'lucid import { toast } from 'sonner' import DrivePickerModal from './DrivePickerModal' -// Modern multicolor Google Drive logo. +// Google Drive logo (2026 mark), full color. const DriveLogo = ({ className = 'h-4 w-4' }) => ( - ({attachments.length})} - {status.connected ? ( + {status.connected && canUse !== false ? ( - ) : ( + ) : status.connected && canUse === false ? ( + {__('Attaching not allowed in this project', 'wedevs-project-manager')} + ) : !status.connected ? ( {__('Connect Google', 'wedevs-project-manager')} - )} + ) : null} {status.connected && status.picker_ready && ( diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js index 5db4cd32b..e1a48365e 100644 --- a/views/assets/src/store/googleWorkspaceSlice.js +++ b/views/assets/src/store/googleWorkspaceSlice.js @@ -11,6 +11,10 @@ const initialState = { saving: false, attachmentsByTask: {}, attachLoading: false, + + // Per-project Drive role access + canUseByProject: {}, // { [projectId]: bool } + accessByProject: {}, // { [projectId]: { co_worker, client } } } export const fetchStatus = createAsyncThunk( @@ -81,6 +85,36 @@ export const attachFile = createAsyncThunk( }, ) +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 }) => { @@ -111,6 +145,10 @@ const slice = createSlice({ .addCase(disconnect.fulfilled, (s) => { s.status = { ...s.status, connected: false, account_email: '', expired: false } }) + .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) => { From 00d207abd2610ce372d57c31ea68b69395c28722 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 11:53:16 +0600 Subject: [PATCH 10/65] feat(google-workspace): clearer 'View only' message in task Drive section when role can't attach --- .../google-workspace/GoogleDriveTaskSection.jsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx index 9189e9f55..0a2dbb563 100644 --- a/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx +++ b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx @@ -7,7 +7,7 @@ import React, { useEffect, useState } from 'react' import { useAppDispatch, useAppSelector } from '@store/index' import { fetchStatus, fetchAttachments, detachFile, fetchCanUse } from '@store/googleWorkspaceSlice' import { Button } from '@components/ui/button' -import { FileText, Plus, ExternalLink, Trash2, Link2, Info, Chrome } from 'lucide-react' +import { FileText, Plus, ExternalLink, Trash2, Link2, Info, Chrome, Lock } from 'lucide-react' import { toast } from 'sonner' import DrivePickerModal from './DrivePickerModal' @@ -95,7 +95,12 @@ export default function GoogleDriveTaskSection({ taskId, projectId }) { {__('Attach', 'wedevs-project-manager')} ) : status.connected && canUse === false ? ( - {__('Attaching not allowed in this project', 'wedevs-project-manager')} + + {__('View only', 'wedevs-project-manager')} + ) : !status.connected ? ( {__('Connect Google', 'wedevs-project-manager')} From 9b7cd6c05fa1ddefa595607d5fc79f91b4eb962d Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 11:57:25 +0600 Subject: [PATCH 11/65] fix(google-workspace): strict role gating on frontend (show Attach only when allowed) + enforce detach permission --- src/Google_Workspace/Controllers/Drive_Controller.php | 9 +++++++-- .../google-workspace/GoogleDriveTaskSection.jsx | 10 ++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php index 86a2ad9ba..c9988a1f5 100644 --- a/src/Google_Workspace/Controllers/Drive_Controller.php +++ b/src/Google_Workspace/Controllers/Drive_Controller.php @@ -111,8 +111,13 @@ public function attach( WP_REST_Request $request ) { /** DELETE projects/{project_id}/tasks/{task_id}/google-drive/{id} */ public function destroy( WP_REST_Request $request ) { - $id = (int) $request->get_param( 'id' ); - $task_id = (int) $request->get_param( 'task_id' ); + $project_id = (int) $request->get_param( 'project_id' ); + $id = (int) $request->get_param( 'id' ); + $task_id = (int) $request->get_param( 'task_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( 'task_id', $task_id )->first(); if ( $row ) { diff --git a/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx index 0a2dbb563..7673e265c 100644 --- a/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx +++ b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx @@ -85,7 +85,7 @@ export default function GoogleDriveTaskSection({ taskId, projectId }) { {attachments.length > 0 && ({attachments.length})} - {status.connected && canUse !== false ? ( + {status.connected && canUse === true ? ( + {canUse === true && ( + + )} ))} From f10e167ad4d448231a367dd2ed8f338192f3db5b Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 12:01:45 +0600 Subject: [PATCH 12/65] fix(google-workspace): no Connect prompt / hide section for roles without Drive access (admin + frontend) --- .../google-workspace/GoogleDriveTaskSection.jsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx index 7673e265c..9a005012d 100644 --- a/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx +++ b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx @@ -75,6 +75,8 @@ export default function GoogleDriveTaskSection({ taskId, projectId }) { } if (!status.configured) return null + // No Drive access in this project and nothing to view → hide the section. + if (canUse === false && attachments.length === 0) return null return (
    @@ -94,21 +96,21 @@ export default function GoogleDriveTaskSection({ taskId, projectId }) { > {__('Attach', 'wedevs-project-manager')} - ) : status.connected && canUse === false ? ( + ) : canUse === false ? ( {__('View only', 'wedevs-project-manager')} - ) : !status.connected ? ( + ) : canUse === true && !status.connected ? ( {__('Connect Google', 'wedevs-project-manager')} ) : null}
    - {status.connected && status.picker_ready && ( + {status.connected && status.picker_ready && canUse === true && (
    + ) : canUse === false ? ( + + {__('View only', 'wedevs-project-manager')} + + ) : canUse === true && !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} + {canUse === true && ( + + )} + + ))} + {action} + {picker} +
    + ) + } + + // Section: bordered block with header (task detail / files). + return ( +
    +
    +
    + + {title || __('Google Drive', 'wedevs-project-manager')} + {attachments.length > 0 && ({attachments.length})} +
    + {action} +
    + + {attachments.length === 0 ? ( +

    {__('No Drive files attached.', 'wedevs-project-manager')}

    + ) : ( +
      + {attachments.map(file => ( +
    • + + + {file.name} + + + + + {canUse === true && ( + + )} +
    • + ))} +
    + )} + {picker} +
    + ) +} diff --git a/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx index 9a005012d..c765ae339 100644 --- a/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx +++ b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx @@ -1,189 +1,18 @@ -import { __ } from '@wordpress/i18n' +import React from 'react' +import GoogleDriveAttach from './GoogleDriveAttach' + /** - * GoogleDriveTaskSection — lists Drive files attached to a task and opens the - * Google Picker. Fills the task.detail.subtasks slot in the task detail sheet. + * Task detail Drive section — fills the task.detail.subtasks slot. + * Thin wrapper over the shared GoogleDriveAttach. */ -import React, { useEffect, useState } from 'react' -import { useAppDispatch, useAppSelector } from '@store/index' -import { fetchStatus, fetchAttachments, detachFile, fetchCanUse } from '@store/googleWorkspaceSlice' -import { Button } from '@components/ui/button' -import { FileText, Plus, ExternalLink, Trash2, Link2, Info, Chrome, Lock } from 'lucide-react' -import { toast } from 'sonner' -import DrivePickerModal from './DrivePickerModal' - -// Google Drive logo (2026 mark), full color. -const DriveLogo = ({ className = 'h-4 w-4' }) => ( - -) - export default function GoogleDriveTaskSection({ taskId, projectId }) { - const dispatch = useAppDispatch() - const status = useAppSelector(s => s.googleWorkspace.status) - const attachments = useAppSelector(s => s.googleWorkspace.attachmentsByTask[taskId] || []) - const canUse = useAppSelector(s => s.googleWorkspace.canUseByProject[projectId]) - const [pickerOpen, setPickerOpen] = useState(false) - const [showHelp, setShowHelp] = useState(false) - - // Option 3: BEFORE the Picker/sign-in loads, ask the browser to grant - // third-party storage *for Google* from this top-level page (the correct API - // for unblocking an embedded Google iframe). Falls back to requestStorageAccess. - // Option 2: if everything is unavailable/denied, reveal the guidance note. - async function openPicker() { - try { - if (document.requestStorageAccessFor) { - // Pre-grant for the Picker + auth origins so no sign-in/401 appears. - 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() - } else { - setShowHelp(true) // no storage-access API → likely blocked - } - } catch (e) { - setShowHelp(true) // denied → Picker likely blocked; show how to fix - } - setPickerOpen(true) - } - - useEffect(() => { - if (!status.configured && !status.connected) dispatch(fetchStatus()) - }, [dispatch]) // eslint-disable-line react-hooks/exhaustive-deps - - useEffect(() => { - if (taskId && projectId) dispatch(fetchAttachments({ projectId, taskId })) - }, [dispatch, taskId, projectId]) - - useEffect(() => { - if (projectId && canUse === undefined) dispatch(fetchCanUse({ projectId })) - }, [dispatch, projectId]) // eslint-disable-line react-hooks/exhaustive-deps - - async function onDetach(id) { - const res = await dispatch(detachFile({ projectId, taskId, id })) - if (detachFile.fulfilled.match(res)) toast.success(__('File removed.', 'wedevs-project-manager')) - } - - if (!status.configured) return null - // No Drive access in this project and nothing to view → hide the section. - if (canUse === false && attachments.length === 0) return null - + if (!taskId || !projectId) return null return ( -
    -
    -
    - - {__('Google Drive', 'wedevs-project-manager')} - {attachments.length > 0 && ({attachments.length})} -
    - - {status.connected && canUse === true ? ( - - ) : canUse === false ? ( - - {__('View only', 'wedevs-project-manager')} - - ) : canUse === true && !status.connected ? ( - - {__('Connect Google', 'wedevs-project-manager')} - - ) : null} -
    - - {status.connected && status.picker_ready && canUse === true && ( -
    - - {showHelp && ( -
    -

    {__('Browser is blocking the Google picker', 'wedevs-project-manager')}

    -

    {__('Google Drive opens in a secure pop-in that needs third-party cookies. If it stays blank or frozen, try one of these:', 'wedevs-project-manager')}

    -
      -
    • - - {__('Open this page in Google Chrome (usually works by default)', 'wedevs-project-manager')} -
    • -
    • - - {__('Chrome: click the eye / cookie icon in the address bar → allow third-party cookies for this site', 'wedevs-project-manager')} -
    • -
    • - - {__('Brave: click the lion (Shields) icon → turn Shields off for this site', 'wedevs-project-manager')} -
    • -
    • - - {__('Safari: Settings → Privacy → uncheck “Prevent cross-site tracking”', 'wedevs-project-manager')} -
    • -
    -
    - )} -
    - )} - - {attachments.length === 0 ? ( -

    {__('No Drive files attached.', 'wedevs-project-manager')}

    - ) : ( -
      - {attachments.map(file => ( -
    • - {file.icon_link - ? - : } - - {file.name} - - - - - {canUse === true && ( - - )} -
    • - ))} -
    - )} - - {pickerOpen && ( - f.file_id)} - onClose={() => setPickerOpen(false)} - /> - )} -
    + ) } diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js index e1a48365e..878c9362d 100644 --- a/views/assets/src/store/googleWorkspaceSlice.js +++ b/views/assets/src/store/googleWorkspaceSlice.js @@ -10,6 +10,7 @@ const initialState = { settingsLoading: false, saving: false, attachmentsByTask: {}, + attachmentsByKey: {}, // { 'type:id': [files] } attachLoading: false, // Per-project Drive role access @@ -85,6 +86,38 @@ export const attachFile = createAsyncThunk( }, ) +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 }) => { @@ -145,6 +178,21 @@ const slice = createSlice({ .addCase(disconnect.fulfilled, (s) => { s.status = { ...s.status, connected: false, account_email: '', expired: false } }) + .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 }) From 166c9f1c432d69d28f5be311bcd69c86a1dbff62 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 12:52:47 +0600 Subject: [PATCH 14/65] feat(google-workspace): attach Drive files to task comments (+ orphan cleanup on comment/task delete) --- src/Google_Workspace/Loader.php | 24 +++++++++++++++++++ .../tasks/TaskDetailSheet/index.jsx | 6 +++++ 2 files changed, 30 insertions(+) diff --git a/src/Google_Workspace/Loader.php b/src/Google_Workspace/Loader.php index bda1e0ac5..b88322c54 100644 --- a/src/Google_Workspace/Loader.php +++ b/src/Google_Workspace/Loader.php @@ -19,6 +19,10 @@ public function boot() { add_action( 'admin_post_pm_google_oauth_callback', [ $this, 'handle_oauth_callback' ] ); add_action( 'admin_init', [ $this, 'maybe_install' ] ); + // Remove Drive attachments when their parent entity is deleted. + add_action( 'wedevs_cpm_comment_delete', [ $this, 'cleanup_comment_attachments' ], 10, 1 ); + add_action( 'wedevs_pm_after_delete_task', [ $this, 'cleanup_task_attachments' ], 10, 1 ); + // Daily purge of tokens unused for 60+ days. add_action( self::CLEANUP_HOOK, [ $this, 'run_cleanup' ] ); if ( ! wp_next_scheduled( self::CLEANUP_HOOK ) ) { @@ -30,6 +34,26 @@ 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 static function redirect_uri() { return admin_url( 'admin-post.php?action=pm_google_oauth_callback' ); } diff --git a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx index fd2a8a995..12b99e424 100644 --- a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx +++ b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx @@ -27,6 +27,7 @@ import { stripAllPreviewUrls } from '@/lib/url-strippers' import { sanitizeHtml } from '@lib/sanitize' import FileUploadArea from '@components/common/FileUploadArea' import CommentAttachment from '@components/common/CommentAttachment' +import GoogleDriveAttach from '@components/google-workspace/GoogleDriveAttach' import TaskStatusCircle from '@components/common/TaskStatusCircle' import NotifyUsers from '@components/common/NotifyUsers' import { UserAvatar } from '@components/common/UserAvatar' @@ -851,6 +852,11 @@ export default function TaskDetailSheet() { ))}
    )} + {!isEditing && ( +
    + +
    + )} ) From 2acb2ceabd5799c8978357df738ea9c229ddcfd9 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 13:02:10 +0600 Subject: [PATCH 15/65] fix(google-workspace): comment Drive attach restricted to comment author or manager (UI + API) --- .../Controllers/Drive_Controller.php | 29 +++++++++++++++++++ .../google-workspace/GoogleDriveAttach.jsx | 14 +++++---- .../tasks/TaskDetailSheet/index.jsx | 2 +- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php index bf18e3a2d..405db4149 100644 --- a/src/Google_Workspace/Controllers/Drive_Controller.php +++ b/src/Google_Workspace/Controllers/Drive_Controller.php @@ -61,6 +61,28 @@ private function resolve_attachable( WP_REST_Request $request ) { return [ $type, $id ]; } + /** + * 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 ) { list( $type, $id ) = $this->resolve_attachable( $request ); @@ -112,6 +134,10 @@ public function attach( WP_REST_Request $request ) { return new \WP_Error( 'pm_google_bad_request', __( 'Invalid attachment target.', 'wedevs-project-manager' ), [ 'status' => 422 ] ); } + 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 ] ); } @@ -157,6 +183,9 @@ public function destroy( WP_REST_Request $request ) { $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(); } diff --git a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx index 96295dce1..10e5cd3c8 100644 --- a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx +++ b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx @@ -35,11 +35,12 @@ const FileIcon = ({ file, className = 'h-4 w-4 shrink-0' }) => ? : -export default function GoogleDriveAttach({ projectId, attachableType, attachableId, variant = 'section', title }) { +export default function GoogleDriveAttach({ projectId, attachableType, attachableId, variant = 'section', title, allowEdit = 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) @@ -76,9 +77,10 @@ export default function GoogleDriveAttach({ projectId, attachableType, attachabl } if (!status.configured) return null - if (canUse === false && attachments.length === 0) return null + // Nothing to show and can't add → hide entirely. + if ((canUse === false || !allowEdit) && attachments.length === 0) return null - const action = status.connected && canUse === true ? ( + const action = status.connected && canEdit ? ( @@ -153,7 +155,7 @@ export default function GoogleDriveAttach({ projectId, attachableType, attachabl - {canUse === true && ( + {canEdit && ( diff --git a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx index 12b99e424..edfa9a244 100644 --- a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx +++ b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx @@ -854,7 +854,7 @@ export default function TaskDetailSheet() { )} {!isEditing && (
    - +
    )} From d1bb174daa63413022d2725e4a814242c6a9cc7c Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 14:07:21 +0600 Subject: [PATCH 16/65] style(google-workspace): section button label 'Attach'; compact comment button = + with mono Drive icon --- .../google-workspace/GoogleDriveAttach.jsx | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx index 10e5cd3c8..684640822 100644 --- a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx +++ b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx @@ -30,6 +30,15 @@ const DriveLogo = ({ className = 'h-4 w-4' }) => ( ) +// Monochrome Drive glyph for the compact (comment) add button. +const MonoDrive = ({ className = 'h-3.5 w-3.5' }) => ( + +) + const FileIcon = ({ file, className = 'h-4 w-4 shrink-0' }) => file.icon_link ? @@ -81,14 +90,25 @@ export default function GoogleDriveAttach({ projectId, attachableType, attachabl if ((canUse === false || !allowEdit) && attachments.length === 0) return null const action = status.connected && canEdit ? ( - + variant === 'compact' ? ( + + ) : ( + + ) ) : canUse === false ? ( {__('View only', 'wedevs-project-manager')} From 9583ec8576ba8889f5b2300c4964cc1abece9bb3 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 14:14:34 +0600 Subject: [PATCH 17/65] feat(google-workspace): Drive attach in discussions (body + comments) + discussion orphan cleanup --- src/Google_Workspace/Loader.php | 5 +++++ .../projects/DiscussionsPage/DiscussionDetailPage.jsx | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/src/Google_Workspace/Loader.php b/src/Google_Workspace/Loader.php index b88322c54..504ee25fb 100644 --- a/src/Google_Workspace/Loader.php +++ b/src/Google_Workspace/Loader.php @@ -22,6 +22,7 @@ public function boot() { // Remove Drive attachments when their parent entity is deleted. add_action( 'wedevs_cpm_comment_delete', [ $this, 'cleanup_comment_attachments' ], 10, 1 ); add_action( 'wedevs_pm_after_delete_task', [ $this, 'cleanup_task_attachments' ], 10, 1 ); + add_action( 'wedevs_cpm_message_delete', [ $this, 'cleanup_discussion_attachments' ], 10, 1 ); // Daily purge of tokens unused for 60+ days. add_action( self::CLEANUP_HOOK, [ $this, 'run_cleanup' ] ); @@ -54,6 +55,10 @@ 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' ); } diff --git a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx index 32fac9350..b429741df 100644 --- a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx +++ b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx @@ -46,6 +46,7 @@ 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 { useCurrentProject } from "@hooks/useCurrentProject"; import DiscussionFiles from "./parts/DiscussionFiles"; @@ -466,6 +467,9 @@ export default function DiscussionDetailPage() { })()} +
    + +
    )} @@ -561,6 +565,9 @@ export default function DiscussionDetailPage() { +
    + +
    )} From b31ca5c59ab3b603ba45a094930a6509efad8ad9 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 17:50:32 +0600 Subject: [PATCH 18/65] feat(google-workspace): attach Drive files while creating a discussion (staged, attached on create) --- .../google-workspace/DrivePickerModal.jsx | 22 +++-- .../google-workspace/GoogleDriveStage.jsx | 94 +++++++++++++++++++ .../projects/DiscussionsPage/index.jsx | 15 ++- 3 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 views/assets/src/components/google-workspace/GoogleDriveStage.jsx diff --git a/views/assets/src/components/google-workspace/DrivePickerModal.jsx b/views/assets/src/components/google-workspace/DrivePickerModal.jsx index 52d9f28a0..748e7209a 100644 --- a/views/assets/src/components/google-workspace/DrivePickerModal.jsx +++ b/views/assets/src/components/google-workspace/DrivePickerModal.jsx @@ -48,7 +48,7 @@ function loadPickerApi() { }) } -export default function DrivePickerModal({ projectId, attachableType, attachableId, attachedIds = [], onClose }) { +export default function DrivePickerModal({ projectId, attachableType, attachableId, attachedIds = [], onClose, onPicked }) { const dispatch = useAppDispatch() const launchedRef = useRef(false) @@ -116,17 +116,27 @@ export default function DrivePickerModal({ projectId, attachableType, attachable if (data.action === Action.CANCEL) { onClose(); return } const docs = data.docs || [] - let attached = 0 - for (const doc of docs) { - if (attachedIds.includes(doc.id)) continue - const file = { + 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++ } 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..a07a79eee --- /dev/null +++ b/views/assets/src/components/google-workspace/GoogleDriveStage.jsx @@ -0,0 +1,94 @@ +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 DrivePickerModal from './DrivePickerModal' + +const MonoDrive = ({ className = 'h-3.5 w-3.5' }) => ( + +) + +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} + + + ))} + + {status.connected ? ( + + ) : ( + + {__('Connect Google', 'wedevs-project-manager')} + + )} + + {pickerOpen && ( + f.id)} + onPicked={onPicked} + onClose={() => setPickerOpen(false)} + /> + )} +
    + ) +} diff --git a/views/assets/src/components/projects/DiscussionsPage/index.jsx b/views/assets/src/components/projects/DiscussionsPage/index.jsx index 304ac1c95..d73c06a18 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 GoogleDriveStage from "@components/google-workspace/GoogleDriveStage"; +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() { + Date: Sun, 21 Jun 2026 18:50:53 +0600 Subject: [PATCH 19/65] feat(google-workspace): expose Drive components via window.PM for pro --- views/assets/src/index.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/views/assets/src/index.jsx b/views/assets/src/index.jsx index 9572c0c8d..66033b08f 100644 --- a/views/assets/src/index.jsx +++ b/views/assets/src/index.jsx @@ -267,6 +267,9 @@ window.PM = { BackButton: require('@components/common/BackButton'), FileUploadArea: require('@components/common/FileUploadArea'), CommentAttachment: require('@components/common/CommentAttachment'), + GoogleDriveAttach: require('@components/google-workspace/GoogleDriveAttach'), + 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'), From da31127b3d7bb3afa53710022272c5d1827ed490 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 19:37:57 +0600 Subject: [PATCH 20/65] feat(google-workspace): allow 'file' attachable type for Drive attachments --- src/Google_Workspace/Controllers/Drive_Controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php index 405db4149..790149e6e 100644 --- a/src/Google_Workspace/Controllers/Drive_Controller.php +++ b/src/Google_Workspace/Controllers/Drive_Controller.php @@ -53,7 +53,7 @@ private function resolve_attachable( WP_REST_Request $request ) { } // Whitelist supported entity types. - $allowed = [ 'task', 'comment', 'discussion', 'project' ]; + $allowed = [ 'task', 'comment', 'discussion', 'project', 'file' ]; if ( ! in_array( $type, $allowed, true ) ) { $type = ''; } From 87b5531aadbf3eaf1e3b2300e7376d0b5a260d52 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 20:36:21 +0600 Subject: [PATCH 21/65] feat(google-workspace): clamp list to 2 + Show all; adder avatar; outlined hover-reveal comment button --- .../Controllers/Drive_Controller.php | 4 + .../google-workspace/GoogleDriveAttach.jsx | 73 +++++++++++++------ .../DiscussionsPage/DiscussionDetailPage.jsx | 2 +- .../tasks/TaskDetailSheet/index.jsx | 2 +- 4 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php index 790149e6e..147e0d632 100644 --- a/src/Google_Workspace/Controllers/Drive_Controller.php +++ b/src/Google_Workspace/Controllers/Drive_Controller.php @@ -202,6 +202,8 @@ private function transform_collection( $rows ) { } private function transform( $row ) { + $user = $row->user_id ? get_userdata( $row->user_id ) : null; + return [ 'id' => (int) $row->id, 'file_id' => $row->file_id, @@ -212,6 +214,8 @@ private function transform( $row ) { '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/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx index 684640822..65b1eef89 100644 --- a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx +++ b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx @@ -30,21 +30,32 @@ const DriveLogo = ({ className = 'h-4 w-4' }) => ( ) -// Monochrome Drive glyph for the compact (comment) add button. +// Outlined Drive glyph for the compact (comment) add button. const MonoDrive = ({ className = 'h-3.5 w-3.5' }) => ( - + : {title.charAt(0).toUpperCase()} +} + const FileIcon = ({ file, className = 'h-4 w-4 shrink-0' }) => file.icon_link ? : -export default function GoogleDriveAttach({ projectId, attachableType, attachableId, variant = 'section', title, allowEdit = true }) { +const MAX_VISIBLE = 2 + +export default function GoogleDriveAttach({ projectId, attachableType, attachableId, variant = 'section', title, allowEdit = true, addRevealClass = '' }) { const dispatch = useAppDispatch() const key = `${attachableType}:${attachableId}` const status = useAppSelector(s => s.googleWorkspace.status) @@ -52,6 +63,7 @@ export default function GoogleDriveAttach({ projectId, attachableType, attachabl 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()) @@ -92,7 +104,7 @@ export default function GoogleDriveAttach({ projectId, attachableType, attachabl const action = status.connected && canEdit ? ( variant === 'compact' ? ( - )} - - ))} - + <> +
      + {(expanded ? attachments : attachments.slice(0, MAX_VISIBLE)).map(file => ( +
    • + + + {file.name} + + + + + + {canEdit && ( + + )} +
    • + ))} +
    + {attachments.length > MAX_VISIBLE && ( + + )} + )} {picker} diff --git a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx index b429741df..2e01fdbfc 100644 --- a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx +++ b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx @@ -566,7 +566,7 @@ export default function DiscussionDetailPage() {
    - +
    )} diff --git a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx index edfa9a244..9750a13f8 100644 --- a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx +++ b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx @@ -854,7 +854,7 @@ export default function TaskDetailSheet() { )} {!isEditing && (
    - +
    )} From 6d9c762d540cc9364c566aba44e9c8466d126e6f Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 20:48:41 +0600 Subject: [PATCH 22/65] fix(google-workspace): comment Drive button reveal via opacity (was hidden + ungenerated variant) --- .../projects/DiscussionsPage/DiscussionDetailPage.jsx | 2 +- views/assets/src/components/tasks/TaskDetailSheet/index.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx index 2e01fdbfc..f5968e401 100644 --- a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx +++ b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx @@ -566,7 +566,7 @@ export default function DiscussionDetailPage() {
    - +
    )} diff --git a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx index 9750a13f8..5dd678d1f 100644 --- a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx +++ b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx @@ -854,7 +854,7 @@ export default function TaskDetailSheet() { )} {!isEditing && (
    - +
    )} From b6db5f2b7fe80a547055663160aeaf7c52e91540 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 20:59:18 +0600 Subject: [PATCH 23/65] feat(google-workspace): comment Drive add as icon-only button beside edit/delete (order: drive, edit, delete); chips render below via showAdd=false --- .../google-workspace/GoogleDriveAttach.jsx | 4 +- .../GoogleDriveCommentButton.jsx | 68 +++++++++++++++++++ .../DiscussionsPage/DiscussionDetailPage.jsx | 4 +- .../tasks/TaskDetailSheet/index.jsx | 4 +- views/assets/src/index.jsx | 1 + 5 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx diff --git a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx index 65b1eef89..7203cae7b 100644 --- a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx +++ b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx @@ -55,7 +55,7 @@ const FileIcon = ({ file, className = 'h-4 w-4 shrink-0' }) => const MAX_VISIBLE = 2 -export default function GoogleDriveAttach({ projectId, attachableType, attachableId, variant = 'section', title, allowEdit = true, addRevealClass = '' }) { +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) @@ -100,6 +100,8 @@ export default function GoogleDriveAttach({ projectId, attachableType, attachabl 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' ? ( 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..0b52e0b28 --- /dev/null +++ b/views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx @@ -0,0 +1,68 @@ +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 DrivePickerModal from './DrivePickerModal' + +const DriveGlyph = ({ className = 'h-3.5 w-3.5' }) => ( + +) + +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.configured && status.connected && status.picker_ready && canUse === true && allowEdit)) return null + + return ( + <> + + {pickerOpen && ( + setPickerOpen(false)} + /> + )} + + ) +} diff --git a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx index f5968e401..65f16e6ff 100644 --- a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx +++ b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx @@ -47,6 +47,7 @@ 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 GoogleDriveCommentButton from "@components/google-workspace/GoogleDriveCommentButton"; import { useCurrentProject } from "@hooks/useCurrentProject"; import DiscussionFiles from "./parts/DiscussionFiles"; @@ -498,6 +499,7 @@ export default function DiscussionDetailPage() {
    {canEditComment(c) && (
    + @@ -854,7 +856,7 @@ export default function TaskDetailSheet() { )} {!isEditing && (
    - +
    )}
    diff --git a/views/assets/src/index.jsx b/views/assets/src/index.jsx index 66033b08f..2ffd53ddc 100644 --- a/views/assets/src/index.jsx +++ b/views/assets/src/index.jsx @@ -268,6 +268,7 @@ window.PM = { FileUploadArea: require('@components/common/FileUploadArea'), CommentAttachment: require('@components/common/CommentAttachment'), GoogleDriveAttach: require('@components/google-workspace/GoogleDriveAttach'), + GoogleDriveCommentButton: require('@components/google-workspace/GoogleDriveCommentButton'), GoogleDriveStage: require('@components/google-workspace/GoogleDriveStage'), DrivePickerModal: require('@components/google-workspace/DrivePickerModal'), TaskStatusCircle: require('@components/common/TaskStatusCircle'), From 58bf81d6be589a316b1cd0cefc7369966b1b33ed Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 21:07:44 +0600 Subject: [PATCH 24/65] feat(google-workspace): show 'Google Drive' label only in task section; discussion/file headers show icon + count only --- .../src/components/google-workspace/GoogleDriveAttach.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx index 7203cae7b..7ae2cecc7 100644 --- a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx +++ b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx @@ -171,7 +171,7 @@ export default function GoogleDriveAttach({ projectId, attachableType, attachabl
    - {title || __('Google Drive', 'wedevs-project-manager')} + {variant === 'section' && (title || __('Google Drive', 'wedevs-project-manager'))} {attachments.length > 0 && ({attachments.length})}
    {action} From 9fbf23d59593c915b86d48ea11865efdac7abe3f Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 21:14:31 +0600 Subject: [PATCH 25/65] feat(google-workspace): drop comment Drive chips; icon button beside edit/delete is the only comment surface --- .../projects/DiscussionsPage/DiscussionDetailPage.jsx | 3 --- views/assets/src/components/tasks/TaskDetailSheet/index.jsx | 6 ------ 2 files changed, 9 deletions(-) diff --git a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx index 65f16e6ff..eb147c9bc 100644 --- a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx +++ b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx @@ -567,9 +567,6 @@ export default function DiscussionDetailPage() { -
    - -
    )}
    diff --git a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx index 06762c1f9..e5afa21ce 100644 --- a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx +++ b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx @@ -27,7 +27,6 @@ import { stripAllPreviewUrls } from '@/lib/url-strippers' import { sanitizeHtml } from '@lib/sanitize' import FileUploadArea from '@components/common/FileUploadArea' import CommentAttachment from '@components/common/CommentAttachment' -import GoogleDriveAttach from '@components/google-workspace/GoogleDriveAttach' import GoogleDriveCommentButton from '@components/google-workspace/GoogleDriveCommentButton' import TaskStatusCircle from '@components/common/TaskStatusCircle' import NotifyUsers from '@components/common/NotifyUsers' @@ -854,11 +853,6 @@ export default function TaskDetailSheet() { ))} )} - {!isEditing && ( -
    - -
    - )} ) From 5039d4c5c05a24791db3c95776e4a5af3557b2bd Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 21:35:39 +0600 Subject: [PATCH 26/65] feat(google-workspace): add Pro extension slots + upgrade covers for Calendar & Meet - G Workspace page: Calendar/Meet rows are now Slots (google.workspace.feature.{calendar,meet}); free shows a clickable Pro upgrade teaser, Pro fills them. - Settings tab: Calendar sync + Meet groups shown as locked Pro teasers (google.workspace.settings.{calendar,meet} slots) behind the upgrade modal. - Drive remains the free feature; Calendar/Meet reserved for Pro. --- .../tabs/GoogleWorkspaceSettingsTab.jsx | 43 ++++++++++++++- .../google-workspace/GoogleWorkspacePage.jsx | 53 +++++++++++++------ 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx index 5afc6bece..b438e99c6 100644 --- a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -12,8 +12,38 @@ 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 } from 'lucide-react' +import { Copy, Check, ExternalLink, ShieldCheck, Calendar, Video, Lock, Crown } from 'lucide-react' import { useToast } from '@hooks/useToast' +import { Slot } from '@hooks/useSlot' +import { useProModal } from '@components/common/ProUpgradeModal' + +/** + * Free, locked teaser for a Pro Google feature's settings (Calendar/Meet). + * Pro replaces it by filling the matching slot with real toggles/options. + */ +const LockedSettingCard = ({ icon: Icon, title, description }) => { + const { setOpen } = useProModal() + return ( +
    setOpen(true)} + > +
    + +
    +
    + {title} + + {__('Pro', 'wedevs-project-manager')} + +
    +
    {description}
    +
    +
    + +
    + ) +} export default function GoogleWorkspaceSettingsTab() { const dispatch = useAppDispatch() @@ -83,6 +113,17 @@ export default function GoogleWorkspaceSettingsTab() { + } + /> + } + /> + {settings.picker_ready && driveEnabled && (
    {__('Google Workspace is configured. Users can now connect their accounts.', 'wedevs-project-manager')} diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx index c34ce95bd..a87c8ead9 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -11,8 +11,33 @@ import { useAppDispatch, useAppSelector } from '@store/index' import { fetchStatus, getAuthUrl, disconnect } from '@store/googleWorkspaceSlice' import { Button } from '@components/ui/button' import { Skeleton } from '@components/ui/skeleton' -import { ShieldCheck, Unlink, HardDrive, Calendar, Video, Settings as SettingsIcon } from 'lucide-react' +import { ShieldCheck, Unlink, HardDrive, Calendar, Video, Settings as SettingsIcon, Crown } from 'lucide-react' import { toast } from 'sonner' +import { Slot } from '@hooks/useSlot' +import { useProModal } from '@components/common/ProUpgradeModal' + +/** + * Free cover for an upcoming Pro Google feature (Calendar/Meet). Pro replaces + * it by filling the matching slot. Click opens the upgrade modal. + */ +const ProFeatureRow = ({ icon: Icon, title, description }) => { + const { setOpen } = useProModal() + return ( +
  • setOpen(true)} + > + +
    +
    {title}
    +
    {description}
    +
    + + {__('Pro', 'wedevs-project-manager')} + +
  • + ) +} const GoogleGlyph = (props) => ( @@ -130,22 +155,16 @@ export default function GoogleWorkspacePage() {
    {__('Available', 'wedevs-project-manager')} -
  • - -
    -
    {__('Google Calendar', 'wedevs-project-manager')}
    -
    {__('Create calendar events from tasks.', 'wedevs-project-manager')}
    -
    - {__('Coming soon', 'wedevs-project-manager')} -
  • -
  • -
  • + } + /> + } + /> From 6556c55f95c564c8308de8220ca5f447719112b6 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 21:53:10 +0600 Subject: [PATCH 27/65] feat(google-workspace): incremental Calendar-scope consent infra (free) - Google_Client: add CALENDAR_SCOPE; get_auth_url() accepts a scope override. - OAuth_Controller: auth-url honors with_calendar (requests calendar.events alongside drive.file via include_granted_scopes); status returns calendar_connected. - Google_Service: user_has_scope()/user_has_calendar(); Loader localizes calendar_connected. - Slice getAuthUrl({withCalendar}); expose GW thunks (fetchStatus/getAuthUrl/disconnect) on window.PM for Pro. --- .../Controllers/OAuth_Controller.php | 18 ++++++++++++++---- src/Google_Workspace/Google_Client.php | 8 ++++++-- src/Google_Workspace/Google_Service.php | 14 ++++++++++++++ src/Google_Workspace/Loader.php | 1 + views/assets/src/index.jsx | 7 +++++++ views/assets/src/store/googleWorkspaceSlice.js | 7 ++++--- 6 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/Google_Workspace/Controllers/OAuth_Controller.php b/src/Google_Workspace/Controllers/OAuth_Controller.php index 750dbcb78..7985adee4 100644 --- a/src/Google_Workspace/Controllers/OAuth_Controller.php +++ b/src/Google_Workspace/Controllers/OAuth_Controller.php @@ -3,6 +3,7 @@ use WP_REST_Request; use WeDevs\PM\Google_Workspace\Google_Service; +use WeDevs\PM\Google_Workspace\Google_Client; if ( ! defined( 'ABSPATH' ) ) exit; @@ -19,9 +20,10 @@ public function status( WP_REST_Request $request ) { 'configured' => Google_Service::is_configured(), 'picker_ready' => Google_Service::picker_ready(), 'drive_enabled' => Google_Service::drive_enabled(), - 'connected' => $conn['connected'], - 'account_email' => $conn['account_email'], - 'expired' => $conn['expired'], + 'connected' => $conn['connected'], + 'account_email' => $conn['account_email'], + 'expired' => $conn['expired'], + 'calendar_connected'=> Google_Service::user_has_calendar( get_current_user_id() ), ], ]; } @@ -34,9 +36,17 @@ public function auth_url( WP_REST_Request $request ) { $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 ), + 'auth_url' => Google_Service::client()->get_auth_url( $state, $scope ), ], ]; } diff --git a/src/Google_Workspace/Google_Client.php b/src/Google_Workspace/Google_Client.php index 4c16ef92c..dd48f3bb4 100644 --- a/src/Google_Workspace/Google_Client.php +++ b/src/Google_Workspace/Google_Client.php @@ -17,6 +17,10 @@ class Google_Client { // Least-privilege: drive.file = only files the user picks via the Picker. const SCOPES = 'openid email profile https://www.googleapis.com/auth/drive.file'; + // Pro Calendar feature — requested incrementally (only when the user opts + // in via the Pro Calendar settings), so free-only users never grant it. + const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.events'; + private $client_id; private $client_secret; private $redirect_uri; @@ -27,12 +31,12 @@ public function __construct( $client_id, $client_secret, $redirect_uri ) { $this->redirect_uri = $redirect_uri; } - public function get_auth_url( $state ) { + 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( self::SCOPES ), + 'scope' => rawurlencode( $scope ), 'access_type' => 'offline', 'include_granted_scopes' => 'true', 'prompt' => 'consent', diff --git a/src/Google_Workspace/Google_Service.php b/src/Google_Workspace/Google_Service.php index eca3dd131..dea1553f4 100644 --- a/src/Google_Workspace/Google_Service.php +++ b/src/Google_Workspace/Google_Service.php @@ -106,6 +106,20 @@ public static function get_app_id() { 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; diff --git a/src/Google_Workspace/Loader.php b/src/Google_Workspace/Loader.php index 504ee25fb..363c30ab3 100644 --- a/src/Google_Workspace/Loader.php +++ b/src/Google_Workspace/Loader.php @@ -73,6 +73,7 @@ public function localize( $localize ) { 'connected' => $conn['connected'], 'account_email' => $conn['account_email'], 'expired' => $conn['expired'], + 'calendar_connected' => Google_Service::user_has_calendar( get_current_user_id() ), 'redirect_uri' => self::redirect_uri(), ]; diff --git a/views/assets/src/index.jsx b/views/assets/src/index.jsx index 2ffd53ddc..393f975ea 100644 --- a/views/assets/src/index.jsx +++ b/views/assets/src/index.jsx @@ -250,6 +250,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) diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js index 878c9362d..2897d182e 100644 --- a/views/assets/src/store/googleWorkspaceSlice.js +++ b/views/assets/src/store/googleWorkspaceSlice.js @@ -4,7 +4,7 @@ import { useApi } from '@hooks/useApi' const api = useApi() const initialState = { - status: { configured: false, picker_ready: false, drive_enabled: false, connected: false, account_email: '', expired: false }, + status: { configured: false, picker_ready: false, drive_enabled: false, connected: false, account_email: '', expired: false, calendar_connected: false }, settings: { client_id: '', has_secret: false, api_key: '', app_id: '', drive_enabled: false, picker_ready: false, redirect_uri: '' }, statusLoading: false, settingsLoading: false, @@ -44,8 +44,9 @@ export const saveSettings = createAsyncThunk( export const getAuthUrl = createAsyncThunk( 'googleWorkspace/getAuthUrl', - async (_, { rejectWithValue }) => { - try { const res = await api.get('google-workspace/auth-url'); return (res.data ?? res).auth_url } + async (arg, { rejectWithValue }) => { + const withCalendar = arg && arg.withCalendar + try { const res = await api.get('google-workspace/auth-url', withCalendar ? { with_calendar: 1 } : undefined); return (res.data ?? res).auth_url } catch (e) { return rejectWithValue(e.message) } }, ) From f7d3c8d4d29e5d52916178c6fe37322e12c4dd09 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Sun, 21 Jun 2026 23:15:11 +0600 Subject: [PATCH 28/65] feat(google-workspace): move Calendar config to the sidebar G Workspace page - Calendar is now a card section on the G Workspace page (slot google.workspace.feature.calendar), Pro fills it with sync settings + connect; free shows a Pro cover card. - Drop the Calendar/Meet teasers from the admin settings tab (creds-only again). --- .../tabs/GoogleWorkspaceSettingsTab.jsx | 43 +----------------- .../google-workspace/GoogleWorkspacePage.jsx | 44 ++++++++++++++++--- 2 files changed, 39 insertions(+), 48 deletions(-) diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx index b438e99c6..5afc6bece 100644 --- a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -12,38 +12,8 @@ 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, Calendar, Video, Lock, Crown } from 'lucide-react' +import { Copy, Check, ExternalLink, ShieldCheck } from 'lucide-react' import { useToast } from '@hooks/useToast' -import { Slot } from '@hooks/useSlot' -import { useProModal } from '@components/common/ProUpgradeModal' - -/** - * Free, locked teaser for a Pro Google feature's settings (Calendar/Meet). - * Pro replaces it by filling the matching slot with real toggles/options. - */ -const LockedSettingCard = ({ icon: Icon, title, description }) => { - const { setOpen } = useProModal() - return ( -
    setOpen(true)} - > -
    - -
    -
    - {title} - - {__('Pro', 'wedevs-project-manager')} - -
    -
    {description}
    -
    -
    - -
    - ) -} export default function GoogleWorkspaceSettingsTab() { const dispatch = useAppDispatch() @@ -113,17 +83,6 @@ export default function GoogleWorkspaceSettingsTab() { - } - /> - } - /> - {settings.picker_ready && driveEnabled && (
    {__('Google Workspace is configured. Users can now connect their accounts.', 'wedevs-project-manager')} diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx index a87c8ead9..ae4040137 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -11,7 +11,7 @@ import { useAppDispatch, useAppSelector } from '@store/index' import { fetchStatus, getAuthUrl, disconnect } from '@store/googleWorkspaceSlice' import { Button } from '@components/ui/button' import { Skeleton } from '@components/ui/skeleton' -import { ShieldCheck, Unlink, HardDrive, Calendar, Video, Settings as SettingsIcon, Crown } from 'lucide-react' +import { ShieldCheck, Unlink, HardDrive, Calendar, Video, Settings as SettingsIcon, Crown, Lock } from 'lucide-react' import { toast } from 'sonner' import { Slot } from '@hooks/useSlot' import { useProModal } from '@components/common/ProUpgradeModal' @@ -39,6 +39,36 @@ const ProFeatureRow = ({ icon: Icon, title, description }) => { ) } +/** + * 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 ( +
    setOpen(true)} + > +
    +
    + +
    +
    + {title} + + {__('Pro', 'wedevs-project-manager')} + +
    +
    {description}
    +
    +
    + +
    +
    + ) +} + const GoogleGlyph = (props) => ( @@ -155,11 +185,6 @@ export default function GoogleWorkspacePage() {
    {__('Available', 'wedevs-project-manager')} - } - /> + + {/* Google Calendar — Pro fills with sync settings + connect; free shows a cover. */} + } + /> ) } From 3e761745b8dc4b275db444510b698e66fb9f0ad6 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Mon, 22 Jun 2026 09:11:44 +0600 Subject: [PATCH 29/65] feat(google-workspace): Workspace page = per-feature connection cards; sidebar label 'Google Workspace' (free+pro) - Sidebar nav label unified to 'Google Workspace' for free and pro. - Workspace page now shows Connected services cards: Google Drive (free, status) + Calendar/Meet slots (Pro connect cards, free covers). - Calendar config (enable + sync options) moved back to Settings -> Google Workspace (admin) via the settings.calendar slot. --- .../tabs/GoogleWorkspaceSettingsTab.jsx | 43 +++++++++++- .../google-workspace/GoogleWorkspacePage.jsx | 67 +++++++------------ .../src/components/layout/AppSidebar.jsx | 2 +- 3 files changed, 68 insertions(+), 44 deletions(-) diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx index 5afc6bece..fa92808f9 100644 --- a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -12,8 +12,38 @@ 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 } from 'lucide-react' +import { Copy, Check, ExternalLink, ShieldCheck, Calendar, Video, Lock, Crown } from 'lucide-react' import { useToast } from '@hooks/useToast' +import { Slot } from '@hooks/useSlot' +import { useProModal } from '@components/common/ProUpgradeModal' + +/** + * 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 = ({ icon: Icon, title, description }) => { + const { setOpen } = useProModal() + return ( +
    setOpen(true)} + > +
    + +
    +
    + {title} + + {__('Pro', 'wedevs-project-manager')} + +
    +
    {description}
    +
    +
    + +
    + ) +} export default function GoogleWorkspaceSettingsTab() { const dispatch = useAppDispatch() @@ -83,6 +113,17 @@ export default function GoogleWorkspaceSettingsTab() { + } + /> + } + /> + {settings.picker_ready && driveEnabled && (
    {__('Google Workspace is configured. Users can now connect their accounts.', 'wedevs-project-manager')} diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx index ae4040137..fe908a8c2 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -16,29 +16,6 @@ import { toast } from 'sonner' import { Slot } from '@hooks/useSlot' import { useProModal } from '@components/common/ProUpgradeModal' -/** - * Free cover for an upcoming Pro Google feature (Calendar/Meet). Pro replaces - * it by filling the matching slot. Click opens the upgrade modal. - */ -const ProFeatureRow = ({ icon: Icon, title, description }) => { - const { setOpen } = useProModal() - return ( -
  • setOpen(true)} - > - -
    -
    {title}
    -
    {description}
    -
    - - {__('Pro', 'wedevs-project-manager')} - -
  • - ) -} - /** * Free card cover for a Pro Google feature section (e.g. Calendar). Pro replaces * it by filling the matching slot with the real settings. @@ -173,31 +150,37 @@ export default function GoogleWorkspacePage() { )} - {/* Features overview — grows as Calendar/Meet land. */} + {/* Connected services — one card per Google feature. */} +

    {__('Connected services', 'wedevs-project-manager')}

    + + {/* Google Drive (free) */}
    -

    {__('Features', 'wedevs-project-manager')}

    -
      -
    • - -
      -
      {__('Google Drive', 'wedevs-project-manager')}
      -
      {__('Browse and attach Drive files to tasks.', 'wedevs-project-manager')}
      -
      - {__('Available', 'wedevs-project-manager')} -
    • - } - /> -
    +
    + +
    +
    {__('Google Drive', 'wedevs-project-manager')}
    +
    {__('Browse and attach Drive files to tasks, comments, discussions and files.', 'wedevs-project-manager')}
    +
    + {status.connected ? ( + {__('Connected', 'wedevs-project-manager')} + ) : ( + {__('Not connected', 'wedevs-project-manager')} + )} +
    - {/* Google Calendar — Pro fills with sync settings + connect; free shows a cover. */} + {/* Google Calendar — Pro fills with connect + status; free shows a cover. */} } + fallback={} + /> + + {/* Google Meet — Pro (coming soon); free shows a cover. */} + } />
    ) diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index ff3683988..103500d49 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -250,7 +250,7 @@ export function AppSidebar() { // Google Workspace — free feature, shown to everyone when the admin has // enabled Google Drive in Settings → Google Workspace. if (typeof PM_Vars !== 'undefined' && PM_Vars.google_workspace?.drive_enabled) { - items.push({ key: 'google-workspace', label: isPro ? __('G Workspace', 'wedevs-project-manager') : __('G Drive', 'wedevs-project-manager'), short: __('Drive', 'wedevs-project-manager'), icon: GoogleDriveNavIcon, route: '/google-workspace' }) + items.push({ key: 'google-workspace', label: __('Google Workspace', 'wedevs-project-manager'), short: __('Workspace', 'wedevs-project-manager'), icon: GoogleDriveNavIcon, route: '/google-workspace' }) } return items }, [__, isPro, activeModulePaths, canManage, isManagerAnywhere]) From c64ecb295e70b7f0be4f5d61465d5d5e714f8ec6 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Mon, 22 Jun 2026 12:14:17 +0600 Subject: [PATCH 30/65] feat(google-workspace): per-user service prefs on Workspace page - Drive card gets a per-user on/off (user meta pm_gws_drive_on); user_can_use_drive respects it; status/localize expose drive_user_on; new POST google-workspace/my-prefs; saveDrivePref thunk. --- routes/google-workspace.php | 3 ++ .../Controllers/OAuth_Controller.php | 10 +++++++ src/Google_Workspace/Google_Service.php | 15 ++++++++++ src/Google_Workspace/Loader.php | 1 + .../google-workspace/GoogleWorkspacePage.jsx | 30 +++++++++++-------- .../assets/src/store/googleWorkspaceSlice.js | 13 ++++++-- 6 files changed, 58 insertions(+), 14 deletions(-) diff --git a/routes/google-workspace.php b/routes/google-workspace.php index 160ea13b1..7cd3fdff9 100644 --- a/routes/google-workspace.php +++ b/routes/google-workspace.php @@ -25,6 +25,9 @@ $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 ] ); diff --git a/src/Google_Workspace/Controllers/OAuth_Controller.php b/src/Google_Workspace/Controllers/OAuth_Controller.php index 7985adee4..472567566 100644 --- a/src/Google_Workspace/Controllers/OAuth_Controller.php +++ b/src/Google_Workspace/Controllers/OAuth_Controller.php @@ -24,10 +24,20 @@ public function status( WP_REST_Request $request ) { 'account_email' => $conn['account_email'], 'expired' => $conn['expired'], 'calendar_connected'=> Google_Service::user_has_calendar( get_current_user_id() ), + 'drive_user_on' => Google_Service::user_drive_enabled( get_current_user_id() ), ], ]; } + /** 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 ] ); diff --git a/src/Google_Workspace/Google_Service.php b/src/Google_Workspace/Google_Service.php index dea1553f4..73d76c329 100644 --- a/src/Google_Workspace/Google_Service.php +++ b/src/Google_Workspace/Google_Service.php @@ -67,6 +67,16 @@ public static function save_project_drive_access( $project_id, $co_worker, $clie ] ); } + /** 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. @@ -74,6 +84,11 @@ public static function save_project_drive_access( $project_id, $co_worker, $clie public static function user_can_use_drive( $project_id, $user_id = null ) { $user_id = $user_id ? $user_id : get_current_user_id(); + // 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; } diff --git a/src/Google_Workspace/Loader.php b/src/Google_Workspace/Loader.php index 363c30ab3..f519e2ac4 100644 --- a/src/Google_Workspace/Loader.php +++ b/src/Google_Workspace/Loader.php @@ -74,6 +74,7 @@ public function localize( $localize ) { 'account_email' => $conn['account_email'], 'expired' => $conn['expired'], 'calendar_connected' => Google_Service::user_has_calendar( get_current_user_id() ), + 'drive_user_on' => Google_Service::user_drive_enabled( get_current_user_id() ), 'redirect_uri' => self::redirect_uri(), ]; diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx index fe908a8c2..a33352606 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -8,8 +8,9 @@ import { __ } from '@wordpress/i18n' */ import React, { useEffect, useState } from 'react' import { useAppDispatch, useAppSelector } from '@store/index' -import { fetchStatus, getAuthUrl, disconnect } from '@store/googleWorkspaceSlice' +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 { ShieldCheck, Unlink, HardDrive, Calendar, Video, Settings as SettingsIcon, Crown, Lock } from 'lucide-react' import { toast } from 'sonner' @@ -153,20 +154,25 @@ export default function GoogleWorkspacePage() { {/* Connected services — one card per Google feature. */}

    {__('Connected services', 'wedevs-project-manager')}

    - {/* Google Drive (free) */} + {/* Google Drive (free) — per-user on/off */}
    -
    - -
    -
    {__('Google Drive', 'wedevs-project-manager')}
    -
    {__('Browse and attach Drive files to tasks, comments, discussions and files.', 'wedevs-project-manager')}
    +
    +
    + +
    +
    {__('Google Drive', 'wedevs-project-manager')}
    +
    {__('Attach Drive files to tasks, comments, discussions and files.', 'wedevs-project-manager')}
    +
    - {status.connected ? ( - {__('Connected', 'wedevs-project-manager')} - ) : ( - {__('Not connected', 'wedevs-project-manager')} - )} + dispatch(saveDrivePref({ drive_on: v }))} + />
    + {!status.connected && ( +

    {__('Connect your Google account above to use Drive.', 'wedevs-project-manager')}

    + )}
    {/* Google Calendar — Pro fills with connect + status; free shows a cover. */} diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js index 2897d182e..d251c2e95 100644 --- a/views/assets/src/store/googleWorkspaceSlice.js +++ b/views/assets/src/store/googleWorkspaceSlice.js @@ -4,7 +4,7 @@ import { useApi } from '@hooks/useApi' const api = useApi() const initialState = { - status: { configured: false, picker_ready: false, drive_enabled: false, connected: false, account_email: '', expired: false, calendar_connected: false }, + status: { configured: false, picker_ready: false, drive_enabled: false, connected: false, account_email: '', expired: false, calendar_connected: false, drive_user_on: true }, settings: { client_id: '', has_secret: false, api_key: '', app_id: '', drive_enabled: false, picker_ready: false, redirect_uri: '' }, statusLoading: false, settingsLoading: false, @@ -51,6 +51,14 @@ export const getAuthUrl = createAsyncThunk( }, ) +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 }) => { @@ -177,7 +185,8 @@ const slice = createSlice({ .addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready; s.status.drive_enabled = a.payload.drive_enabled }) .addCase(saveSettings.rejected, (s) => { s.saving = false }) - .addCase(disconnect.fulfilled, (s) => { s.status = { ...s.status, connected: false, account_email: '', expired: false } }) + .addCase(disconnect.fulfilled, (s) => { s.status = { ...s.status, connected: false, account_email: '', expired: false, calendar_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 }) From 26cf82ab54e7b9cd97059f89e6c4ac870785f4fe Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Mon, 22 Jun 2026 12:37:01 +0600 Subject: [PATCH 31/65] feat(google-workspace): note that all features use one account; reconnecting a different account replaces it --- .../components/google-workspace/GoogleWorkspacePage.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx index a33352606..a555fe086 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -12,7 +12,7 @@ import { fetchStatus, getAuthUrl, disconnect, saveDrivePref } from '@store/googl import { Button } from '@components/ui/button' import { Switch } from '@components/ui/switch' import { Skeleton } from '@components/ui/skeleton' -import { ShieldCheck, Unlink, HardDrive, Calendar, Video, Settings as SettingsIcon, Crown, Lock } from 'lucide-react' +import { ShieldCheck, Unlink, HardDrive, Calendar, Video, Settings as SettingsIcon, Crown, Lock, Info } from 'lucide-react' import { toast } from 'sonner' import { Slot } from '@hooks/useSlot' import { useProModal } from '@components/common/ProUpgradeModal' @@ -149,6 +149,13 @@ export default function GoogleWorkspacePage() { )} + + {status.configured && ( +

    + + {__('All Google features — Drive, Calendar and Meet — use this one account. Connecting a different Google account later replaces this one for every feature.', 'wedevs-project-manager')} +

    + )} {/* Connected services — one card per Google feature. */} From 531a527eafa239704a541b3ac6627bdcbab85fb4 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Mon, 22 Jun 2026 14:16:06 +0600 Subject: [PATCH 32/65] feat(google-workspace): fire pm_google_before_disconnect (token still valid) for feature cleanup --- src/Google_Workspace/Google_Service.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Google_Workspace/Google_Service.php b/src/Google_Workspace/Google_Service.php index 73d76c329..3ba84088f 100644 --- a/src/Google_Workspace/Google_Service.php +++ b/src/Google_Workspace/Google_Service.php @@ -238,6 +238,10 @@ public static function disconnect_user( $user_id ) { 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 ); From 77c2b5bf6cc214238d4799255591ddd71f8a70e0 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Mon, 22 Jun 2026 14:21:29 +0600 Subject: [PATCH 33/65] feat(google-workspace): disconnect confirm modal (plugin AlertDialog, wider) explaining consequences --- .../google-workspace/GoogleWorkspacePage.jsx | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx index a555fe086..b56016271 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -12,6 +12,10 @@ import { fetchStatus, getAuthUrl, disconnect, saveDrivePref } from '@store/googl 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, Calendar, Video, Settings as SettingsIcon, Crown, Lock, Info } from 'lucide-react' import { toast } from 'sonner' import { Slot } from '@hooks/useSlot' @@ -73,6 +77,7 @@ 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) @@ -137,7 +142,7 @@ export default function GoogleWorkspacePage() { {__('Connected as', 'wedevs-project-manager')} {status.account_email || __('Google account', 'wedevs-project-manager')} - @@ -195,6 +200,29 @@ export default function GoogleWorkspacePage() { status={status} fallback={} /> + + + + + {__('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')} + + +
    +
    ) } From 8555a8119ed0ddcc70ec8865ed27ca554bff778d Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Mon, 22 Jun 2026 15:12:42 +0600 Subject: [PATCH 34/65] feat(google-workspace): G Workspace sidebar label; smaller settings desc + setup docs link; admin toggle to disable Drive in comments (auto-save+toast, enforced UI+API); branded Calendar/Meet icons in free teasers --- .../Controllers/Drive_Controller.php | 4 ++ .../Controllers/OAuth_Controller.php | 1 + .../Controllers/Settings_Controller.php | 6 +++ src/Google_Workspace/Google_Service.php | 6 +++ src/Google_Workspace/Loader.php | 1 + .../tabs/GoogleWorkspaceSettingsTab.jsx | 45 ++++++++++++++++--- .../GoogleDriveCommentButton.jsx | 1 + .../google-workspace/GoogleIcons.jsx | 28 ++++++++++++ .../google-workspace/GoogleWorkspacePage.jsx | 7 +-- .../src/components/layout/AppSidebar.jsx | 2 +- .../assets/src/store/googleWorkspaceSlice.js | 14 +++--- 11 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 views/assets/src/components/google-workspace/GoogleIcons.jsx diff --git a/src/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php index 147e0d632..620464b18 100644 --- a/src/Google_Workspace/Controllers/Drive_Controller.php +++ b/src/Google_Workspace/Controllers/Drive_Controller.php @@ -134,6 +134,10 @@ public function attach( WP_REST_Request $request ) { return new \WP_Error( 'pm_google_bad_request', __( 'Invalid attachment target.', 'wedevs-project-manager' ), [ 'status' => 422 ] ); } + 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 ] ); } diff --git a/src/Google_Workspace/Controllers/OAuth_Controller.php b/src/Google_Workspace/Controllers/OAuth_Controller.php index 472567566..2567cb525 100644 --- a/src/Google_Workspace/Controllers/OAuth_Controller.php +++ b/src/Google_Workspace/Controllers/OAuth_Controller.php @@ -25,6 +25,7 @@ public function status( WP_REST_Request $request ) { 'expired' => $conn['expired'], 'calendar_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(), ], ]; } diff --git a/src/Google_Workspace/Controllers/Settings_Controller.php b/src/Google_Workspace/Controllers/Settings_Controller.php index 58bccb735..2f0df49dd 100644 --- a/src/Google_Workspace/Controllers/Settings_Controller.php +++ b/src/Google_Workspace/Controllers/Settings_Controller.php @@ -22,6 +22,7 @@ public function get( WP_REST_Request $request ) { 'api_key' => isset( $settings['api_key'] ) ? $settings['api_key'] : '', 'app_id' => isset( $settings['app_id'] ) ? $settings['app_id'] : '', 'drive_enabled' => Google_Service::drive_enabled(), + 'drive_comments'=> Google_Service::drive_comments_enabled(), 'configured' => Google_Service::is_configured(), 'picker_ready' => Google_Service::picker_ready(), 'redirect_uri' => Loader::redirect_uri(), @@ -40,6 +41,11 @@ public function save( WP_REST_Request $request ) { $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 !== '' ) { diff --git a/src/Google_Workspace/Google_Service.php b/src/Google_Workspace/Google_Service.php index 3ba84088f..9d14c5f17 100644 --- a/src/Google_Workspace/Google_Service.php +++ b/src/Google_Workspace/Google_Service.php @@ -105,6 +105,12 @@ public static function drive_enabled() { return ! empty( $settings['drive_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', [] ); diff --git a/src/Google_Workspace/Loader.php b/src/Google_Workspace/Loader.php index f519e2ac4..f7f52a614 100644 --- a/src/Google_Workspace/Loader.php +++ b/src/Google_Workspace/Loader.php @@ -75,6 +75,7 @@ public function localize( $localize ) { 'expired' => $conn['expired'], 'calendar_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(), ]; diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx index fa92808f9..1de8ffead 100644 --- a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -12,10 +12,13 @@ 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, Calendar, Video, Lock, Crown } from 'lucide-react' +import { Copy, Check, ExternalLink, ShieldCheck, Lock, Crown, BookOpen } from 'lucide-react' import { useToast } from '@hooks/useToast' import { Slot } from '@hooks/useSlot' import { useProModal } from '@components/common/ProUpgradeModal' +import { CalendarGlyph, MeetGlyph } 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). @@ -55,6 +58,7 @@ export default function GoogleWorkspaceSettingsTab() { 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]) @@ -64,7 +68,8 @@ export default function GoogleWorkspaceSettingsTab() { setApiKey(settings.api_key || '') setAppId(settings.app_id || '') setDriveEnabled(!!settings.drive_enabled) - }, [settings.client_id, settings.api_key, settings.app_id, 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 ?? '') @@ -85,6 +90,17 @@ export default function GoogleWorkspaceSettingsTab() { } } + 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 })) @@ -100,9 +116,16 @@ export default function GoogleWorkspaceSettingsTab() {

    {__('Google Workspace', 'wedevs-project-manager')}

    -

    - {__('Connect your Google Cloud project once. These credentials power all Google Workspace features (Drive now; Calendar and Meet coming soon). Each user then connects their own Google account from the Google Workspace page.', 'wedevs-project-manager')} +

    + {__('Set up your Google Cloud project once. Each user then connects their own Google account from the Google Workspace page.', 'wedevs-project-manager')}

    + + {__('How to set this up', 'wedevs-project-manager')} +
    @@ -113,15 +136,25 @@ export default function GoogleWorkspaceSettingsTab() {
    + {driveEnabled && ( +
    +
    +
    {__('Allow Drive in comments', 'wedevs-project-manager')}
    +
    {__('Let users attach Drive files inside comments. Turn off to hide the Drive button in comments only.', 'wedevs-project-manager')}
    +
    + +
    + )} + } + fallback={} /> } + fallback={} /> {settings.picker_ready && driveEnabled && ( diff --git a/views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx b/views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx index 0b52e0b28..de24b8e87 100644 --- a/views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx +++ b/views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx @@ -43,6 +43,7 @@ export default function GoogleDriveCommentButton({ projectId, attachableType, at } // 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 ( 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..505b9bcd3 --- /dev/null +++ b/views/assets/src/components/google-workspace/GoogleIcons.jsx @@ -0,0 +1,28 @@ +import React from 'react' + +/** Google Calendar brand glyph. Size via className (e.g. h-5 w-5). */ +export const CalendarGlyph = (props) => ( + + + + + + + + + + + + +) + +/** Google Meet brand glyph (simplified). Size via className. */ +export const MeetGlyph = (props) => ( + + + + + + + +) diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx index b56016271..cb8a901b4 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -16,7 +16,8 @@ import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '@components/ui/alert-dialog' -import { ShieldCheck, Unlink, HardDrive, Calendar, Video, Settings as SettingsIcon, Crown, Lock, Info } from 'lucide-react' +import { ShieldCheck, Unlink, HardDrive, Settings as SettingsIcon, Crown, Lock, Info } from 'lucide-react' +import { CalendarGlyph, MeetGlyph } from '@components/google-workspace/GoogleIcons' import { toast } from 'sonner' import { Slot } from '@hooks/useSlot' import { useProModal } from '@components/common/ProUpgradeModal' @@ -191,14 +192,14 @@ export default function GoogleWorkspacePage() { } + fallback={} /> {/* Google Meet — Pro (coming soon); free shows a cover. */} } + fallback={} /> diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index 103500d49..08b189b74 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -250,7 +250,7 @@ export function AppSidebar() { // Google Workspace — free feature, shown to everyone when the admin has // enabled Google Drive in Settings → Google Workspace. if (typeof PM_Vars !== 'undefined' && PM_Vars.google_workspace?.drive_enabled) { - items.push({ key: 'google-workspace', label: __('Google Workspace', 'wedevs-project-manager'), short: __('Workspace', 'wedevs-project-manager'), icon: GoogleDriveNavIcon, route: '/google-workspace' }) + 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]) diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js index d251c2e95..814ed730c 100644 --- a/views/assets/src/store/googleWorkspaceSlice.js +++ b/views/assets/src/store/googleWorkspaceSlice.js @@ -4,7 +4,7 @@ import { useApi } from '@hooks/useApi' const api = useApi() const initialState = { - status: { configured: false, picker_ready: false, drive_enabled: false, connected: false, account_email: '', expired: false, calendar_connected: false, drive_user_on: true }, + status: { configured: false, picker_ready: false, drive_enabled: false, connected: false, account_email: '', expired: false, calendar_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, settingsLoading: false, @@ -36,9 +36,13 @@ export const fetchSettings = createAsyncThunk( export const saveSettings = createAsyncThunk( 'googleWorkspace/saveSettings', - async ({ client_id, client_secret, api_key, app_id, drive_enabled }, { rejectWithValue }) => { - try { const res = await api.post('google-workspace/settings', { client_id, client_secret, api_key, app_id, drive_enabled }); return res.data ?? res } - catch (e) { return rejectWithValue(e.message) } + 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) } }, ) @@ -182,7 +186,7 @@ const slice = createSlice({ .addCase(fetchSettings.rejected, (s) => { s.settingsLoading = false }) .addCase(saveSettings.pending, (s) => { s.saving = true }) - .addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready; s.status.drive_enabled = a.payload.drive_enabled }) + .addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready; s.status.drive_enabled = a.payload.drive_enabled; s.status.drive_comments_on = a.payload.drive_comments }) .addCase(saveSettings.rejected, (s) => { s.saving = false }) .addCase(disconnect.fulfilled, (s) => { s.status = { ...s.status, connected: false, account_email: '', expired: false, calendar_connected: false } }) From fb1c3998434a9a8fc12698c32245af7578fe809f Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Mon, 22 Jun 2026 15:46:09 +0600 Subject: [PATCH 35/65] fix(google-workspace): use plugin ProBadge for teasers; neutral calendar connect prompt; docs link icon-only top-right --- .../tabs/GoogleWorkspaceSettingsTab.jsx | 24 ++++++++++--------- .../google-workspace/GoogleWorkspacePage.jsx | 7 +++--- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx index 1de8ffead..5f7561963 100644 --- a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -12,10 +12,11 @@ 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, Crown, BookOpen } from 'lucide-react' +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 { CalendarGlyph, MeetGlyph } from '@components/google-workspace/GoogleIcons' const DOCS_URL = 'https://wedevs.com/docs/wp-project-manager/integrations/google-workspace/' @@ -36,9 +37,7 @@ const LockedSettingCard = ({ icon: Icon, title, description }) => {
    {title} - - {__('Pro', 'wedevs-project-manager')} - +
    {description}
    @@ -114,17 +113,20 @@ export default function GoogleWorkspaceSettingsTab() { return (
    -
    -

    {__('Google Workspace', 'wedevs-project-manager')}

    -

    - {__('Set up your Google Cloud project once. Each user then connects their own Google account from the Google Workspace page.', 'wedevs-project-manager')} -

    +
    +
    +

    {__('Google Workspace', 'wedevs-project-manager')}

    +

    + {__('Set up your Google Cloud project once. Each user then connects their own Google account from the Google Workspace page.', 'wedevs-project-manager')} +

    +
    - {__('How to set this up', 'wedevs-project-manager')} +
    diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx index cb8a901b4..4b3aca7ce 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -16,8 +16,9 @@ import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '@components/ui/alert-dialog' -import { ShieldCheck, Unlink, HardDrive, Settings as SettingsIcon, Crown, Lock, Info } from 'lucide-react' +import { ShieldCheck, Unlink, HardDrive, Settings as SettingsIcon, Lock, Info } from 'lucide-react' import { CalendarGlyph, MeetGlyph } 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' @@ -39,9 +40,7 @@ const ProFeatureCard = ({ icon: Icon, title, description }) => {
    {title} - - {__('Pro', 'wedevs-project-manager')} - +
    {description}
    From a561a323f6cc6ffd8189507e7c616d60fa53ebdb Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Mon, 22 Jun 2026 15:56:16 +0600 Subject: [PATCH 36/65] =?UTF-8?q?fix(google-workspace):=20settings=20tab?= =?UTF-8?q?=20=E2=80=94=20G=20Workspace=20heading,=20shorter=20desc,=20cre?= =?UTF-8?q?dentials=20heading,=20icon-less=20feature=20toggles;=20trim=20a?= =?UTF-8?q?ccount=20note=202nd=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tabs/GoogleWorkspaceSettingsTab.jsx | 26 +++++++++---------- .../google-workspace/GoogleWorkspacePage.jsx | 2 +- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx index 5f7561963..9c60824c1 100644 --- a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -17,7 +17,6 @@ import { useToast } from '@hooks/useToast' import { Slot } from '@hooks/useSlot' import { useProModal } from '@components/common/ProUpgradeModal' import ProBadge from '@components/common/ProBadge' -import { CalendarGlyph, MeetGlyph } from '@components/google-workspace/GoogleIcons' const DOCS_URL = 'https://wedevs.com/docs/wp-project-manager/integrations/google-workspace/' @@ -25,22 +24,19 @@ const DOCS_URL = 'https://wedevs.com/docs/wp-project-manager/integrations/google * 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 = ({ icon: Icon, title, description }) => { +const LockedSettingCard = ({ title, description }) => { const { setOpen } = useProModal() return (
    setOpen(true)} > -
    - -
    -
    - {title} - -
    -
    {description}
    +
    +
    + {title} +
    +
    {description}
    @@ -115,9 +111,9 @@ export default function GoogleWorkspaceSettingsTab() {
    -

    {__('Google Workspace', 'wedevs-project-manager')}

    +

    {__('G Workspace', 'wedevs-project-manager')}

    - {__('Set up your Google Cloud project once. Each user then connects their own Google account from the Google Workspace page.', 'wedevs-project-manager')} + {__('Set up your Google Cloud project once. Users then connect their own account.', 'wedevs-project-manager')}

    } + fallback={} /> } + fallback={} /> {settings.picker_ready && driveEnabled && ( @@ -165,6 +161,8 @@ export default function GoogleWorkspaceSettingsTab() {
    )} +

    {__('API credentials & keys', 'wedevs-project-manager')}

    +
    diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx index 4b3aca7ce..73456ed83 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -158,7 +158,7 @@ export default function GoogleWorkspacePage() { {status.configured && (

    - {__('All Google features — Drive, Calendar and Meet — use this one account. Connecting a different Google account later replaces this one for every feature.', 'wedevs-project-manager')} + {__('All Google features — Drive, Calendar and Meet — use this one account.', 'wedevs-project-manager')}

    )} From 2894974babbcc3da1a4d111e88149c9c0b804522 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Mon, 22 Jun 2026 16:09:02 +0600 Subject: [PATCH 37/65] =?UTF-8?q?fix(google-workspace):=20settings=20?= =?UTF-8?q?=E2=80=94=20Google=20glyph=20+=20'Google=20Workspace'=20heading?= =?UTF-8?q?,=20larger=20credentials=20heading,=20grouped=20Drive=20enable?= =?UTF-8?q?=20+=20Drive-in-comments=20card=20with=20Drive=20logo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tabs/GoogleWorkspaceSettingsTab.jsx | 65 +++++++++++++------ 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx index 9c60824c1..e1413060f 100644 --- a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -20,6 +20,23 @@ import ProBadge from '@components/common/ProBadge' const DOCS_URL = 'https://wedevs.com/docs/wp-project-manager/integrations/google-workspace/' +const GoogleGlyph = (props) => ( + + + + + + +) + +const DriveLogo = (props) => ( + + + + + +) + /** * 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. @@ -110,11 +127,14 @@ export default function GoogleWorkspaceSettingsTab() { return (
    - -
    -
    -
    {__('Enable Google Drive', 'wedevs-project-manager')}
    -
    {__('Show Google Drive in the sidebar and on tasks. Turn off to hide it everywhere.', '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 users attach Drive files inside comments. Turn off to hide the Drive button in comments only.', 'wedevs-project-manager')}
    + {driveEnabled && ( +
    +
    +
    {__('Allow Drive in comments', 'wedevs-project-manager')}
    +
    {__('Let users attach Drive files inside comments. Turn off to hide the Drive button in comments only.', 'wedevs-project-manager')}
    +
    +
    - -
    - )} + )} +
    )} -

    {__('API credentials & keys', 'wedevs-project-manager')}

    +

    {__('API credentials & keys', 'wedevs-project-manager')}

    From f0c2d8291b78bc231ee09a980077e3d7c8b83896 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Mon, 22 Jun 2026 16:55:02 +0600 Subject: [PATCH 38/65] fix(google-workspace): settings header glyph violet (plugin accent), not multicolor Google --- .../tabs/GoogleWorkspaceSettingsTab.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx index e1413060f..6bc568dca 100644 --- a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -21,11 +21,11 @@ import ProBadge from '@components/common/ProBadge' const DOCS_URL = 'https://wedevs.com/docs/wp-project-manager/integrations/google-workspace/' const GoogleGlyph = (props) => ( - - - - - + + + + + ) @@ -128,7 +128,7 @@ export default function GoogleWorkspaceSettingsTab() {
    - +

    {__('Google Workspace', 'wedevs-project-manager')}

    From ac13e548f1e1911de7b61d15b57133f23abed13a Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Mon, 22 Jun 2026 16:58:54 +0600 Subject: [PATCH 39/65] fix(google-workspace): settings header glyph inline + w-5 (match Pusher); remove Drive icon from Enable Google Drive toggle --- .../tabs/GoogleWorkspaceSettingsTab.jsx | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx index 6bc568dca..68f9b7ef6 100644 --- a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -29,13 +29,6 @@ const GoogleGlyph = (props) => ( ) -const DriveLogo = (props) => ( - - - - - -) /** * Free, locked teaser for a Pro Google feature's settings (Calendar/Meet). @@ -127,14 +120,14 @@ export default function GoogleWorkspaceSettingsTab() { return (

    -
    - -
    -

    {__('Google Workspace', 'wedevs-project-manager')}

    -

    - {__('Set up your Google Cloud project once. Users then connect their own account.', 'wedevs-project-manager')} -

    -
    +
    +

    + + {__('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')}
    -
    +
    +
    {__('Enable Google Drive', 'wedevs-project-manager')}
    +
    {__('Show Google Drive in the sidebar and on tasks. Turn off to hide it everywhere.', 'wedevs-project-manager')}
    From 43629a38b82e456f081f6021486178ff16d1f1f4 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Mon, 22 Jun 2026 23:33:24 +0600 Subject: [PATCH 40/65] feat(google-workspace): incremental Meet scope (meetings.space.created) + meet_connected status; getAuthUrl supports withMeet --- src/Google_Workspace/Controllers/OAuth_Controller.php | 4 ++++ src/Google_Workspace/Google_Client.php | 3 +++ src/Google_Workspace/Google_Service.php | 5 +++++ src/Google_Workspace/Loader.php | 1 + views/assets/src/store/googleWorkspaceSlice.js | 8 +++++--- 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Google_Workspace/Controllers/OAuth_Controller.php b/src/Google_Workspace/Controllers/OAuth_Controller.php index 2567cb525..8a3e747bb 100644 --- a/src/Google_Workspace/Controllers/OAuth_Controller.php +++ b/src/Google_Workspace/Controllers/OAuth_Controller.php @@ -24,6 +24,7 @@ public function status( WP_REST_Request $request ) { '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_meet( get_current_user_id() ), 'drive_user_on' => Google_Service::user_drive_enabled( get_current_user_id() ), 'drive_comments_on' => Google_Service::drive_comments_enabled(), ], @@ -54,6 +55,9 @@ public function auth_url( WP_REST_Request $request ) { if ( $request->get_param( 'with_calendar' ) ) { $scope .= ' ' . Google_Client::CALENDAR_SCOPE; } + if ( $request->get_param( 'with_meet' ) ) { + $scope .= ' ' . Google_Client::MEET_SCOPE; + } return [ 'data' => [ diff --git a/src/Google_Workspace/Google_Client.php b/src/Google_Workspace/Google_Client.php index dd48f3bb4..21aba5573 100644 --- a/src/Google_Workspace/Google_Client.php +++ b/src/Google_Workspace/Google_Client.php @@ -21,6 +21,9 @@ class Google_Client { // in via the Pro Calendar settings), so free-only users never grant it. const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.events'; + // Pro Meet feature — incremental scope to create Meet spaces (meeting links). + const MEET_SCOPE = 'https://www.googleapis.com/auth/meetings.space.created'; + private $client_id; private $client_secret; private $redirect_uri; diff --git a/src/Google_Workspace/Google_Service.php b/src/Google_Workspace/Google_Service.php index 9d14c5f17..7d1a4d4f8 100644 --- a/src/Google_Workspace/Google_Service.php +++ b/src/Google_Workspace/Google_Service.php @@ -141,6 +141,11 @@ public static function user_has_calendar( $user_id ) { return self::user_has_scope( $user_id, Google_Client::CALENDAR_SCOPE ); } + /** Whether the user has granted the Meet scope (Pro Meet feature). */ + public static function user_has_meet( $user_id ) { + return self::user_has_scope( $user_id, Google_Client::MEET_SCOPE ); + } + public static function get_user_token( $user_id ) { if ( empty( $user_id ) ) { return null; diff --git a/src/Google_Workspace/Loader.php b/src/Google_Workspace/Loader.php index f7f52a614..22fd26567 100644 --- a/src/Google_Workspace/Loader.php +++ b/src/Google_Workspace/Loader.php @@ -74,6 +74,7 @@ public function localize( $localize ) { '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_meet( 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(), diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js index 814ed730c..45810c398 100644 --- a/views/assets/src/store/googleWorkspaceSlice.js +++ b/views/assets/src/store/googleWorkspaceSlice.js @@ -4,7 +4,7 @@ import { useApi } from '@hooks/useApi' const api = useApi() const initialState = { - status: { configured: false, picker_ready: false, drive_enabled: false, connected: false, account_email: '', expired: false, calendar_connected: false, drive_user_on: true, drive_comments_on: true }, + status: { configured: false, picker_ready: false, drive_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, settingsLoading: false, @@ -49,8 +49,10 @@ export const saveSettings = createAsyncThunk( export const getAuthUrl = createAsyncThunk( 'googleWorkspace/getAuthUrl', async (arg, { rejectWithValue }) => { - const withCalendar = arg && arg.withCalendar - try { const res = await api.get('google-workspace/auth-url', withCalendar ? { with_calendar: 1 } : undefined); return (res.data ?? res).auth_url } + const params = {} + if (arg && arg.withCalendar) params.with_calendar = 1 + if (arg && arg.withMeet) params.with_meet = 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) } }, ) From b09de19f77d825a1bdeca9ea41189d8f9d308f7c Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Tue, 23 Jun 2026 00:37:25 +0600 Subject: [PATCH 41/65] fix(google-workspace): reset calendar/meet connected state on disconnect --- views/assets/src/store/googleWorkspaceSlice.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js index 45810c398..a39a0bd85 100644 --- a/views/assets/src/store/googleWorkspaceSlice.js +++ b/views/assets/src/store/googleWorkspaceSlice.js @@ -191,7 +191,7 @@ const slice = createSlice({ .addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready; s.status.drive_enabled = a.payload.drive_enabled; s.status.drive_comments_on = a.payload.drive_comments }) .addCase(saveSettings.rejected, (s) => { s.saving = false }) - .addCase(disconnect.fulfilled, (s) => { s.status = { ...s.status, connected: false, account_email: '', expired: false, calendar_connected: 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 }) From b6b771e0ce081c8156861e1460c0d6b17227aadd Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Tue, 23 Jun 2026 00:46:16 +0600 Subject: [PATCH 42/65] fix(google-workspace): Drive toggle off when account disconnected --- .../src/components/google-workspace/GoogleWorkspacePage.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx index 73456ed83..4461fc2a5 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -177,7 +177,7 @@ export default function GoogleWorkspacePage() {
    dispatch(saveDrivePref({ drive_on: v }))} /> From c6e13786b24e23e1777a24216abb297b0c15e5b6 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Tue, 23 Jun 2026 07:54:57 +0600 Subject: [PATCH 43/65] feat(google-workspace): comment.composer.action slot in task + discussion comment composers --- src/Google_Workspace/Controllers/OAuth_Controller.php | 2 +- src/Google_Workspace/Loader.php | 2 +- .../projects/DiscussionsPage/DiscussionDetailPage.jsx | 2 ++ .../src/components/tasks/TaskDetailSheet/index.jsx | 10 +++++++--- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Google_Workspace/Controllers/OAuth_Controller.php b/src/Google_Workspace/Controllers/OAuth_Controller.php index 8a3e747bb..ebebe7d73 100644 --- a/src/Google_Workspace/Controllers/OAuth_Controller.php +++ b/src/Google_Workspace/Controllers/OAuth_Controller.php @@ -24,7 +24,7 @@ public function status( WP_REST_Request $request ) { '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_meet( 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(), ], diff --git a/src/Google_Workspace/Loader.php b/src/Google_Workspace/Loader.php index 22fd26567..4b1f8eef8 100644 --- a/src/Google_Workspace/Loader.php +++ b/src/Google_Workspace/Loader.php @@ -74,7 +74,7 @@ public function localize( $localize ) { '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_meet( 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(), diff --git a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx index eb147c9bc..9c726dd61 100644 --- a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx +++ b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx @@ -48,6 +48,7 @@ import { formatPmDateTime } from "@lib/pm-utils"; import { usePermissions } from "@hooks/usePermissions"; import GoogleDriveAttach from "@components/google-workspace/GoogleDriveAttach"; import GoogleDriveCommentButton from "@components/google-workspace/GoogleDriveCommentButton"; +import { Slot } from "@hooks/useSlot"; import { useCurrentProject } from "@hooks/useCurrentProject"; import DiscussionFiles from "./parts/DiscussionFiles"; @@ -605,6 +606,7 @@ export default function DiscussionDetailPage() { value={commentNotifyUsers} onChange={setCommentNotifyUsers} /> + setNewComment(prev => (prev || '') + html)} />
    diff --git a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx index e5afa21ce..a627a64fb 100644 --- a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx +++ b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx @@ -28,6 +28,7 @@ import { sanitizeHtml } from '@lib/sanitize' import FileUploadArea from '@components/common/FileUploadArea' import CommentAttachment from '@components/common/CommentAttachment' import GoogleDriveCommentButton from '@components/google-workspace/GoogleDriveCommentButton' +import { Slot } from '@hooks/useSlot' import TaskStatusCircle from '@components/common/TaskStatusCircle' import NotifyUsers from '@components/common/NotifyUsers' import { UserAvatar } from '@components/common/UserAvatar' @@ -874,9 +875,12 @@ export default function TaskDetailSheet() { value={commentNotifyUsers} onChange={setCommentNotifyUsers} /> - +
    + + setNewComment(prev => (prev || '') + html)} /> +
    From 88558283e0b575a48810be11e5f1ffb67c251d83 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Tue, 23 Jun 2026 13:39:59 +0600 Subject: [PATCH 44/65] fix(comment): guard null parent_comment/commentable in activity logging (was fatal -> 500 on add comment) --- src/Comment/Observers/Comment_Observer.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Comment/Observers/Comment_Observer.php b/src/Comment/Observers/Comment_Observer.php index ee7aae966..ef3970ec9 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': From c23d629134bf0d2e98a0610432f29a9e61d0d9f8 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Tue, 23 Jun 2026 14:08:07 +0600 Subject: [PATCH 45/65] fix(google-workspace): show Drive attachments under task + discussion comments (was attached but not rendered) --- .../projects/DiscussionsPage/DiscussionDetailPage.jsx | 3 +++ views/assets/src/components/tasks/TaskDetailSheet/index.jsx | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx index 9c726dd61..8a75b2912 100644 --- a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx +++ b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx @@ -568,6 +568,9 @@ export default function DiscussionDetailPage() { +
    + +
    )}
    diff --git a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx index a627a64fb..d19fd7e59 100644 --- a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx +++ b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx @@ -28,6 +28,7 @@ import { sanitizeHtml } from '@lib/sanitize' import FileUploadArea from '@components/common/FileUploadArea' import CommentAttachment from '@components/common/CommentAttachment' import GoogleDriveCommentButton from '@components/google-workspace/GoogleDriveCommentButton' +import GoogleDriveAttach from '@components/google-workspace/GoogleDriveAttach' import { Slot } from '@hooks/useSlot' import TaskStatusCircle from '@components/common/TaskStatusCircle' import NotifyUsers from '@components/common/NotifyUsers' @@ -854,6 +855,11 @@ export default function TaskDetailSheet() { ))}
    )} + {!isEditing && ( +
    + +
    + )}
    ) From 07dcaf4c71f057aa7cb0784850741dc9c15ca818 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Tue, 23 Jun 2026 14:19:50 +0600 Subject: [PATCH 46/65] feat(google-workspace): unified Drive+Meet link insertion in comment composers (new + edit); drop chips + per-comment attach button (CommentLinkActions) --- .../google-workspace/CommentLinkActions.jsx | 19 +++++ .../google-workspace/DriveCommentInsert.jsx | 70 +++++++++++++++++++ .../DiscussionsPage/DiscussionDetailPage.jsx | 10 +-- .../tasks/TaskDetailSheet/index.jsx | 13 +--- views/assets/src/index.jsx | 1 + 5 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 views/assets/src/components/google-workspace/CommentLinkActions.jsx create mode 100644 views/assets/src/components/google-workspace/DriveCommentInsert.jsx 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..e0622c51b --- /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 }) { + if (!projectId || typeof onInsert !== 'function') return null + return ( +
    + + +
    + ) +} 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..e36950ba0 --- /dev/null +++ b/views/assets/src/components/google-workspace/DriveCommentInsert.jsx @@ -0,0 +1,70 @@ +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 DrivePickerModal from './DrivePickerModal' + +const MonoDrive = ({ className = 'h-3.5 w-3.5' }) => ( + +) + +function linkHtml(f) { + const url = f.webViewLink || f.url + if (!url) return '' + return `

    📎 ${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/projects/DiscussionsPage/DiscussionDetailPage.jsx b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx index 8a75b2912..bd574931f 100644 --- a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx +++ b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx @@ -47,8 +47,7 @@ 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 GoogleDriveCommentButton from "@components/google-workspace/GoogleDriveCommentButton"; -import { Slot } from "@hooks/useSlot"; +import CommentLinkActions from "@components/google-workspace/CommentLinkActions"; import { useCurrentProject } from "@hooks/useCurrentProject"; import DiscussionFiles from "./parts/DiscussionFiles"; @@ -500,7 +499,6 @@ export default function DiscussionDetailPage() { {canEditComment(c) && (
    - + setEditCommentText(prev => (prev || '') + html)} />
    ) : ( @@ -568,9 +567,6 @@ export default function DiscussionDetailPage() { -
    - -
    )}
    @@ -609,7 +605,7 @@ export default function DiscussionDetailPage() { value={commentNotifyUsers} onChange={setCommentNotifyUsers} /> - setNewComment(prev => (prev || '') + html)} /> + setNewComment(prev => (prev || '') + html)} />
    diff --git a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx index d19fd7e59..b51a53f49 100644 --- a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx +++ b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx @@ -27,9 +27,7 @@ import { stripAllPreviewUrls } from '@/lib/url-strippers' import { sanitizeHtml } from '@lib/sanitize' import FileUploadArea from '@components/common/FileUploadArea' import CommentAttachment from '@components/common/CommentAttachment' -import GoogleDriveCommentButton from '@components/google-workspace/GoogleDriveCommentButton' -import GoogleDriveAttach from '@components/google-workspace/GoogleDriveAttach' -import { Slot } from '@hooks/useSlot' +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' @@ -812,7 +810,6 @@ export default function TaskDetailSheet() { {formatPmDateTime(comment.created_at)} {canEdit && !isEditing && ( - @@ -838,6 +835,7 @@ export default function TaskDetailSheet() { {savingEditComment ? __('Saving...', 'wedevs-project-manager') : __('Save', 'wedevs-project-manager')} + setEditCommentText(prev => (prev || '') + html)} />
    ) : ( @@ -855,11 +853,6 @@ export default function TaskDetailSheet() { ))}
    )} - {!isEditing && ( -
    - -
    - )}
    ) @@ -885,7 +878,7 @@ export default function TaskDetailSheet() { - setNewComment(prev => (prev || '') + html)} /> + setNewComment(prev => (prev || '') + html)} />
    diff --git a/views/assets/src/index.jsx b/views/assets/src/index.jsx index 393f975ea..e4f8cc2e7 100644 --- a/views/assets/src/index.jsx +++ b/views/assets/src/index.jsx @@ -276,6 +276,7 @@ window.PM = { 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'), From 72e00c36f6ecdc73d4dfe0486396c551ed53503f Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Tue, 23 Jun 2026 14:25:45 +0600 Subject: [PATCH 47/65] fix(google-workspace): drop emoji from inserted Drive link (4-byte emoji broke save on utf8mb3 comment column) --- .../src/components/google-workspace/DriveCommentInsert.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/assets/src/components/google-workspace/DriveCommentInsert.jsx b/views/assets/src/components/google-workspace/DriveCommentInsert.jsx index e36950ba0..4234b864e 100644 --- a/views/assets/src/components/google-workspace/DriveCommentInsert.jsx +++ b/views/assets/src/components/google-workspace/DriveCommentInsert.jsx @@ -22,7 +22,7 @@ const MonoDrive = ({ className = 'h-3.5 w-3.5' }) => ( function linkHtml(f) { const url = f.webViewLink || f.url if (!url) return '' - return `

    📎 ${f.name || __('Drive file', 'wedevs-project-manager')}

    ` + return `

    ${f.name || __('Drive file', 'wedevs-project-manager')}

    ` } export default function DriveCommentInsert({ projectId, onInsert }) { From f0f4515ccb2e30ea54c70bfe5d2a1b50074aa271 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Tue, 23 Jun 2026 14:35:48 +0600 Subject: [PATCH 48/65] feat(google-workspace): icon-only Drive/Meet composer buttons; add Meeting to discussion create form --- .../src/components/google-workspace/DriveCommentInsert.jsx | 4 ++-- .../src/components/projects/DiscussionsPage/index.jsx | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/views/assets/src/components/google-workspace/DriveCommentInsert.jsx b/views/assets/src/components/google-workspace/DriveCommentInsert.jsx index 4234b864e..3ed01bec0 100644 --- a/views/assets/src/components/google-workspace/DriveCommentInsert.jsx +++ b/views/assets/src/components/google-workspace/DriveCommentInsert.jsx @@ -55,8 +55,8 @@ export default function DriveCommentInsert({ projectId, onInsert }) { return ( <> - {pickerOpen && ( - +
    + + setFormDesc(prev => (prev || '') + html)} /> +
    Date: Tue, 23 Jun 2026 15:04:43 +0600 Subject: [PATCH 49/65] feat(google-workspace): unify Drive icon in discussion create (CommentLinkActions); allowMeet flag --- .../components/google-workspace/CommentLinkActions.jsx | 4 ++-- .../src/components/projects/DiscussionsPage/index.jsx | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/views/assets/src/components/google-workspace/CommentLinkActions.jsx b/views/assets/src/components/google-workspace/CommentLinkActions.jsx index e0622c51b..0c4459081 100644 --- a/views/assets/src/components/google-workspace/CommentLinkActions.jsx +++ b/views/assets/src/components/google-workspace/CommentLinkActions.jsx @@ -8,12 +8,12 @@ import React from 'react' import DriveCommentInsert from './DriveCommentInsert' import { Slot } from '@hooks/useSlot' -export default function CommentLinkActions({ projectId, onInsert }) { +export default function CommentLinkActions({ projectId, onInsert, allowMeet = true }) { if (!projectId || typeof onInsert !== 'function') return null return (
    - + {allowMeet && }
    ) } diff --git a/views/assets/src/components/projects/DiscussionsPage/index.jsx b/views/assets/src/components/projects/DiscussionsPage/index.jsx index 902387f7a..811817188 100644 --- a/views/assets/src/components/projects/DiscussionsPage/index.jsx +++ b/views/assets/src/components/projects/DiscussionsPage/index.jsx @@ -28,8 +28,7 @@ import { ChevronRight, } from "lucide-react"; import FileUploadArea from "@components/common/FileUploadArea"; -import GoogleDriveStage from "@components/google-workspace/GoogleDriveStage"; -import { Slot } from "@hooks/useSlot"; +import CommentLinkActions from "@components/google-workspace/CommentLinkActions"; import { useAppDispatch } from "@store/index"; import { attachFileFor } from "@store/googleWorkspaceSlice"; import NotifyUsers from "@components/common/NotifyUsers"; @@ -245,10 +244,7 @@ export default function DiscussionsPage() { -
    - - setFormDesc(prev => (prev || '') + html)} /> -
    + setFormDesc(prev => (prev || '') + html)} /> Date: Tue, 23 Jun 2026 17:53:58 +0600 Subject: [PATCH 50/65] fix(google-workspace): normalize sidebar Drive nav glyph size/centering (viewBox padding) to align with label --- views/assets/src/components/layout/AppSidebar.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index 08b189b74..170ee7e9c 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -16,7 +16,7 @@ import { cn } from '@lib/utils' // Google Drive glyph (2026 mark), outlined to match the lucide nav icons. const GoogleDriveNavIcon = (props) => ( - + From 32c0effa01112608951bfd204493583763b7718b Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Tue, 23 Jun 2026 18:10:38 +0600 Subject: [PATCH 51/65] revert: sidebar Drive nav glyph viewBox back to 0 0 24 24 (no resize) --- views/assets/src/components/layout/AppSidebar.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index 170ee7e9c..08b189b74 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -16,7 +16,7 @@ import { cn } from '@lib/utils' // Google Drive glyph (2026 mark), outlined to match the lucide nav icons. const GoogleDriveNavIcon = (props) => ( - + From 231d7b9e9d9354d262ff55d93bb7a45e4dc1f12a Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Tue, 23 Jun 2026 18:14:29 +0600 Subject: [PATCH 52/65] fix(google-workspace): top-align sidebar Google icon with the label (no resize) --- views/assets/src/components/layout/AppSidebar.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index 08b189b74..bac1d0f13 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -328,7 +328,7 @@ export function AppSidebar() { )} title={item.label} > - + {collapsed ? {item.short ?? item.label} : {item.label} From 3e9fcd9231f3208bc0a08d12b7c06af22473a25e Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Tue, 23 Jun 2026 18:22:48 +0600 Subject: [PATCH 53/65] style(google-meet): update Meet brand icon (colored places); monochrome composer unchanged --- .../components/google-workspace/GoogleIcons.jsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/views/assets/src/components/google-workspace/GoogleIcons.jsx b/views/assets/src/components/google-workspace/GoogleIcons.jsx index 505b9bcd3..a9b594820 100644 --- a/views/assets/src/components/google-workspace/GoogleIcons.jsx +++ b/views/assets/src/components/google-workspace/GoogleIcons.jsx @@ -16,13 +16,16 @@ export const CalendarGlyph = (props) => ( ) -/** Google Meet brand glyph (simplified). Size via className. */ +/** Google Meet brand glyph. Size via className. */ export const MeetGlyph = (props) => ( - - - - - + + + + + + + + ) From 24736295bfdc881e834ec97a2bf3054d37723660 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Wed, 24 Jun 2026 19:02:48 +0600 Subject: [PATCH 54/65] feat(google-workspace): log Drive attach/detach + Meet activity, monochrome marks in activity feeds --- .../Transformers/Activity_Transformer.php | 14 +++++ src/Comment/Observers/Comment_Observer.php | 13 +++++ .../Controllers/Drive_Controller.php | 37 +++++++++++++ .../google-workspace/GoogleIcons.jsx | 9 ++++ .../projects/ActivitiesPage/constants.js | 10 +++- .../ActivitiesPage/parts/ActivityItem.jsx | 52 ++++++++++++++----- 6 files changed, 122 insertions(+), 13 deletions(-) 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 ef3970ec9..dca9cf1fb 100644 --- a/src/Comment/Observers/Comment_Observer.php +++ b/src/Comment/Observers/Comment_Observer.php @@ -73,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/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php index 620464b18..61186b181 100644 --- a/src/Google_Workspace/Controllers/Drive_Controller.php +++ b/src/Google_Workspace/Controllers/Drive_Controller.php @@ -4,6 +4,8 @@ use WP_REST_Request; use WeDevs\PM\Google_Workspace\Google_Service; use WeDevs\PM\Google_Workspace\Models\Google_Drive_File; +use WeDevs\PM\Activity\Models\Activity; +use WeDevs\PM\Task\Models\Task; use Carbon\Carbon; if ( ! defined( 'ABSPATH' ) ) exit; @@ -173,6 +175,24 @@ public function attach( WP_REST_Request $request ) { do_action( 'pm_google_drive_file_attached', $row, $type, $id, $project_id ); + if ( $type === 'task' ) { + $task = Task::find( $id ); + Activity::create( [ + 'actor_id' => get_current_user_id(), + 'action' => 'attach_drive_file', + 'action_type' => 'create', + 'resource_id' => $id, + 'resource_type' => 'task', + 'meta' => [ + 'task_title' => $task ? $task->title : '', + 'file_name' => $row->name, + 'file_url' => $row->web_view_link, + 'has_drive' => true, + ], + 'project_id' => $project_id, + ] ); + } + return [ 'data' => $this->transform( $row ) ]; } @@ -191,6 +211,23 @@ public function destroy( WP_REST_Request $request ) { 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 ); + + if ( $row->attachable_type === 'task' ) { + $task = Task::find( $row->attachable_id ); + Activity::create( [ + 'actor_id' => get_current_user_id(), + 'action' => 'detach_drive_file', + 'action_type' => 'delete', + 'resource_id' => (int) $row->attachable_id, + 'resource_type' => 'task', + 'meta' => [ + 'task_title' => $task ? $task->title : '', + 'file_name' => $row->name, + ], + 'project_id' => $project_id, + ] ); + } + $row->delete(); } diff --git a/views/assets/src/components/google-workspace/GoogleIcons.jsx b/views/assets/src/components/google-workspace/GoogleIcons.jsx index a9b594820..adde4ef83 100644 --- a/views/assets/src/components/google-workspace/GoogleIcons.jsx +++ b/views/assets/src/components/google-workspace/GoogleIcons.jsx @@ -16,6 +16,15 @@ 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) => ( 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)}

    - )} +
    + {isTask ? ( + + ) : ( +

    {parseMessage(act)}

    + )} + {marks} +
    {timeStr && ( - {timeStr} + {timeStr} )}
    From 2012098023a2a2eaadef6086a6d4b49435f2315b Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Wed, 24 Jun 2026 19:02:48 +0600 Subject: [PATCH 55/65] feat(google-workspace): decorate Drive/Meet links in comment view (file-type icons + Meet card); expose decorateGoogleLinks on window.PM --- .../DiscussionsPage/DiscussionDetailPage.jsx | 5 +- .../components/tasks/SingleTaskListPage.jsx | 3 +- .../tasks/TaskDetailSheet/index.jsx | 17 +++- views/assets/src/index.jsx | 1 + views/assets/src/lib/google-links.js | 83 +++++++++++++++++++ 5 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 views/assets/src/lib/google-links.js diff --git a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx index bd574931f..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"; @@ -457,7 +458,7 @@ export default function DiscussionDetailPage() {
    @@ -560,7 +561,7 @@ export default function DiscussionDetailPage() {
    diff --git a/views/assets/src/components/tasks/SingleTaskListPage.jsx b/views/assets/src/components/tasks/SingleTaskListPage.jsx index 10b35f814..ed3c4250b 100644 --- a/views/assets/src/components/tasks/SingleTaskListPage.jsx +++ b/views/assets/src/components/tasks/SingleTaskListPage.jsx @@ -21,6 +21,7 @@ import { formatPmDateTime } from '@lib/pm-utils' import TaskRow from './TaskRow' import TaskDetailSheet from './TaskDetailSheet' import { sanitizeHtml } from '@lib/sanitize' +import { decorateGoogleLinks } from '@lib/google-links' function extractMentionedUsers(html) { const parser = new DOMParser() @@ -407,7 +408,7 @@ export default function SingleTaskListPage() {
    ) : ( -
    +
    )} {/* 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 b51a53f49..1fbd81b79 100644 --- a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx +++ b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx @@ -25,6 +25,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 FileUploadArea from '@components/common/FileUploadArea' import CommentAttachment from '@components/common/CommentAttachment' import CommentLinkActions from '@components/google-workspace/CommentLinkActions' @@ -63,7 +64,9 @@ import { Pencil, FileText, Loader2, + Video, } from 'lucide-react' +import { DriveMonoGlyph } from '@components/google-workspace/GoogleIcons' import { isTaskComplete, formatPmDate, @@ -840,7 +843,7 @@ export default function TaskDetailSheet() {
    ) : ( <> -
    +
    @@ -947,6 +950,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 && ( +
    diff --git a/views/assets/src/index.jsx b/views/assets/src/index.jsx index e4f8cc2e7..10fa18d94 100644 --- a/views/assets/src/index.jsx +++ b/views/assets/src/index.jsx @@ -350,6 +350,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..c31053ece --- /dev/null +++ b/views/assets/src/lib/google-links.js @@ -0,0 +1,83 @@ +/** + * 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 `` +} + +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)) { + 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' + card.innerHTML = + `${iconHtml('meet')}${text || 'Google Meet'}` + + `Join Google Meet` + 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 +} From 4e3238cfa78a1a612f72ba2b52ae8242cd93cd2c Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Wed, 24 Jun 2026 19:02:48 +0600 Subject: [PATCH 56/65] copy(google-workspace): use 'team members' wording in Drive comments setting --- .../admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx index 68f9b7ef6..e9b555019 100644 --- a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -152,7 +152,7 @@ export default function GoogleWorkspaceSettingsTab() {
    {__('Allow Drive in comments', 'wedevs-project-manager')}
    -
    {__('Let users attach Drive files inside comments. Turn off to hide the Drive button in comments only.', 'wedevs-project-manager')}
    +
    {__('Let team members attach Drive files inside comments. Turn off to hide the Drive button in comments only.', 'wedevs-project-manager')}
    From 2aeeb4556e40e2c75b241959490c0d55cebaf923 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Thu, 25 Jun 2026 09:28:57 +0600 Subject: [PATCH 57/65] =?UTF-8?q?chore(google-workspace):=20remove=20dead?= =?UTF-8?q?=20Meet=20scope=20chain=20(MEET=5FSCOPE/user=5Fhas=5Fmeet/with?= =?UTF-8?q?=5Fmeet)=20=E2=80=94=20Meet=20uses=20calendar.events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Google_Workspace/Controllers/OAuth_Controller.php | 3 --- src/Google_Workspace/Google_Client.php | 3 --- src/Google_Workspace/Google_Service.php | 5 ----- views/assets/src/store/googleWorkspaceSlice.js | 1 - 4 files changed, 12 deletions(-) diff --git a/src/Google_Workspace/Controllers/OAuth_Controller.php b/src/Google_Workspace/Controllers/OAuth_Controller.php index ebebe7d73..0a6981157 100644 --- a/src/Google_Workspace/Controllers/OAuth_Controller.php +++ b/src/Google_Workspace/Controllers/OAuth_Controller.php @@ -55,9 +55,6 @@ public function auth_url( WP_REST_Request $request ) { if ( $request->get_param( 'with_calendar' ) ) { $scope .= ' ' . Google_Client::CALENDAR_SCOPE; } - if ( $request->get_param( 'with_meet' ) ) { - $scope .= ' ' . Google_Client::MEET_SCOPE; - } return [ 'data' => [ diff --git a/src/Google_Workspace/Google_Client.php b/src/Google_Workspace/Google_Client.php index 21aba5573..dd48f3bb4 100644 --- a/src/Google_Workspace/Google_Client.php +++ b/src/Google_Workspace/Google_Client.php @@ -21,9 +21,6 @@ class Google_Client { // in via the Pro Calendar settings), so free-only users never grant it. const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.events'; - // Pro Meet feature — incremental scope to create Meet spaces (meeting links). - const MEET_SCOPE = 'https://www.googleapis.com/auth/meetings.space.created'; - private $client_id; private $client_secret; private $redirect_uri; diff --git a/src/Google_Workspace/Google_Service.php b/src/Google_Workspace/Google_Service.php index 7d1a4d4f8..9d14c5f17 100644 --- a/src/Google_Workspace/Google_Service.php +++ b/src/Google_Workspace/Google_Service.php @@ -141,11 +141,6 @@ public static function user_has_calendar( $user_id ) { return self::user_has_scope( $user_id, Google_Client::CALENDAR_SCOPE ); } - /** Whether the user has granted the Meet scope (Pro Meet feature). */ - public static function user_has_meet( $user_id ) { - return self::user_has_scope( $user_id, Google_Client::MEET_SCOPE ); - } - public static function get_user_token( $user_id ) { if ( empty( $user_id ) ) { return null; diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js index a39a0bd85..e86eebbda 100644 --- a/views/assets/src/store/googleWorkspaceSlice.js +++ b/views/assets/src/store/googleWorkspaceSlice.js @@ -51,7 +51,6 @@ export const getAuthUrl = createAsyncThunk( async (arg, { rejectWithValue }) => { const params = {} if (arg && arg.withCalendar) params.with_calendar = 1 - if (arg && arg.withMeet) params.with_meet = 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) } }, From 0d23756f65b7d2d207f4891323877282762b708e Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Thu, 25 Jun 2026 09:28:57 +0600 Subject: [PATCH 58/65] =?UTF-8?q?security(google-workspace):=20prevent=20i?= =?UTF-8?q?njected=20markup=20in=20comment=20link=20rendering=20=E2=80=94?= =?UTF-8?q?=20DOM-build=20Meet=20card=20with=20textContent,=20escape=20+?= =?UTF-8?q?=20http(s)-validate=20inserted=20Drive/Meet=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../google-workspace/DriveCommentInsert.jsx | 12 +++++-- views/assets/src/lib/google-links.js | 35 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/views/assets/src/components/google-workspace/DriveCommentInsert.jsx b/views/assets/src/components/google-workspace/DriveCommentInsert.jsx index 3ed01bec0..49efc99f0 100644 --- a/views/assets/src/components/google-workspace/DriveCommentInsert.jsx +++ b/views/assets/src/components/google-workspace/DriveCommentInsert.jsx @@ -19,10 +19,18 @@ const MonoDrive = ({ className = 'h-3.5 w-3.5' }) => ( ) +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 = f.webViewLink || f.url + const url = safeHttpUrl(f.webViewLink || f.url) if (!url) return '' - return `

    ${f.name || __('Drive file', 'wedevs-project-manager')}

    ` + return `

    ${esc(f.name || __('Drive file', 'wedevs-project-manager'))}

    ` } export default function DriveCommentInsert({ projectId, onInsert }) { diff --git a/views/assets/src/lib/google-links.js b/views/assets/src/lib/google-links.js index c31053ece..ad2d2bb5b 100644 --- a/views/assets/src/lib/google-links.js +++ b/views/assets/src/lib/google-links.js @@ -46,6 +46,16 @@ function iconHtml(kind) { return `` } +/** 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 @@ -62,12 +72,31 @@ export function decorateGoogleLinks(html) { 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' - card.innerHTML = - `${iconHtml('meet')}${text || 'Google Meet'}` + - `Join Google Meet` + + 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 } From 8b8f10348ac9c7dba75b9fa91c2f55ce463fa614 Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Thu, 25 Jun 2026 19:12:04 +0600 Subject: [PATCH 59/65] fix(google-workspace): add G Workspace to admin submenu under Sprints; sidebar any-feature gate + reactive; active-route highlight for /google-workspace --- core/WP/Menu.php | 30 +++++++++++++++++++ .../src/components/layout/AppSidebar.jsx | 15 ++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) 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/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index bac1d0f13..613b3c28b 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' @@ -233,6 +234,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 = [ @@ -249,11 +259,11 @@ export function AppSidebar() { } // Google Workspace — free feature, shown to everyone when the admin has // enabled Google Drive in Settings → Google Workspace. - if (typeof PM_Vars !== 'undefined' && PM_Vars.google_workspace?.drive_enabled) { + 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) @@ -298,6 +308,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('/importtools')) return 'importtools' if (path.startsWith('/license')) return 'license' return 'projects' From 6d7fc47827033266d6f95218e3a92b5dbae3251f Mon Sep 17 00:00:00 2001 From: anik-fahmid Date: Thu, 25 Jun 2026 19:12:04 +0600 Subject: [PATCH 60/65] fix(google-workspace): enforce Drive master toggle on usage + Drive card off-state; align access route perms to Project_Settings_Page_Access; localize calendar/meet master flags; drop dead Meet scope --- routes/google-workspace.php | 3 +- .../Controllers/Drive_Controller.php | 37 ------------------- .../Controllers/OAuth_Controller.php | 2 + .../Controllers/Settings_Controller.php | 2 + src/Google_Workspace/Google_Service.php | 17 +++++++++ src/Google_Workspace/Loader.php | 2 + .../google-workspace/GoogleWorkspacePage.jsx | 31 +++++++++++----- .../assets/src/store/googleWorkspaceSlice.js | 7 ++-- 8 files changed, 51 insertions(+), 50 deletions(-) diff --git a/routes/google-workspace.php b/routes/google-workspace.php index 7cd3fdff9..4d2f808e1 100644 --- a/routes/google-workspace.php +++ b/routes/google-workspace.php @@ -33,7 +33,8 @@ ->permission( [ $gw_auth ] ); // ── Per-project Drive role access (manager configures; members query) ── -$gw_manager = 'WeDevs\PM\Core\Permissions\Project_Manage_Capability'; +// 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 ] ); diff --git a/src/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php index 61186b181..620464b18 100644 --- a/src/Google_Workspace/Controllers/Drive_Controller.php +++ b/src/Google_Workspace/Controllers/Drive_Controller.php @@ -4,8 +4,6 @@ use WP_REST_Request; use WeDevs\PM\Google_Workspace\Google_Service; use WeDevs\PM\Google_Workspace\Models\Google_Drive_File; -use WeDevs\PM\Activity\Models\Activity; -use WeDevs\PM\Task\Models\Task; use Carbon\Carbon; if ( ! defined( 'ABSPATH' ) ) exit; @@ -175,24 +173,6 @@ public function attach( WP_REST_Request $request ) { do_action( 'pm_google_drive_file_attached', $row, $type, $id, $project_id ); - if ( $type === 'task' ) { - $task = Task::find( $id ); - Activity::create( [ - 'actor_id' => get_current_user_id(), - 'action' => 'attach_drive_file', - 'action_type' => 'create', - 'resource_id' => $id, - 'resource_type' => 'task', - 'meta' => [ - 'task_title' => $task ? $task->title : '', - 'file_name' => $row->name, - 'file_url' => $row->web_view_link, - 'has_drive' => true, - ], - 'project_id' => $project_id, - ] ); - } - return [ 'data' => $this->transform( $row ) ]; } @@ -211,23 +191,6 @@ public function destroy( WP_REST_Request $request ) { 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 ); - - if ( $row->attachable_type === 'task' ) { - $task = Task::find( $row->attachable_id ); - Activity::create( [ - 'actor_id' => get_current_user_id(), - 'action' => 'detach_drive_file', - 'action_type' => 'delete', - 'resource_id' => (int) $row->attachable_id, - 'resource_type' => 'task', - 'meta' => [ - 'task_title' => $task ? $task->title : '', - 'file_name' => $row->name, - ], - 'project_id' => $project_id, - ] ); - } - $row->delete(); } diff --git a/src/Google_Workspace/Controllers/OAuth_Controller.php b/src/Google_Workspace/Controllers/OAuth_Controller.php index 0a6981157..53d11a5c6 100644 --- a/src/Google_Workspace/Controllers/OAuth_Controller.php +++ b/src/Google_Workspace/Controllers/OAuth_Controller.php @@ -20,6 +20,8 @@ public function status( WP_REST_Request $request ) { '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'], diff --git a/src/Google_Workspace/Controllers/Settings_Controller.php b/src/Google_Workspace/Controllers/Settings_Controller.php index 2f0df49dd..77a6cefb9 100644 --- a/src/Google_Workspace/Controllers/Settings_Controller.php +++ b/src/Google_Workspace/Controllers/Settings_Controller.php @@ -22,6 +22,8 @@ public function get( WP_REST_Request $request ) { '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(), diff --git a/src/Google_Workspace/Google_Service.php b/src/Google_Workspace/Google_Service.php index 9d14c5f17..5ce38c490 100644 --- a/src/Google_Workspace/Google_Service.php +++ b/src/Google_Workspace/Google_Service.php @@ -84,6 +84,11 @@ public static function set_user_drive_enabled( $user_id, $on ) { 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; @@ -105,6 +110,18 @@ public static function drive_enabled() { 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', [] ); diff --git a/src/Google_Workspace/Loader.php b/src/Google_Workspace/Loader.php index 4b1f8eef8..74d43359c 100644 --- a/src/Google_Workspace/Loader.php +++ b/src/Google_Workspace/Loader.php @@ -70,6 +70,8 @@ public function localize( $localize ) { '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'], diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx index 4461fc2a5..b64c0cc02 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -166,8 +166,8 @@ export default function GoogleWorkspacePage() { {/* Connected services — one card per Google feature. */}

    {__('Connected services', 'wedevs-project-manager')}

    - {/* Google Drive (free) — per-user on/off */} -
    + {/* Google Drive (free) — admin master gate + per-user on/off */} +
    @@ -176,15 +176,28 @@ export default function GoogleWorkspacePage() {
    {__('Attach Drive files to tasks, comments, discussions and files.', 'wedevs-project-manager')}
    - dispatch(saveDrivePref({ drive_on: v }))} - /> + {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.connected && ( + {!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. */} diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js index e86eebbda..c2cd3ac2c 100644 --- a/views/assets/src/store/googleWorkspaceSlice.js +++ b/views/assets/src/store/googleWorkspaceSlice.js @@ -4,9 +4,10 @@ import { useApi } from '@hooks/useApi' const api = useApi() const initialState = { - status: { configured: false, picker_ready: false, drive_enabled: false, connected: false, account_email: '', expired: false, calendar_connected: false, meet_connected: false, drive_user_on: true, drive_comments_on: true }, + 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: {}, @@ -179,7 +180,7 @@ const slice = createSlice({ extraReducers: (builder) => { builder .addCase(fetchStatus.pending, (s) => { s.statusLoading = true }) - .addCase(fetchStatus.fulfilled, (s, a) => { s.statusLoading = false; s.status = a.payload }) + .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 }) @@ -187,7 +188,7 @@ const slice = createSlice({ .addCase(fetchSettings.rejected, (s) => { s.settingsLoading = false }) .addCase(saveSettings.pending, (s) => { s.saving = true }) - .addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready; s.status.drive_enabled = a.payload.drive_enabled; s.status.drive_comments_on = a.payload.drive_comments }) + .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 } }) From 3bdc23c38aff1fea190c2fe0eca55c92f73a741c Mon Sep 17 00:00:00 2001 From: Ariful Hoque Date: Fri, 3 Jul 2026 15:39:51 +0600 Subject: [PATCH 61/65] fix(drive): bind Drive attachments to the route project (IDOR) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Drive_Controller.php | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php index 620464b18..360cd6db7 100644 --- a/src/Google_Workspace/Controllers/Drive_Controller.php +++ b/src/Google_Workspace/Controllers/Drive_Controller.php @@ -4,6 +4,10 @@ use WP_REST_Request; use WeDevs\PM\Google_Workspace\Google_Service; use WeDevs\PM\Google_Workspace\Models\Google_Drive_File; +use WeDevs\PM\Task\Models\Task; +use WeDevs\PM\Comment\Models\Comment; +use WeDevs\PM\Discussion_Board\Models\Discussion_Board; +use WeDevs\PM\File\Models\File; use Carbon\Carbon; if ( ! defined( 'ABSPATH' ) ) exit; @@ -61,6 +65,42 @@ private function resolve_attachable( WP_REST_Request $request ) { return [ $type, $id ]; } + /** + * Resolve the real project that owns a polymorphic attachable target. + * Returns 0 when the target does not exist or the type is not verifiable. + */ + private function attachable_project_id( $type, $id ) { + $id = (int) $id; + + if ( ! $id ) { + return 0; + } + + switch ( $type ) { + case 'task': + $model = Task::find( $id ); + return $model ? (int) $model->project_id : 0; + + case 'comment': + $model = Comment::find( $id ); + return $model ? (int) $model->project_id : 0; + + case 'discussion': + $model = Discussion_Board::find( $id ); + return $model ? (int) $model->project_id : 0; + + case 'file': + $model = File::find( $id ); + return $model ? (int) $model->project_id : 0; + + case 'project': + return $id; + + default: + return 0; + } + } + /** * For comment attachments, restrict add/remove to the comment author or a * project manager (mirrors who can edit the comment). Other entity types @@ -84,14 +124,21 @@ private function can_manage_attachable( $type, $id, $project_id ) { } public function index( WP_REST_Request $request ) { + $project_id = (int) $request->get_param( 'project_id' ); list( $type, $id ) = $this->resolve_attachable( $request ); if ( $type === '' ) { return [ 'data' => [] ]; } + $owner_project_id = $this->attachable_project_id( $type, $id ); + if ( ! $owner_project_id || $owner_project_id !== $project_id ) { + return new \WP_Error( 'pm_google_forbidden', __( 'This attachment target does not belong to the current project.', 'wedevs-project-manager' ), [ 'status' => 403 ] ); + } + $files = Google_Drive_File::where( 'attachable_type', $type ) ->where( 'attachable_id', $id ) + ->where( 'project_id', $project_id ) ->orderBy( 'created_at', 'desc' ) ->get(); @@ -134,6 +181,11 @@ public function attach( WP_REST_Request $request ) { 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 ] ); } From a06f1e3b48adf5d0aaf790d38b7a30a476d81df2 Mon Sep 17 00:00:00 2001 From: Ariful Hoque Date: Fri, 3 Jul 2026 15:56:57 +0600 Subject: [PATCH 62/65] harden(google): safe token-key fallback + gate picker_config to Drive-enabled Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Google_Workspace/Controllers/Drive_Controller.php | 4 ++++ src/Google_Workspace/Google_Service.php | 4 ++-- .../components/google-workspace/GoogleDriveAttach.jsx | 11 ++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php index 360cd6db7..9569be56c 100644 --- a/src/Google_Workspace/Controllers/Drive_Controller.php +++ b/src/Google_Workspace/Controllers/Drive_Controller.php @@ -20,6 +20,10 @@ class Drive_Controller { /** GET google-workspace/drive/picker-config */ public function picker_config( WP_REST_Request $request ) { + if ( ! Google_Service::drive_enabled() ) { + return new \WP_Error( 'pm_google_drive_disabled', __( 'Google Drive integration is disabled.', 'wedevs-project-manager' ), [ 'status' => 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 ] ); } diff --git a/src/Google_Workspace/Google_Service.php b/src/Google_Workspace/Google_Service.php index 5ce38c490..66bcf7903 100644 --- a/src/Google_Workspace/Google_Service.php +++ b/src/Google_Workspace/Google_Service.php @@ -353,8 +353,8 @@ public static function get_access_token( $user_id ) { // ── Encryption (AES-256-CBC, key from WP salts) ────────────────────── private static function key() { - $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'pm-google-fallback'; - $salt .= defined( 'AUTH_SALT' ) ? AUTH_SALT : 'pm-google-fallback-salt'; + $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : wp_salt( 'auth' ); + $salt .= defined( 'AUTH_SALT' ) ? AUTH_SALT : wp_salt( 'auth' ); return hash( 'sha256', $salt, true ); } diff --git a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx index 7ae2cecc7..1172758a0 100644 --- a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx +++ b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx @@ -17,6 +17,11 @@ import { FileText, Plus, ExternalLink, Trash2, Link2, Lock, X } from 'lucide-rea import { toast } from 'sonner' 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 '' } +} + const DriveLogo = ({ className = 'h-4 w-4' }) => ( - {file.name} + {file.name} {canEdit && ( {pickerOpen && ( { @@ -22,28 +23,6 @@ const safeHttpUrl = (u) => { catch { return '' } } -const DriveLogo = ({ className = 'h-4 w-4' }) => ( - -) - -// Outlined Drive glyph for the compact (comment) add button. -const MonoDrive = ({ className = 'h-3.5 w-3.5' }) => ( - -) - // Tiny "added by" avatar with tooltip — non-intrusive attribution. const AdderAvatar = ({ file }) => { if (!file.added_by_name) return null @@ -116,7 +95,7 @@ export default function GoogleDriveAttach({ projectId, attachableType, attachabl title={status.picker_ready ? __('Add from Drive', 'wedevs-project-manager') : __('Admin must add the API key and App ID first.', 'wedevs-project-manager')} onClick={openPicker} > - + ) : ( {pickerOpen && ( ( - -) - export default function GoogleDriveStage({ projectId, value = [], onChange }) { const dispatch = useAppDispatch() const status = useAppSelector(s => s.googleWorkspace.status) @@ -73,7 +66,7 @@ export default function GoogleDriveStage({ projectId, value = [], onChange }) { title={status.picker_ready ? __('Add from Drive', 'wedevs-project-manager') : __('Admin must add the API key and App ID first.', 'wedevs-project-manager')} onClick={openPicker} > - + ) : ( diff --git a/views/assets/src/components/google-workspace/GoogleIcons.jsx b/views/assets/src/components/google-workspace/GoogleIcons.jsx index adde4ef83..b288accce 100644 --- a/views/assets/src/components/google-workspace/GoogleIcons.jsx +++ b/views/assets/src/components/google-workspace/GoogleIcons.jsx @@ -38,3 +38,46 @@ 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 index b64c0cc02..616feb808 100644 --- a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -17,7 +17,7 @@ import { AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '@components/ui/alert-dialog' import { ShieldCheck, Unlink, HardDrive, Settings as SettingsIcon, Lock, Info } from 'lucide-react' -import { CalendarGlyph, MeetGlyph } from '@components/google-workspace/GoogleIcons' +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' @@ -51,28 +51,6 @@ const ProFeatureCard = ({ icon: Icon, title, description }) => { ) } -const GoogleGlyph = (props) => ( - - - - - - -) - -const DriveLogo = (props) => ( - - - - - - - - - - -) - export default function GoogleWorkspacePage() { const dispatch = useAppDispatch() const { status, statusLoading } = useAppSelector(s => s.googleWorkspace) @@ -115,7 +93,7 @@ export default function GoogleWorkspacePage() { return (
    - +

    {__('Google Workspace', 'wedevs-project-manager')}

    {__('Connect your Google account to use Google features inside Project Manager.', 'wedevs-project-manager')}

    @@ -150,7 +128,7 @@ export default function GoogleWorkspacePage() {

    {__('Connect your Google account to browse and attach Drive files to tasks.', 'wedevs-project-manager')}

    )} @@ -170,7 +148,7 @@ export default function GoogleWorkspacePage() {
    - +
    {__('Google Drive', 'wedevs-project-manager')}
    {__('Attach Drive files to tasks, comments, discussions and files.', 'wedevs-project-manager')}
    diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index 613b3c28b..312b354a9 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -14,15 +14,7 @@ import { Columns3, GitBranch, Receipt, Timer, Shield, Wrench, } from 'lucide-react' import { cn } from '@lib/utils' - -// Google Drive glyph (2026 mark), outlined to match the lucide nav icons. -const GoogleDriveNavIcon = (props) => ( - - - - - -) +import { DriveMonoGlyph as GoogleDriveNavIcon } from '@components/google-workspace/GoogleIcons' function statusColor(p) { const s = p.status From 609b985d5bf5cbc60bd7c3fe065736a1dae94485 Mon Sep 17 00:00:00 2001 From: Ariful Hoque Date: Fri, 3 Jul 2026 16:12:23 +0600 Subject: [PATCH 64/65] feat(google): hide Drive/Meet comment links from members without project access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google links pasted into comments were shown to every project member. Now the comment transformer runs content through a wedevs_pm_comment_content_visibility filter; Google_Workspace strips Drive/Docs/Meet anchors (replacing them with plain text) when the requesting user fails the existing per-project role permission Google_Service::user_can_use_drive() — managers/admins pass, co_worker/client gated by the project access map. Stripped server-side so the URL never reaches the response. No-op unless the content actually holds a google.com link. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Transformers/Comment_Transformer.php | 2 +- src/Google_Workspace/Loader.php | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) 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/Loader.php b/src/Google_Workspace/Loader.php index 74d43359c..4db9ea817 100644 --- a/src/Google_Workspace/Loader.php +++ b/src/Google_Workspace/Loader.php @@ -19,6 +19,9 @@ public function boot() { add_action( 'admin_post_pm_google_oauth_callback', [ $this, 'handle_oauth_callback' ] ); add_action( 'admin_init', [ $this, 'maybe_install' ] ); + // Hide Google links in comments from users without Drive access in the project. + add_filter( 'wedevs_pm_comment_content_visibility', [ $this, 'hide_google_links_without_access' ], 10, 2 ); + // Remove Drive attachments when their parent entity is deleted. add_action( 'wedevs_cpm_comment_delete', [ $this, 'cleanup_comment_attachments' ], 10, 1 ); add_action( 'wedevs_pm_after_delete_task', [ $this, 'cleanup_task_attachments' ], 10, 1 ); @@ -31,6 +34,42 @@ public function boot() { } } + /** + * Strip Google Drive/Docs/Meet links from comment content for users who + * lack Drive access in the project. Reuses the existing per-project role + * permission (Google_Service::user_can_use_drive) — managers/admins pass, + * co_worker/client are gated by the project access map. The URL is removed + * server-side, so it never reaches the response. + * + * @param string $content Comment HTML. + * @param int $project_id Owning project id. + * @return string + */ + public function hide_google_links_without_access( $content, $project_id ) { + if ( ! is_string( $content ) || $content === '' ) { + return $content; + } + + // Cheap short-circuit: only pay the permission lookup when a Google link is present. + if ( strpos( $content, 'google.com' ) === false ) { + return $content; + } + + if ( Google_Service::user_can_use_drive( (int) $project_id ) ) { + return $content; + } + + // Replace each Google anchor with its plain visible text (drops the href). + return preg_replace_callback( + '#]*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 ); } From c23e3271bcaff0d77cdd9739191d67ae44030413 Mon Sep 17 00:00:00 2001 From: Ariful Hoque Date: Fri, 3 Jul 2026 16:12:23 +0600 Subject: [PATCH 65/65] =?UTF-8?q?fix(uninstall):=20preserve=20user=20data?= =?UTF-8?q?=20=E2=80=94=20stop=20dropping=20tables/options=20on=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uninstall.php dropped pm_google_tokens + pm_google_drive_files and deleted the settings options, destroying user data on plugin delete/reinstall (this is the free wp.org core plugin). Now it only clears the scheduled cleanup cron; no tables dropped, no options deleted. Co-Authored-By: Claude Opus 4.8 (1M context) --- uninstall.php | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/uninstall.php b/uninstall.php index 7a8457968..c577b9b4b 100644 --- a/uninstall.php +++ b/uninstall.php @@ -2,26 +2,17 @@ /** * Uninstall cleanup. * - * Currently scoped to the Google Workspace integration: removes its tables, - * options, and scheduled cron. Core Project Manager data is intentionally - * left intact. + * User data is intentionally preserved on uninstall — Google Workspace tokens, + * Drive attachments, settings, and all core Project Manager data survive a + * delete/reinstall. Only the scheduled cleanup cron is cleared, since a dangling + * schedule for removed code is not user data. */ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { exit; } -global $wpdb; - -// Google Workspace tables. -$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}pm_google_tokens" ); -$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}pm_google_drive_files" ); - -// Google Workspace options. -delete_option( 'pm_google_workspace_settings' ); -delete_option( 'pm_google_workspace_db_version' ); - -// Scheduled cleanup cron. +// Clear the scheduled cleanup cron only. No tables are dropped, no options deleted. $timestamp = wp_next_scheduled( 'pm_google_workspace_cleanup' ); if ( $timestamp ) { wp_unschedule_event( $timestamp, 'pm_google_workspace_cleanup' );