diff --git a/bootstrap/start.php b/bootstrap/start.php index 37f0d9f74..2bc8b2482 100644 --- a/bootstrap/start.php +++ b/bootstrap/start.php @@ -22,6 +22,7 @@ wedevs_pm_register_routes(); wedevs_pm_clean_svg(); +( new \WeDevs\PM\Google_Workspace\Loader() )->boot(); new WeDevs\PM\Kanban\Kanban(); do_action( 'wedevs_pm_loaded' ); diff --git a/core/WP/Menu.php b/core/WP/Menu.php index c82d0a9fe..16367d2ee 100644 --- a/core/WP/Menu.php +++ b/core/WP/Menu.php @@ -37,6 +37,36 @@ public static function admin_menu() { do_action( 'wedevs_cpm_admin_menu', self::$capability, $home ); + // 3b. G Workspace — placed directly under Sprints (or in the views group + // when Sprints isn't present). Shown when any Google feature is on. + if ( class_exists( '\WeDevs\PM\Google_Workspace\Google_Service' ) ) { + $gw = '\WeDevs\PM\Google_Workspace\Google_Service'; + $gw_on = $gw::drive_enabled() || $gw::calendar_master_enabled() || $gw::meet_master_enabled(); + if ( $gw_on ) { + $gw_item = [ __( 'G Workspace', 'wedevs-project-manager' ), self::$capability, "admin.php?page={$slug}#/google-workspace" ]; + $list = isset( $submenu[ $slug ] ) ? $submenu[ $slug ] : []; + + // Find the integer POSITION of the Sprints entry (its array key may + // be a string, so never do arithmetic on the key itself). + $pos = -1; $i = 0; + foreach ( $list as $it ) { + if ( isset( $it[2] ) && strpos( $it[2], '#/sprints' ) !== false ) { $pos = $i; break; } + $i++; + } + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally adding custom submenu items to WordPress admin menu + if ( $pos >= 0 ) { + $submenu[ $slug ] = array_merge( + array_slice( $list, 0, $pos + 1 ), + [ $gw_item ], + array_slice( $list, $pos + 1 ) + ); + } else { + $submenu[ $slug ][] = $gw_item; + } + } + } + // 4. Upgrade to Pro (free only) if ( ! $wedevs_pm_pro ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally adding custom submenu items to WordPress admin menu diff --git a/db/Create_Table.php b/db/Create_Table.php index ade6ab43d..c44a80681 100644 --- a/db/Create_Table.php +++ b/db/Create_Table.php @@ -26,6 +26,7 @@ public function __construct() { $this->crate_role_project_users_table(); $this->update_version(); $this->task_types(); + \WeDevs\PM\Google_Workspace\Loader::install(); } private function prefix() { diff --git a/routes/google-workspace.php b/routes/google-workspace.php new file mode 100644 index 000000000..4d2f808e1 --- /dev/null +++ b/routes/google-workspace.php @@ -0,0 +1,63 @@ +get( 'google-workspace/settings', $gw_base . 'Settings_Controller@get' ) + ->permission( [ $gw_admin ] ); + +$wedevs_pm_router->post( 'google-workspace/settings', $gw_base . 'Settings_Controller@save' ) + ->permission( [ $gw_admin ] ); + +// ── Per-user connection ────────────────────────────────────────────── +$wedevs_pm_router->get( 'google-workspace/status', $gw_base . 'OAuth_Controller@status' ) + ->permission( [ $gw_auth ] ); + +$wedevs_pm_router->get( 'google-workspace/auth-url', $gw_base . 'OAuth_Controller@auth_url' ) + ->permission( [ $gw_auth ] ); + +$wedevs_pm_router->post( 'google-workspace/disconnect', $gw_base . 'OAuth_Controller@disconnect' ) + ->permission( [ $gw_auth ] ); + +$wedevs_pm_router->post( 'google-workspace/my-prefs', $gw_base . 'OAuth_Controller@save_my_prefs' ) + ->permission( [ $gw_auth ] ); + +// ── Drive Picker config (vends caller's own access token + Picker keys) ── +$wedevs_pm_router->get( 'google-workspace/drive/picker-config', $gw_base . 'Drive_Controller@picker_config' ) + ->permission( [ $gw_auth ] ); + +// ── Per-project Drive role access (manager configures; members query) ── +// Same gate as the project Capabilities/Settings tab where these toggles live. +$gw_manager = 'WeDevs\PM\Core\Permissions\Project_Settings_Page_Access'; + +$wedevs_pm_router->get( 'projects/{project_id}/google-workspace/access', $gw_base . 'Drive_Controller@get_access' ) + ->permission( [ $gw_manager ] ); + +$wedevs_pm_router->post( 'projects/{project_id}/google-workspace/access', $gw_base . 'Drive_Controller@save_access' ) + ->permission( [ $gw_manager ] ); + +$wedevs_pm_router->get( 'projects/{project_id}/google-workspace/can-use', $gw_base . 'Drive_Controller@can_use' ) + ->permission( [ $gw_access ] ); + +// ── Drive attachments (polymorphic: task, comment, discussion, project) ── +$wedevs_pm_router->get( 'projects/{project_id}/google-drive', $gw_base . 'Drive_Controller@index' ) + ->permission( [ $gw_access ] ); + +$wedevs_pm_router->post( 'projects/{project_id}/google-drive', $gw_base . 'Drive_Controller@attach' ) + ->permission( [ $gw_access ] ); + +$wedevs_pm_router->delete( 'projects/{project_id}/google-drive/{id}', $gw_base . 'Drive_Controller@destroy' ) + ->permission( [ $gw_access ] ); + +// Legacy task-scoped routes (kept for back-compat). +$wedevs_pm_router->get( 'projects/{project_id}/tasks/{task_id}/google-drive', $gw_base . 'Drive_Controller@index' ) + ->permission( [ $gw_access ] ); + +$wedevs_pm_router->post( 'projects/{project_id}/tasks/{task_id}/google-drive', $gw_base . 'Drive_Controller@attach' ) + ->permission( [ $gw_access ] ); diff --git a/src/Activity/Transformers/Activity_Transformer.php b/src/Activity/Transformers/Activity_Transformer.php index 9af660ad3..f89c0911f 100644 --- a/src/Activity/Transformers/Activity_Transformer.php +++ b/src/Activity/Transformers/Activity_Transformer.php @@ -152,6 +152,10 @@ private function parse_meta_for_comment( Activity $activity ) { } } + // Google Workspace: flag comments that contain a Drive/Meet link. + if ( ! empty( $activity->meta['has_drive'] ) ) { $meta['has_drive'] = true; } + if ( ! empty( $activity->meta['has_meet'] ) ) { $meta['has_meet'] = true; } + return $meta; } @@ -228,6 +232,16 @@ private function get_nested_value( $data, $path ) { */ private function get_activity_message_by_action( $action ) { switch ( $action ) { + // Google Workspace activities + case 'attach_drive_file': + return __( '{{actor.data.display_name}} attached a Google Drive file to {{meta.task_title}}.', 'wedevs-project-manager' ); + + case 'detach_drive_file': + return __( '{{actor.data.display_name}} removed a Google Drive file from {{meta.task_title}}.', 'wedevs-project-manager' ); + + case 'create_meet': + return __( '{{actor.data.display_name}} started a Google Meet meeting.', 'wedevs-project-manager' ); + // Project activities case 'create_project': /* translators: 1: User display name, 2: Project title */ diff --git a/src/Comment/Observers/Comment_Observer.php b/src/Comment/Observers/Comment_Observer.php index ee7aae966..dca9cf1fb 100644 --- a/src/Comment/Observers/Comment_Observer.php +++ b/src/Comment/Observers/Comment_Observer.php @@ -37,8 +37,14 @@ protected function content( Comment $comment, $old_value ) { private function log_activity( Comment $comment, $action_type ) { $parent_comment = Comment::parent_comment( $comment->id ); + if ( ! $parent_comment ) { + return; // can't resolve the thread root — skip activity logging + } $commentable_type = $parent_comment->commentable_type; $commentable = $this->get_commentable( $parent_comment ); + if ( ! $commentable ) { + return; // commentable (task/discussion/file) gone — skip logging + } switch ( $commentable_type ) { case 'task': @@ -67,11 +73,24 @@ private function log_activity( Comment $comment, $action_type ) { } } + /** Flags whether a comment body contains a Drive / Meet link (for activity icons). */ + private function gw_meta( Comment $comment ) { + $content = (string) $comment->content; + return [ + 'has_drive' => ( strpos( $content, 'drive.google.com' ) !== false || strpos( $content, 'docs.google.com' ) !== false ), + 'has_meet' => ( strpos( $content, 'meet.google.com' ) !== false ), + ]; + } + private function comment_on_task( Comment $comment, Task $task, $action_type ) { $meta = [ 'comment_id' => $comment->id, 'task_title' => $task->title, ]; + // Don't mark a deleted comment with Drive/Meet icons. + if ( $action_type !== 'delete' ) { + $meta = array_merge( $meta, $this->gw_meta( $comment ) ); + } if ( $action_type == 'create' && $comment->commentable_type == 'comment' ) { $action = 'reply_comment_on_task'; diff --git a/src/Comment/Transformers/Comment_Transformer.php b/src/Comment/Transformers/Comment_Transformer.php index 4e47bd41b..e2a2b064c 100644 --- a/src/Comment/Transformers/Comment_Transformer.php +++ b/src/Comment/Transformers/Comment_Transformer.php @@ -30,7 +30,7 @@ class Comment_Transformer extends TransformerAbstract { public function transform( Comment $item ) { return [ 'id' => (int) $item->id, - 'content' => wedevs_pm_get_content( $item->content ), + 'content' => apply_filters( 'wedevs_pm_comment_content_visibility', wedevs_pm_get_content( $item->content ), (int) $item->project_id ), 'commentable_type' => $item->commentable_type, 'commentable_id' => $item->commentable_id, 'created_at' => wedevs_pm_format_date( $item->created_at ), diff --git a/src/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php new file mode 100644 index 000000000..9569be56c --- /dev/null +++ b/src/Google_Workspace/Controllers/Drive_Controller.php @@ -0,0 +1,281 @@ + 403 ] ); + } + + if ( ! Google_Service::picker_ready() ) { + return new \WP_Error( 'pm_google_not_ready', __( 'Google Picker is not fully configured. An administrator must add the API key and App ID.', 'wedevs-project-manager' ), [ 'status' => 400 ] ); + } + + $access_token = Google_Service::get_access_token( get_current_user_id() ); + if ( empty( $access_token ) ) { + return new \WP_Error( 'pm_google_not_connected', __( 'Connect your Google account first.', 'wedevs-project-manager' ), [ 'status' => 401 ] ); + } + + return [ + 'data' => [ + 'access_token' => $access_token, + 'api_key' => Google_Service::get_api_key(), + 'app_id' => Google_Service::get_app_id(), + ], + ]; + } + + /** GET projects/{project_id}/tasks/{task_id}/google-drive */ + /** + * Resolve the polymorphic target from the request. Prefers + * attachable_type/attachable_id; falls back to task_id (legacy task route). + * + * @return array{0:string,1:int} + */ + private function resolve_attachable( WP_REST_Request $request ) { + $type = sanitize_text_field( (string) $request->get_param( 'attachable_type' ) ); + $id = (int) $request->get_param( 'attachable_id' ); + + if ( $type === '' ) { + $task_id = (int) $request->get_param( 'task_id' ); + if ( $task_id ) { + return [ 'task', $task_id ]; + } + } + + // Whitelist supported entity types. + $allowed = [ 'task', 'comment', 'discussion', 'project', 'file' ]; + if ( ! in_array( $type, $allowed, true ) ) { + $type = ''; + } + + return [ $type, $id ]; + } + + /** + * Resolve the real project that owns a polymorphic attachable target. + * Returns 0 when the target does not exist or the type is not verifiable. + */ + private function attachable_project_id( $type, $id ) { + $id = (int) $id; + + if ( ! $id ) { + return 0; + } + + switch ( $type ) { + case 'task': + $model = Task::find( $id ); + return $model ? (int) $model->project_id : 0; + + case 'comment': + $model = Comment::find( $id ); + return $model ? (int) $model->project_id : 0; + + case 'discussion': + $model = Discussion_Board::find( $id ); + return $model ? (int) $model->project_id : 0; + + case 'file': + $model = File::find( $id ); + return $model ? (int) $model->project_id : 0; + + case 'project': + return $id; + + default: + return 0; + } + } + + /** + * For comment attachments, restrict add/remove to the comment author or a + * project manager (mirrors who can edit the comment). Other entity types + * are gated by project-level Drive access only. + */ + private function can_manage_attachable( $type, $id, $project_id ) { + if ( $type !== 'comment' ) { + return true; + } + + global $wpdb; + $created_by = (int) $wpdb->get_var( $wpdb->prepare( + "SELECT created_by FROM {$wpdb->prefix}pm_comments WHERE id = %d", (int) $id + ) ); + + $uid = get_current_user_id(); + if ( $created_by && $created_by === $uid ) { + return true; + } + return wedevs_pm_has_manage_capability( $uid ) || wedevs_pm_is_manager( $project_id, $uid ); + } + + public function index( WP_REST_Request $request ) { + $project_id = (int) $request->get_param( 'project_id' ); + list( $type, $id ) = $this->resolve_attachable( $request ); + + if ( $type === '' ) { + return [ 'data' => [] ]; + } + + $owner_project_id = $this->attachable_project_id( $type, $id ); + if ( ! $owner_project_id || $owner_project_id !== $project_id ) { + return new \WP_Error( 'pm_google_forbidden', __( 'This attachment target does not belong to the current project.', 'wedevs-project-manager' ), [ 'status' => 403 ] ); + } + + $files = Google_Drive_File::where( 'attachable_type', $type ) + ->where( 'attachable_id', $id ) + ->where( 'project_id', $project_id ) + ->orderBy( 'created_at', 'desc' ) + ->get(); + + return [ 'data' => $this->transform_collection( $files ) ]; + } + + /** GET projects/{project_id}/google-workspace/access — manager: read role access */ + public function get_access( WP_REST_Request $request ) { + $project_id = (int) $request->get_param( 'project_id' ); + return [ 'data' => Google_Service::project_drive_access( $project_id ) ]; + } + + /** POST projects/{project_id}/google-workspace/access — manager: save role access */ + public function save_access( WP_REST_Request $request ) { + $project_id = (int) $request->get_param( 'project_id' ); + Google_Service::save_project_drive_access( + $project_id, + filter_var( $request->get_param( 'co_worker' ), FILTER_VALIDATE_BOOLEAN ), + filter_var( $request->get_param( 'client' ), FILTER_VALIDATE_BOOLEAN ) + ); + return [ 'data' => Google_Service::project_drive_access( $project_id ) ]; + } + + /** GET projects/{project_id}/google-workspace/can-use — current user gate */ + public function can_use( WP_REST_Request $request ) { + $project_id = (int) $request->get_param( 'project_id' ); + return [ 'data' => [ 'can_use' => Google_Service::user_can_use_drive( $project_id ) ] ]; + } + + public function attach( WP_REST_Request $request ) { + $project_id = (int) $request->get_param( 'project_id' ); + $file = $request->get_param( 'file' ); + list( $type, $id ) = $this->resolve_attachable( $request ); + + if ( ! Google_Service::user_can_use_drive( $project_id ) ) { + return new \WP_Error( 'pm_google_forbidden', __( 'You are not allowed to attach Google Drive files in this project.', 'wedevs-project-manager' ), [ 'status' => 403 ] ); + } + + if ( $type === '' || ! $id ) { + return new \WP_Error( 'pm_google_bad_request', __( 'Invalid attachment target.', 'wedevs-project-manager' ), [ 'status' => 422 ] ); + } + + $owner_project_id = $this->attachable_project_id( $type, $id ); + if ( ! $owner_project_id || $owner_project_id !== $project_id ) { + return new \WP_Error( 'pm_google_forbidden', __( 'This attachment target does not belong to the current project.', 'wedevs-project-manager' ), [ 'status' => 403 ] ); + } + + if ( $type === 'comment' && ! Google_Service::drive_comments_enabled() ) { + return new \WP_Error( 'pm_google_forbidden', __( 'Google Drive in comments is turned off.', 'wedevs-project-manager' ), [ 'status' => 403 ] ); + } + + if ( ! $this->can_manage_attachable( $type, $id, $project_id ) ) { + return new \WP_Error( 'pm_google_forbidden', __( 'You can only attach Drive files to your own comment.', 'wedevs-project-manager' ), [ 'status' => 403 ] ); + } + + if ( empty( $file ) || empty( $file['id'] ) ) { + return new \WP_Error( 'pm_google_bad_request', __( 'No file provided.', 'wedevs-project-manager' ), [ 'status' => 422 ] ); + } + + $existing = Google_Drive_File::where( 'attachable_type', $type ) + ->where( 'attachable_id', $id ) + ->where( 'file_id', $file['id'] ) + ->first(); + if ( $existing ) { + return [ 'data' => $this->transform( $existing ) ]; + } + + $row = Google_Drive_File::create( [ + 'project_id' => $project_id, + 'task_id' => $type === 'task' ? $id : null, + 'attachable_type' => $type, + 'attachable_id' => $id, + 'user_id' => get_current_user_id(), + 'file_id' => sanitize_text_field( $file['id'] ), + 'name' => isset( $file['name'] ) ? sanitize_text_field( $file['name'] ) : '', + 'mime_type' => isset( $file['mimeType'] ) ? sanitize_text_field( $file['mimeType'] ) : '', + 'icon_link' => isset( $file['iconLink'] ) ? esc_url_raw( $file['iconLink'] ) : '', + 'thumbnail_link' => isset( $file['thumbnailLink'] ) ? esc_url_raw( $file['thumbnailLink'] ) : '', + 'web_view_link' => isset( $file['webViewLink'] ) ? esc_url_raw( $file['webViewLink'] ) : '', + 'modified_time' => isset( $file['modifiedTime'] ) ? sanitize_text_field( $file['modifiedTime'] ) : '', + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ] ); + + do_action( 'pm_google_drive_file_attached', $row, $type, $id, $project_id ); + + return [ 'data' => $this->transform( $row ) ]; + } + + /** DELETE projects/{project_id}/google-drive/{id} */ + public function destroy( WP_REST_Request $request ) { + $project_id = (int) $request->get_param( 'project_id' ); + $id = (int) $request->get_param( 'id' ); + + if ( ! Google_Service::user_can_use_drive( $project_id ) ) { + return new \WP_Error( 'pm_google_forbidden', __( 'You are not allowed to remove Google Drive files in this project.', 'wedevs-project-manager' ), [ 'status' => 403 ] ); + } + + $row = Google_Drive_File::where( 'id', $id )->where( 'project_id', $project_id )->first(); + if ( $row ) { + if ( ! $this->can_manage_attachable( $row->attachable_type, $row->attachable_id, $project_id ) ) { + return new \WP_Error( 'pm_google_forbidden', __( 'You can only remove Drive files from your own comment.', 'wedevs-project-manager' ), [ 'status' => 403 ] ); + } + do_action( 'pm_google_drive_file_detached', $row ); + $row->delete(); + } + + return [ 'data' => [ 'id' => $id, 'deleted' => true ] ]; + } + + private function transform_collection( $rows ) { + $out = []; + foreach ( $rows as $row ) { + $out[] = $this->transform( $row ); + } + return $out; + } + + private function transform( $row ) { + $user = $row->user_id ? get_userdata( $row->user_id ) : null; + + return [ + 'id' => (int) $row->id, + 'file_id' => $row->file_id, + 'name' => $row->name, + 'mime_type' => $row->mime_type, + 'icon_link' => $row->icon_link, + 'thumbnail_link' => $row->thumbnail_link, + 'web_view_link' => $row->web_view_link, + 'modified_time' => $row->modified_time, + 'user_id' => (int) $row->user_id, + 'added_by_name' => $user ? $user->display_name : '', + 'added_by_avatar'=> $row->user_id ? get_avatar_url( $row->user_id, [ 'size' => 32 ] ) : '', + ]; + } +} diff --git a/src/Google_Workspace/Controllers/OAuth_Controller.php b/src/Google_Workspace/Controllers/OAuth_Controller.php new file mode 100644 index 000000000..53d11a5c6 --- /dev/null +++ b/src/Google_Workspace/Controllers/OAuth_Controller.php @@ -0,0 +1,73 @@ + [ + 'configured' => Google_Service::is_configured(), + 'picker_ready' => Google_Service::picker_ready(), + 'drive_enabled' => Google_Service::drive_enabled(), + 'calendar_enabled' => Google_Service::calendar_master_enabled(), + 'meet_enabled' => Google_Service::meet_master_enabled(), + 'connected' => $conn['connected'], + 'account_email' => $conn['account_email'], + 'expired' => $conn['expired'], + 'calendar_connected'=> Google_Service::user_has_calendar( get_current_user_id() ), + 'meet_connected' => Google_Service::user_has_calendar( get_current_user_id() ), + 'drive_user_on' => Google_Service::user_drive_enabled( get_current_user_id() ), + 'drive_comments_on' => Google_Service::drive_comments_enabled(), + ], + ]; + } + + /** Per-user Workspace preferences (currently: Drive on/off for me). */ + public function save_my_prefs( WP_REST_Request $request ) { + $user_id = get_current_user_id(); + if ( $request->get_param( 'drive_on' ) !== null ) { + Google_Service::set_user_drive_enabled( $user_id, (bool) $request->get_param( 'drive_on' ) ); + } + return [ 'data' => [ 'drive_user_on' => Google_Service::user_drive_enabled( $user_id ) ] ]; + } + + public function auth_url( WP_REST_Request $request ) { + if ( ! Google_Service::is_configured() ) { + return new \WP_Error( 'pm_google_not_configured', __( 'Google credentials are not configured. Ask an administrator to add the Client ID and Secret.', 'wedevs-project-manager' ), [ 'status' => 400 ] ); + } + + $user_id = get_current_user_id(); + $state = $user_id . '|' . wp_create_nonce( 'pm_google_oauth_' . $user_id ); + + // Incremental consent: when a Pro feature asks for Calendar, request the + // Calendar scope alongside the base scopes (include_granted_scopes keeps + // the previously granted Drive scope). + $scope = Google_Client::SCOPES; + if ( $request->get_param( 'with_calendar' ) ) { + $scope .= ' ' . Google_Client::CALENDAR_SCOPE; + } + + return [ + 'data' => [ + 'auth_url' => Google_Service::client()->get_auth_url( $state, $scope ), + ], + ]; + } + + public function disconnect( WP_REST_Request $request ) { + Google_Service::disconnect_user( get_current_user_id() ); + + return [ 'data' => [ 'connected' => false ] ]; + } +} diff --git a/src/Google_Workspace/Controllers/Settings_Controller.php b/src/Google_Workspace/Controllers/Settings_Controller.php new file mode 100644 index 000000000..77a6cefb9 --- /dev/null +++ b/src/Google_Workspace/Controllers/Settings_Controller.php @@ -0,0 +1,61 @@ + [ + 'client_id' => isset( $settings['client_id'] ) ? $settings['client_id'] : '', + 'has_secret' => ! empty( $settings['client_secret'] ), + 'api_key' => isset( $settings['api_key'] ) ? $settings['api_key'] : '', + 'app_id' => isset( $settings['app_id'] ) ? $settings['app_id'] : '', + 'drive_enabled' => Google_Service::drive_enabled(), + 'calendar_enabled' => Google_Service::calendar_master_enabled(), + 'meet_enabled' => Google_Service::meet_master_enabled(), + 'drive_comments'=> Google_Service::drive_comments_enabled(), + 'configured' => Google_Service::is_configured(), + 'picker_ready' => Google_Service::picker_ready(), + 'redirect_uri' => Loader::redirect_uri(), + ], + ]; + } + + public function save( WP_REST_Request $request ) { + $client_id = trim( (string) $request->get_param( 'client_id' ) ); + $client_secret = trim( (string) $request->get_param( 'client_secret' ) ); + + $settings = get_option( 'pm_google_workspace_settings', [] ); + + $settings['client_id'] = sanitize_text_field( $client_id ); + $settings['api_key'] = sanitize_text_field( (string) $request->get_param( 'api_key' ) ); + $settings['app_id'] = sanitize_text_field( (string) $request->get_param( 'app_id' ) ); + $settings['drive_enabled'] = filter_var( $request->get_param( 'drive_enabled' ), FILTER_VALIDATE_BOOLEAN ); + + // Preserve drive_comments unless explicitly sent (its own toggle). + if ( $request->get_param( 'drive_comments' ) !== null ) { + $settings['drive_comments'] = filter_var( $request->get_param( 'drive_comments' ), FILTER_VALIDATE_BOOLEAN ); + } + + // Only overwrite the secret when a fresh value is sent (UI sends blank to keep). + // Stored encrypted at rest. + if ( $client_secret !== '' ) { + $settings['client_secret'] = Google_Service::encrypt( $client_secret ); + } + + update_option( 'pm_google_workspace_settings', $settings ); + + return $this->get( $request ); + } +} diff --git a/src/Google_Workspace/Google_Client.php b/src/Google_Workspace/Google_Client.php new file mode 100644 index 000000000..dd48f3bb4 --- /dev/null +++ b/src/Google_Workspace/Google_Client.php @@ -0,0 +1,109 @@ +client_id = $client_id; + $this->client_secret = $client_secret; + $this->redirect_uri = $redirect_uri; + } + + public function get_auth_url( $state, $scope = self::SCOPES ) { + return add_query_arg( [ + 'client_id' => rawurlencode( $this->client_id ), + 'redirect_uri' => rawurlencode( $this->redirect_uri ), + 'response_type' => 'code', + 'scope' => rawurlencode( $scope ), + 'access_type' => 'offline', + 'include_granted_scopes' => 'true', + 'prompt' => 'consent', + 'state' => rawurlencode( $state ), + ], self::AUTH_URL ); + } + + public function exchange_code( $code ) { + return $this->token_request( [ + 'code' => $code, + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'redirect_uri' => $this->redirect_uri, + 'grant_type' => 'authorization_code', + ] ); + } + + public function refresh_token( $refresh_token ) { + return $this->token_request( [ + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'refresh_token' => $refresh_token, + 'grant_type' => 'refresh_token', + ] ); + } + + private function token_request( $body ) { + return $this->parse( wp_remote_post( self::TOKEN_URL, [ + 'timeout' => 20, + 'body' => $body, + ] ) ); + } + + public function revoke( $token ) { + wp_remote_post( self::REVOKE_URL, [ + 'timeout' => 15, + 'body' => [ 'token' => $token ], + ] ); + } + + public function get_userinfo( $access_token ) { + return $this->parse( wp_remote_get( self::USERINFO, [ + 'timeout' => 15, + 'headers' => [ 'Authorization' => 'Bearer ' . $access_token ], + ] ) ); + } + + private function parse( $res ) { + if ( is_wp_error( $res ) ) { + return [ 'ok' => false, 'status' => 0, 'error' => $res->get_error_message() ]; + } + + $status = (int) wp_remote_retrieve_response_code( $res ); + $body = json_decode( wp_remote_retrieve_body( $res ), true ); + + if ( $status < 200 || $status >= 300 ) { + $message = 'Google API error'; + if ( isset( $body['error_description'] ) ) { + $message = $body['error_description']; + } elseif ( isset( $body['error']['message'] ) ) { + $message = $body['error']['message']; + } elseif ( isset( $body['error'] ) && is_string( $body['error'] ) ) { + $message = $body['error']; + } + return [ 'ok' => false, 'status' => $status, 'error' => $message ]; + } + + return [ 'ok' => true, 'status' => $status, 'data' => $body ]; + } +} diff --git a/src/Google_Workspace/Google_Service.php b/src/Google_Workspace/Google_Service.php new file mode 100644 index 000000000..66bcf7903 --- /dev/null +++ b/src/Google_Workspace/Google_Service.php @@ -0,0 +1,383 @@ + true, 'client' => false ]; + } + return [ + 'co_worker' => ! empty( $saved['co_worker'] ), + 'client' => ! empty( $saved['client'] ), + ]; + } + + public static function save_project_drive_access( $project_id, $co_worker, $client ) { + update_option( 'pm_gdrive_access_' . (int) $project_id, [ + 'co_worker' => (bool) $co_worker, + 'client' => (bool) $client, + ] ); + } + + /** Per-user opt-out of Drive (toggled on the Google Workspace page). Default on. */ + public static function user_drive_enabled( $user_id ) { + $v = get_user_meta( (int) $user_id, 'pm_gws_drive_on', true ); + return $v === '' ? true : (bool) $v; + } + + public static function set_user_drive_enabled( $user_id, $on ) { + update_user_meta( (int) $user_id, 'pm_gws_drive_on', $on ? 1 : 0 ); + } + + /** + * Whether a user may use Drive in a project. Managers/admins always may; + * co_worker/client gated by the per-project access map. + */ + public static function user_can_use_drive( $project_id, $user_id = null ) { + $user_id = $user_id ? $user_id : get_current_user_id(); + + // Admin master switch off -> Drive disabled for everyone. + if ( ! self::drive_enabled() ) { + return false; + } + + // Respect the user's own Drive on/off choice first. + if ( ! self::user_drive_enabled( $user_id ) ) { + return false; + } + + if ( wedevs_pm_has_manage_capability( $user_id ) || wedevs_pm_is_manager( $project_id, $user_id ) ) { + return true; + } + + $role = wedevs_pm_get_role( $project_id, $user_id ); // co_worker | client | null + $access = self::project_drive_access( $project_id ); + + return ! empty( $access[ $role ] ); + } + + /** Global on/off switch for the Drive feature (admin-controlled). */ + public static function drive_enabled() { + $settings = get_option( 'pm_google_workspace_settings', [] ); + return ! empty( $settings['drive_enabled'] ); + } + + /** Pro Calendar master switch — read directly (plain option, no Pro class needed). */ + public static function calendar_master_enabled() { + $s = get_option( 'pm_google_calendar_settings', [] ); + return is_array( $s ) && ! empty( $s['enabled'] ); + } + + /** Pro Meet master switch — read directly (plain option, no Pro class needed). */ + public static function meet_master_enabled() { + $s = get_option( 'pm_google_meet_settings', [] ); + return is_array( $s ) && ! empty( $s['enabled'] ); + } + + /** Whether Drive attach is allowed inside comments (admin-controlled, default on). */ + public static function drive_comments_enabled() { + $settings = get_option( 'pm_google_workspace_settings', [] ); + return ! array_key_exists( 'drive_comments', $settings ) ? true : ! empty( $settings['drive_comments'] ); + } + + /** Picker also needs an API key + App ID (project number). */ + public static function picker_ready() { + $settings = get_option( 'pm_google_workspace_settings', [] ); + return self::is_configured() && ! empty( $settings['api_key'] ) && ! empty( $settings['app_id'] ); + } + + public static function get_api_key() { + $settings = get_option( 'pm_google_workspace_settings', [] ); + return isset( $settings['api_key'] ) ? $settings['api_key'] : ''; + } + + public static function get_app_id() { + $settings = get_option( 'pm_google_workspace_settings', [] ); + return isset( $settings['app_id'] ) ? $settings['app_id'] : ''; + } + + /** Whether the user's stored grant includes a given OAuth scope. */ + public static function user_has_scope( $user_id, $needle ) { + $token = self::get_user_token( $user_id ); + if ( ! $token || empty( $token->scope ) ) { + return false; + } + return strpos( $token->scope, $needle ) !== false; + } + + /** Whether the user has granted the Calendar scope (Pro Calendar feature). */ + public static function user_has_calendar( $user_id ) { + return self::user_has_scope( $user_id, Google_Client::CALENDAR_SCOPE ); + } + + public static function get_user_token( $user_id ) { + if ( empty( $user_id ) ) { + return null; + } + return Google_Token::where( 'user_id', $user_id )->first(); + } + + /** + * @return array{connected:bool, account_email:string, expired:bool} + */ + public static function user_connection( $user_id ) { + $token = self::get_user_token( $user_id ); + + if ( ! $token ) { + Loader::log( 'Status: no token row for user ' . $user_id ); + return [ 'connected' => false, 'account_email' => '', 'expired' => false ]; + } + + if ( self::token_undecryptable( $token ) ) { + Loader::log( 'Status: token undecryptable for user ' . $user_id . ' — purging' ); + $token->delete(); + return [ 'connected' => false, 'account_email' => '', 'expired' => true ]; + } + + return [ 'connected' => true, 'account_email' => $token->google_email, 'expired' => false ]; + } + + private static function token_undecryptable( $token ) { + $has_refresh = ! empty( $token->refresh_token ); + $has_access = ! empty( $token->access_token ); + + if ( ! $has_refresh && ! $has_access ) { + return false; + } + + $refresh_ok = ! $has_refresh || self::decrypt( $token->refresh_token ) !== null; + $access_ok = ! $has_access || self::decrypt( $token->access_token ) !== null; + + return ! $refresh_ok && ! $access_ok; + } + + public static function connect_user( $user_id, $code ) { + $client = self::client(); + $token = $client->exchange_code( $code ); + + if ( empty( $token['ok'] ) ) { + Loader::log( 'Token exchange failed for user ' . $user_id . ': ' . ( isset( $token['error'] ) ? $token['error'] : 'unknown' ) ); + return false; + } + + $data = $token['data']; + $email = ''; + $profile = $client->get_userinfo( $data['access_token'] ); + if ( ! empty( $profile['ok'] ) && isset( $profile['data']['email'] ) ) { + $email = $profile['data']['email']; + } + + $expires_at = Carbon::now()->addSeconds( isset( $data['expires_in'] ) ? (int) $data['expires_in'] : 3600 ); + + $existing = self::get_user_token( $user_id ); + + $refresh_plain = isset( $data['refresh_token'] ) ? $data['refresh_token'] : null; + if ( empty( $refresh_plain ) && $existing ) { + $refresh_enc = $existing->refresh_token; + } else { + $refresh_enc = $refresh_plain ? self::encrypt( $refresh_plain ) : null; + } + + $attrs = [ + 'user_id' => $user_id, + 'google_email' => $email, + 'access_token' => self::encrypt( $data['access_token'] ), + 'refresh_token' => $refresh_enc, + 'scope' => isset( $data['scope'] ) ? $data['scope'] : Google_Client::SCOPES, + 'expires_at' => $expires_at, + 'last_used_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + + try { + if ( $existing ) { + $existing->update( $attrs ); + } else { + $attrs['created_at'] = Carbon::now(); + Google_Token::create( $attrs ); + } + } catch ( \Exception $e ) { + // Surface the real DB error (e.g. missing column) instead of a + // false "success" — the caller will report an error to the user. + Loader::log( 'Token store FAILED for user ' . $user_id . ': ' . $e->getMessage() ); + return false; + } + + Loader::log( 'Token stored for user ' . $user_id . ' (' . $email . ')' ); + return true; + } + + public static function disconnect_user( $user_id ) { + $token = self::get_user_token( $user_id ); + if ( ! $token ) { + return; + } + + // Let features (e.g. Pro Calendar) clean up their Google data while the + // token is still valid — fired before revoke so API calls still work. + do_action( 'pm_google_before_disconnect', (int) $user_id ); + + $refresh = self::decrypt( $token->refresh_token ); + if ( $refresh ) { + self::client()->revoke( $refresh ); + } + + $token->delete(); + } + + /** + * Revoke + delete tokens not used for $days days (default 60). Runs daily + * via cron so abandoned grants don't linger. Applies to every user. + * + * @return int number purged + */ + public static function purge_stale( $days = 60 ) { + $cutoff = Carbon::now()->subDays( $days ); + $purged = 0; + + $tokens = Google_Token::all(); + foreach ( $tokens as $token ) { + $marker = $token->last_used_at ?: $token->updated_at ?: $token->created_at; + if ( ! $marker || ! Carbon::parse( $marker )->lessThan( $cutoff ) ) { + continue; + } + + $refresh = self::decrypt( $token->refresh_token ); + if ( $refresh ) { + self::client()->revoke( $refresh ); + } + $token->delete(); + $purged++; + } + + return $purged; + } + + /** + * Fresh access token for a user, refreshing if needed. Purges tokens that + * can no longer be decrypted (salts rotated) so the UI prompts reconnect. + * + * @return string|null + */ + public static function get_access_token( $user_id ) { + $token = self::get_user_token( $user_id ); + if ( ! $token ) { + return null; + } + + if ( self::token_undecryptable( $token ) ) { + $token->delete(); + return null; + } + + // Track activity for the 60-day stale-token purge. + $token->update( [ 'last_used_at' => Carbon::now() ] ); + + $expires_at = $token->expires_at ? Carbon::parse( $token->expires_at ) : null; + + if ( $expires_at && $expires_at->isFuture() && $expires_at->diffInSeconds( Carbon::now() ) > 60 ) { + return self::decrypt( $token->access_token ); + } + + $refresh = self::decrypt( $token->refresh_token ); + if ( empty( $refresh ) ) { + return self::decrypt( $token->access_token ); + } + + $res = self::client()->refresh_token( $refresh ); + if ( empty( $res['ok'] ) ) { + return null; + } + + $data = $res['data']; + $expires = Carbon::now()->addSeconds( isset( $data['expires_in'] ) ? (int) $data['expires_in'] : 3600 ); + $access = $data['access_token']; + + $token->update( [ + 'access_token' => self::encrypt( $access ), + 'expires_at' => $expires, + 'updated_at' => Carbon::now(), + ] ); + + return $access; + } + + // ── Encryption (AES-256-CBC, key from WP salts) ────────────────────── + + private static function key() { + $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : wp_salt( 'auth' ); + $salt .= defined( 'AUTH_SALT' ) ? AUTH_SALT : wp_salt( 'auth' ); + return hash( 'sha256', $salt, true ); + } + + public static function encrypt( $plain ) { + if ( $plain === null || $plain === '' || ! function_exists( 'openssl_encrypt' ) ) { + return $plain; + } + $iv = openssl_random_pseudo_bytes( 16 ); + $cipher = openssl_encrypt( $plain, 'aes-256-cbc', self::key(), OPENSSL_RAW_DATA, $iv ); + return base64_encode( $iv . $cipher ); + } + + public static function decrypt( $stored ) { + if ( empty( $stored ) || ! function_exists( 'openssl_decrypt' ) ) { + return $stored; + } + $raw = base64_decode( $stored, true ); + if ( $raw === false || strlen( $raw ) <= 16 ) { + return null; + } + $iv = substr( $raw, 0, 16 ); + $cipher = substr( $raw, 16 ); + $plain = openssl_decrypt( $cipher, 'aes-256-cbc', self::key(), OPENSSL_RAW_DATA, $iv ); + return $plain === false ? null : $plain; + } +} diff --git a/src/Google_Workspace/Loader.php b/src/Google_Workspace/Loader.php new file mode 100644 index 000000000..4db9ea817 --- /dev/null +++ b/src/Google_Workspace/Loader.php @@ -0,0 +1,255 @@ +]*href=(["\'])https?://[^"\']*(?:drive\.google\.com|docs\.google\.com|meet\.google\.com)[^"\']*\1[^>]*>(.*?)#is', + function ( $m ) { + $text = trim( wp_strip_all_tags( $m[2] ) ); + return $text === '' ? '' : esc_html( $text ); + }, + $content + ); + } + + public function run_cleanup() { + Google_Service::purge_stale( 60 ); + } + + private static function delete_attachments( $type, $id ) { + global $wpdb; + if ( empty( $id ) ) { + return; + } + $wpdb->delete( $wpdb->prefix . 'pm_google_drive_files', [ + 'attachable_type' => $type, + 'attachable_id' => (int) $id, + ] ); + } + + public function cleanup_comment_attachments( $comment ) { + $id = is_object( $comment ) ? ( isset( $comment->id ) ? $comment->id : 0 ) : (int) $comment; + self::delete_attachments( 'comment', $id ); + } + + public function cleanup_task_attachments( $task_id ) { + self::delete_attachments( 'task', $task_id ); + } + + public function cleanup_discussion_attachments( $discussion_id ) { + self::delete_attachments( 'discussion', (int) $discussion_id ); + } + + public static function redirect_uri() { + return admin_url( 'admin-post.php?action=pm_google_oauth_callback' ); + } + + public function localize( $localize ) { + $conn = Google_Service::user_connection( get_current_user_id() ); + + $localize['google_workspace'] = [ + 'configured' => Google_Service::is_configured(), + 'picker_ready' => Google_Service::picker_ready(), + 'drive_enabled' => Google_Service::drive_enabled(), + 'calendar_enabled' => Google_Service::calendar_master_enabled(), + 'meet_enabled' => Google_Service::meet_master_enabled(), + 'connected' => $conn['connected'], + 'account_email' => $conn['account_email'], + 'expired' => $conn['expired'], + 'calendar_connected' => Google_Service::user_has_calendar( get_current_user_id() ), + 'meet_connected' => Google_Service::user_has_calendar( get_current_user_id() ), + 'drive_user_on' => Google_Service::user_drive_enabled( get_current_user_id() ), + 'drive_comments_on' => Google_Service::drive_comments_enabled(), + 'redirect_uri' => self::redirect_uri(), + ]; + + return $localize; + } + + public function handle_oauth_callback() { + if ( ! is_user_logged_in() ) { + wp_die( esc_html__( 'You must be logged in to connect Google.', 'wedevs-project-manager' ) ); + } + + $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; + $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; + + $return = admin_url( 'admin.php?page=pm_projects#/google-workspace' ); + + $parts = explode( '|', $state ); + if ( count( $parts ) !== 2 || (int) $parts[0] !== get_current_user_id() + || ! wp_verify_nonce( $parts[1], 'pm_google_oauth_' . get_current_user_id() ) ) { + wp_safe_redirect( add_query_arg( 'google_connected', 'invalid_state', $return ) ); + exit; + } + + if ( empty( $code ) ) { + wp_safe_redirect( add_query_arg( 'google_connected', 'denied', $return ) ); + exit; + } + + $ok = Google_Service::connect_user( get_current_user_id(), $code ); + + wp_safe_redirect( add_query_arg( 'google_connected', $ok ? 'success' : 'error', $return ) ); + exit; + } + + /** Gated debug logger — writes when WP_DEBUG or PM_GOOGLE_DEBUG is on. */ + public static function log( $message ) { + if ( ( defined( 'WP_DEBUG' ) && WP_DEBUG ) || ( defined( 'PM_GOOGLE_DEBUG' ) && PM_GOOGLE_DEBUG ) ) { + error_log( '[PM Google Workspace] ' . $message ); + } + } + + public function maybe_install() { + // Always self-heal columns first (cheap) so upgrades from an older + // schema — e.g. a table left by a previous install — get last_used_at + // without depending on the version gate. + self::ensure_columns(); + + if ( get_option( 'pm_google_workspace_db_version' ) === '1.3' ) { + return; + } + self::install(); + } + + private static function column_missing( $table, $column ) { + global $wpdb; + $has = $wpdb->get_results( $wpdb->prepare( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s", + DB_NAME, $table, $column + ) ); + return empty( $has ); + } + + /** + * Idempotently add columns missing from existing tables (covers upgrades + * from an older schema). MySQL + MariaDB safe (no ADD COLUMN IF NOT EXISTS). + */ + public static function ensure_columns() { + global $wpdb; + $tokens = $wpdb->prefix . 'pm_google_tokens'; + $files = $wpdb->prefix . 'pm_google_drive_files'; + + if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $tokens ) ) && self::column_missing( $tokens, 'last_used_at' ) ) { + $wpdb->query( "ALTER TABLE {$tokens} ADD COLUMN `last_used_at` datetime DEFAULT NULL AFTER `expires_at`" ); + self::log( 'Added last_used_at to ' . $tokens ); + } + + if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $files ) ) ) { + if ( self::column_missing( $files, 'attachable_type' ) ) { + $wpdb->query( "ALTER TABLE {$files} ADD COLUMN `attachable_type` varchar(40) DEFAULT NULL AFTER `task_id`" ); + $wpdb->query( "ALTER TABLE {$files} ADD COLUMN `attachable_id` bigint(20) UNSIGNED DEFAULT NULL AFTER `attachable_type`" ); + $wpdb->query( "ALTER TABLE {$files} ADD KEY `attachable` (`attachable_type`,`attachable_id`)" ); + // Migrate existing task attachments to the polymorphic columns. + $wpdb->query( "UPDATE {$files} SET `attachable_type` = 'task', `attachable_id` = `task_id` WHERE `task_id` IS NOT NULL AND `attachable_type` IS NULL" ); + self::log( 'Added polymorphic columns + migrated task rows on ' . $files ); + } + } + } + + public static function install() { + global $wpdb; + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $tokens = $wpdb->prefix . 'pm_google_tokens'; + $files = $wpdb->prefix . 'pm_google_drive_files'; + + dbDelta( "CREATE TABLE IF NOT EXISTS {$tokens} ( + `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` bigint(20) UNSIGNED NOT NULL, + `google_email` varchar(191) DEFAULT NULL, + `access_token` text, + `refresh_token` text, + `scope` text, + `expires_at` datetime DEFAULT NULL, + `last_used_at` datetime DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_id` (`user_id`) + ) DEFAULT CHARSET=utf8" ); + + dbDelta( "CREATE TABLE IF NOT EXISTS {$files} ( + `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `project_id` bigint(20) UNSIGNED DEFAULT NULL, + `task_id` bigint(20) UNSIGNED DEFAULT NULL, + `attachable_type` varchar(40) DEFAULT NULL, + `attachable_id` bigint(20) UNSIGNED DEFAULT NULL, + `user_id` bigint(20) UNSIGNED NOT NULL, + `file_id` varchar(191) NOT NULL, + `name` varchar(255) DEFAULT NULL, + `mime_type` varchar(191) DEFAULT NULL, + `icon_link` text, + `thumbnail_link` text, + `web_view_link` text, + `modified_time` varchar(64) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `task_id` (`task_id`), + KEY `project_id` (`project_id`), + KEY `attachable` (`attachable_type`,`attachable_id`) + ) DEFAULT CHARSET=utf8" ); + + update_option( 'pm_google_workspace_db_version', '1.3' ); + } +} diff --git a/src/Google_Workspace/Models/Google_Drive_File.php b/src/Google_Workspace/Models/Google_Drive_File.php new file mode 100644 index 000000000..ae74a4fed --- /dev/null +++ b/src/Google_Workspace/Models/Google_Drive_File.php @@ -0,0 +1,24 @@ + ( @@ -38,6 +39,7 @@ const NotionSettingsTab = lazy(() => import('./tabs/NotionSettingsTab')) const LoomSettingsTab = lazy(() => import('./tabs/LoomSettingsTab')) const InvoiceSettingsTab = lazy(() => import('./tabs/InvoiceSettingsTab')) const PagesSettingsTab = lazy(() => import('./tabs/PagesSettingsTab')) +const GoogleWorkspaceSettingsTab = lazy(() => import('./tabs/GoogleWorkspaceSettingsTab')) // ── Tab → Component map ────────────────────────────────────── const tabComponents = { @@ -49,6 +51,7 @@ const tabComponents = { 'github': GitHubSettingsTab, 'notion': NotionSettingsTab, 'loom': LoomSettingsTab, + 'google-workspace': GoogleWorkspaceSettingsTab, 'invoices': InvoiceSettingsTab, 'pages': PagesSettingsTab, 'woo-project': null, // injected by pm-pro via filter @@ -94,6 +97,7 @@ const SettingsPage = () => { const showWooTab = !isPro || isWooModuleActive const integrationTabs = [ + { key: 'google-workspace', label: __('G Workspace', 'wedevs-project-manager'), icon: GoogleWorkspaceNavIcon }, { key: 'pusher', label: __('Pusher', 'wedevs-project-manager'), icon: Radio }, { key: 'github', label: __('GitHub', 'wedevs-project-manager'), icon: GitHubNavIcon }, { key: 'notion', label: __('Notion', 'wedevs-project-manager'), icon: NotionNavIcon }, diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx new file mode 100644 index 000000000..831eb4715 --- /dev/null +++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx @@ -0,0 +1,226 @@ +import { __ } from '@wordpress/i18n' +/** + * GoogleWorkspaceSettingsTab — admin, one-time setup of the site-level Google + * Cloud OAuth credentials + Picker keys. Shared by all Google Workspace + * features (Drive now; Calendar/Meet later). Per-user account connection lives + * on the separate Google Workspace page, not here. + */ +import React, { useEffect, useState } from 'react' +import { useAppDispatch, useAppSelector } from '@store/index' +import { fetchSettings, saveSettings } from '@store/googleWorkspaceSlice' +import { Button } from '@components/ui/button' +import { Input } from '@components/ui/input' +import { Switch } from '@components/ui/switch' +import { Skeleton } from '@components/ui/skeleton' +import { Copy, Check, ExternalLink, ShieldCheck, Lock } from 'lucide-react' +import { useToast } from '@hooks/useToast' +import { Slot } from '@hooks/useSlot' +import { useProModal } from '@components/common/ProUpgradeModal' +import ProBadge from '@components/common/ProBadge' +import { GoogleMonoGlyph } from '@components/google-workspace/GoogleIcons' + +const DOCS_URL = 'https://wedevs.com/docs/wp-project-manager/integrations/google-workspace/' + +/** + * Free, locked teaser for a Pro Google feature's settings (Calendar/Meet). + * Pro replaces it by filling the matching slot with real enable + sync toggles. + */ +const LockedSettingCard = ({ title, description }) => { + const { setOpen } = useProModal() + return ( +
setOpen(true)} + > +
+
+ {title} + +
+
{description}
+
+ +
+ ) +} + +export default function GoogleWorkspaceSettingsTab() { + const dispatch = useAppDispatch() + const toast = useToast() + const { settings, settingsLoading, saving } = useAppSelector(s => s.googleWorkspace) + + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') + const [apiKey, setApiKey] = useState('') + const [appId, setAppId] = useState('') + const [driveEnabled, setDriveEnabled] = useState(false) + const [driveComments, setDriveComments] = useState(true) + const [copied, setCopied] = useState(false) + + useEffect(() => { dispatch(fetchSettings()) }, [dispatch]) + + useEffect(() => { + setClientId(settings.client_id || '') + setApiKey(settings.api_key || '') + setAppId(settings.app_id || '') + setDriveEnabled(!!settings.drive_enabled) + setDriveComments(settings.drive_comments === undefined ? true : !!settings.drive_comments) + }, [settings.client_id, settings.api_key, settings.app_id, settings.drive_enabled, settings.drive_comments]) + + const redirectUri = settings.redirect_uri || (window.PM_Vars?.google_workspace?.redirect_uri ?? '') + + function copyRedirect() { + navigator.clipboard?.writeText(redirectUri) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + async function toggleDrive(v) { + setDriveEnabled(v) + const res = await dispatch(saveSettings({ client_id: clientId, client_secret: '', api_key: apiKey, app_id: appId, drive_enabled: v })) + if (saveSettings.fulfilled.match(res)) { + toast.success(v ? __('Google Drive enabled.', 'wedevs-project-manager') : __('Google Drive disabled.', 'wedevs-project-manager')) + } else { + setDriveEnabled(!v) + toast.error(res.payload || __('Failed to update.', 'wedevs-project-manager')) + } + } + + async function toggleComments(v) { + setDriveComments(v) + const res = await dispatch(saveSettings({ client_id: clientId, client_secret: '', api_key: apiKey, app_id: appId, drive_enabled: driveEnabled, drive_comments: v })) + if (saveSettings.fulfilled.match(res)) { + toast.success(v ? __('Drive in comments enabled.', 'wedevs-project-manager') : __('Drive in comments disabled.', 'wedevs-project-manager')) + } else { + setDriveComments(!v) + toast.error(res.payload || __('Failed to update.', 'wedevs-project-manager')) + } + } + + async function onSave(e) { + e.preventDefault() + const res = await dispatch(saveSettings({ client_id: clientId, client_secret: clientSecret, api_key: apiKey, app_id: appId, drive_enabled: driveEnabled })) + if (saveSettings.fulfilled.match(res)) { + setClientSecret('') + toast.success(__('Google Workspace settings saved.', 'wedevs-project-manager')) + } else { + toast.error(res.payload || __('Failed to save settings.', 'wedevs-project-manager')) + } + } + + return ( +
+
+
+

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

+

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

+
+ + + +
+ +
+
+
+
{__('Enable Google Drive', 'wedevs-project-manager')}
+
{__('Show Google Drive in the sidebar and on tasks. Turn off to hide it everywhere.', 'wedevs-project-manager')}
+
+ +
+ + {driveEnabled && ( +
+
+
{__('Allow Drive in comments', 'wedevs-project-manager')}
+
{__('Let team members attach Drive files inside comments. Turn off to hide the Drive button in comments only.', 'wedevs-project-manager')}
+
+ +
+ )} +
+ + } + /> + } + /> + + {settings.picker_ready && driveEnabled && ( +
+ {__('Google Workspace is configured. Users can now connect their accounts.', 'wedevs-project-manager')} +
+ )} + +

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

+ +
+
+ +
+ {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/CommentLinkActions.jsx b/views/assets/src/components/google-workspace/CommentLinkActions.jsx new file mode 100644 index 000000000..0c4459081 --- /dev/null +++ b/views/assets/src/components/google-workspace/CommentLinkActions.jsx @@ -0,0 +1,19 @@ +import React from 'react' +/** + * CommentLinkActions — the Drive + Meet "insert link" buttons for a comment + * composer (new or edit). Drive is free (DriveCommentInsert); Meet is Pro, which + * fills the comment.composer.action slot. Both insert a link into the comment + * via onInsert(html). + */ +import DriveCommentInsert from './DriveCommentInsert' +import { Slot } from '@hooks/useSlot' + +export default function CommentLinkActions({ projectId, onInsert, allowMeet = true }) { + if (!projectId || typeof onInsert !== 'function') return null + return ( +
+ + {allowMeet && } +
+ ) +} diff --git a/views/assets/src/components/google-workspace/DriveCommentInsert.jsx b/views/assets/src/components/google-workspace/DriveCommentInsert.jsx new file mode 100644 index 000000000..656ce7f42 --- /dev/null +++ b/views/assets/src/components/google-workspace/DriveCommentInsert.jsx @@ -0,0 +1,71 @@ +import { __ } from '@wordpress/i18n' +/** + * DriveCommentInsert — Drive button for a comment composer (new or edit). Opens + * the Picker and inserts a link to the chosen file(s) into the comment text + * (no separate attachment record). Gated by Drive enabled + connection + the + * admin "Drive in comments" toggle + per-project role. + */ +import React, { useEffect, useState } from 'react' +import { useAppDispatch, useAppSelector } from '@store/index' +import { fetchStatus, fetchCanUse } from '@store/googleWorkspaceSlice' +import { Button } from '@components/ui/button' +import { DriveMonoGlyph } from '@components/google-workspace/GoogleIcons' +import DrivePickerModal from './DrivePickerModal' + +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] +)) +const safeHttpUrl = (u) => { + try { const p = new URL(u, window.location.origin); return (p.protocol === 'https:' || p.protocol === 'http:') ? p.href : '' } + catch { return '' } +} + +function linkHtml(f) { + const url = safeHttpUrl(f.webViewLink || f.url) + if (!url) return '' + return `

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

` +} + +export default function DriveCommentInsert({ projectId, onInsert }) { + const dispatch = useAppDispatch() + const status = useAppSelector(s => s.googleWorkspace.status) + const canUse = useAppSelector(s => s.googleWorkspace.canUseByProject[projectId]) + const [pickerOpen, setPickerOpen] = useState(false) + + useEffect(() => { + if (!status.configured && !status.connected) dispatch(fetchStatus()) + }, [dispatch]) // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (projectId && canUse === undefined) dispatch(fetchCanUse({ projectId })) + }, [dispatch, projectId]) // eslint-disable-line react-hooks/exhaustive-deps + + async function openPicker() { + try { + if (document.requestStorageAccessFor) { + await Promise.allSettled([ + document.requestStorageAccessFor('https://docs.google.com'), + document.requestStorageAccessFor('https://accounts.google.com'), + ]) + } + } catch (e) { /* guidance shows elsewhere */ } + setPickerOpen(true) + } + + if (status.drive_comments_on === false) return null + if (!(status.configured && status.connected && status.picker_ready && canUse === true)) return null + + return ( + <> + + {pickerOpen && ( + setPickerOpen(false)} + onPicked={(files) => { files.forEach(f => { const h = linkHtml(f); if (h) onInsert(h) }) }} + /> + )} + + ) +} diff --git a/views/assets/src/components/google-workspace/DrivePickerModal.jsx b/views/assets/src/components/google-workspace/DrivePickerModal.jsx new file mode 100644 index 000000000..748e7209a --- /dev/null +++ b/views/assets/src/components/google-workspace/DrivePickerModal.jsx @@ -0,0 +1,149 @@ +import { __ } from '@wordpress/i18n' +/** + * DrivePickerModal — launches the Google Picker (Google-hosted overlay) under + * the drive.file scope and attaches picked files by reference. + * + * The Picker is Google's own iframe overlay (appended to ), so it can't + * live inside our task sheet. We render nothing ourselves — just open the + * Picker on top of everything — to avoid a spinner dialog covering it. + */ +import { useEffect, useRef } from 'react' +import { useAppDispatch } from '@store/index' +import { fetchPickerConfig, attachFileFor } from '@store/googleWorkspaceSlice' +import { toast } from 'sonner' + +const GAPI_SRC = 'https://apis.google.com/js/api.js' +const ZINDEX_STYLE_ID = 'pm-gpicker-zindex' + +// Force the Google Picker above the task sheet / WP admin bar. +function ensureZIndexStyle() { + if (document.getElementById(ZINDEX_STYLE_ID)) return + const s = document.createElement('style') + s.id = ZINDEX_STYLE_ID + // z-index: above the task sheet / admin bar. + // pointer-events: the open Radix sheet sets body { pointer-events:none }, + // which freezes the Picker (it lives on ) — re-enable it here. + s.textContent = + '.picker-dialog-bg{z-index:2147483646 !important;pointer-events:auto !important}' + + '.picker-dialog{z-index:2147483647 !important;pointer-events:auto !important}' + + '.picker-dialog iframe{pointer-events:auto !important}' + document.head.appendChild(s) +} + +function loadPickerApi() { + return new Promise((resolve, reject) => { + if (window.google?.picker) return resolve() + const ready = () => window.gapi.load('picker', { callback: resolve, onerror: reject }) + if (window.gapi) return ready() + const existing = document.querySelector(`script[src="${GAPI_SRC}"]`) + if (existing) { + existing.addEventListener('load', ready) + existing.addEventListener('error', reject) + return + } + const el = document.createElement('script') + el.src = GAPI_SRC; el.async = true; el.defer = true + el.onload = ready; el.onerror = reject + document.body.appendChild(el) + }) +} + +export default function DrivePickerModal({ projectId, attachableType, attachableId, attachedIds = [], onClose, onPicked }) { + const dispatch = useAppDispatch() + const launchedRef = useRef(false) + + useEffect(() => { + if (launchedRef.current) return + launchedRef.current = true + + let cancelled = false + const toastId = toast.loading(__('Opening Google Drive…', 'wedevs-project-manager')) + + ;(async () => { + try { + const cfgRes = await dispatch(fetchPickerConfig()) + if (!fetchPickerConfig.fulfilled.match(cfgRes)) { + throw new Error(cfgRes.payload || __('Could not start Google Picker.', 'wedevs-project-manager')) + } + const { access_token, api_key, app_id } = cfgRes.payload + + await loadPickerApi() + if (cancelled) return + ensureZIndexStyle() + + const { google } = window + const view = new google.picker.DocsView(google.picker.ViewId.DOCS) + .setIncludeFolders(true) + .setSelectFolderEnabled(false) + .setMode(google.picker.DocsViewMode.LIST) + + const picker = new google.picker.PickerBuilder() + .enableFeature(google.picker.Feature.MULTISELECT_ENABLED) + .setOrigin(window.location.protocol + '//' + window.location.host) + .setOAuthToken(access_token) + .setDeveloperKey(api_key) + .setAppId(app_id) + .addView(view) + .setCallback((data) => handlePickerEvent(data, google)) + .build() + + toast.dismiss(toastId) + // Flag the active Picker session so the task sheet won't close on the + // Picker's outside focus/pointer events. + window.__pmGooglePickerOpen = true + picker.setVisible(true) + } catch (e) { + toast.dismiss(toastId) + toast.error(e.message || __('Failed to load Google Picker.', 'wedevs-project-manager')) + onClose() + } + })() + + return () => { cancelled = true; toast.dismiss(toastId); window.__pmGooglePickerOpen = false } + }, [dispatch]) // eslint-disable-line react-hooks/exhaustive-deps + + async function handlePickerEvent(data, google) { + const Action = google.picker.Action + + // Only PICKED/CANCEL end the session. Other callbacks (LOADED, folder + // navigation) must NOT clear the flag, or the sheet would close mid-pick. + if (data.action !== Action.PICKED && data.action !== Action.CANCEL) return + + // Session ending — clear the flag a tick later so the close event Radix + // sees is still suppressed, then normal behavior resumes. + setTimeout(() => { window.__pmGooglePickerOpen = false }, 300) + + if (data.action === Action.CANCEL) { onClose(); return } + + const docs = data.docs || [] + const files = docs + .filter(doc => !attachedIds.includes(doc.id)) + .map(doc => ({ + id: doc.id, + name: doc.name, + mimeType: doc.mimeType, + iconLink: doc.iconUrl, + webViewLink: doc.url, + modifiedTime: doc.lastEditedUtc ? new Date(doc.lastEditedUtc).toISOString() : '', + })) + + // Stage mode: hand picked files back to the caller (e.g. a create form + // whose entity doesn't exist yet) instead of attaching now. + if (typeof onPicked === 'function') { + if (files.length) onPicked(files) + onClose() + return + } + + let attached = 0 + for (const file of files) { + const res = await dispatch(attachFileFor({ projectId, attachableType, attachableId, file })) + if (attachFileFor.fulfilled.match(res)) attached++ + } + if (attached > 0) toast.success(__('File attached.', 'wedevs-project-manager')) + onClose() + } + + // Picker renders its own overlay; we render nothing. + return null +} diff --git a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx new file mode 100644 index 000000000..a7b164aa7 --- /dev/null +++ b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx @@ -0,0 +1,203 @@ +import { __ } from '@wordpress/i18n' +/** + * GoogleDriveAttach — reusable Drive attach + list for any entity + * (task, comment, discussion, project). Respects per-project role access and + * the global on/off + connection state. + * + * Props: + * projectId, attachableType ('task'|'comment'|'discussion'|'project'), attachableId + * variant: 'section' (bordered block, default) | 'compact' (inline chips) + * title (default "Google Drive") + */ +import React, { useEffect, useState } from 'react' +import { useAppDispatch, useAppSelector } from '@store/index' +import { fetchStatus, fetchCanUse, fetchAttachmentsFor, detachFileFor } from '@store/googleWorkspaceSlice' +import { Button } from '@components/ui/button' +import { FileText, Plus, ExternalLink, Trash2, Link2, Lock, X } from 'lucide-react' +import { toast } from 'sonner' +import { GoogleDriveColorGlyph, DriveMonoGlyph } from '@components/google-workspace/GoogleIcons' +import DrivePickerModal from './DrivePickerModal' + +const safeHttpUrl = (u) => { + try { const p = new URL(u, window.location.origin); return (p.protocol === 'https:' || p.protocol === 'http:') ? p.href : '' } + catch { return '' } +} + +// Tiny "added by" avatar with tooltip — non-intrusive attribution. +const AdderAvatar = ({ file }) => { + if (!file.added_by_name) return null + const title = file.added_by_name + return file.added_by_avatar + ? + : {title.charAt(0).toUpperCase()} +} + +const FileIcon = ({ file, className = 'h-4 w-4 shrink-0' }) => + file.icon_link + ? + : + +const MAX_VISIBLE = 2 + +export default function GoogleDriveAttach({ projectId, attachableType, attachableId, variant = 'section', title, allowEdit = true, addRevealClass = '', showAdd = true }) { + const dispatch = useAppDispatch() + const key = `${attachableType}:${attachableId}` + const status = useAppSelector(s => s.googleWorkspace.status) + const canUse = useAppSelector(s => s.googleWorkspace.canUseByProject[projectId]) + const canEdit = canUse === true && allowEdit + const attachments = useAppSelector(s => s.googleWorkspace.attachmentsByKey[key] || []) + const [pickerOpen, setPickerOpen] = useState(false) + const [expanded, setExpanded] = useState(false) + + useEffect(() => { + if (!status.configured && !status.connected) dispatch(fetchStatus()) + }, [dispatch]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (projectId && canUse === undefined) dispatch(fetchCanUse({ projectId })) + }, [dispatch, projectId]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (projectId && attachableId) dispatch(fetchAttachmentsFor({ projectId, attachableType, attachableId })) + }, [dispatch, projectId, attachableType, attachableId]) + + async function onDetach(id) { + const res = await dispatch(detachFileFor({ projectId, attachableType, attachableId, id })) + if (detachFileFor.fulfilled.match(res)) toast.success(__('File removed.', 'wedevs-project-manager')) + } + + async function openPicker() { + try { + if (document.requestStorageAccessFor) { + await Promise.allSettled([ + document.requestStorageAccessFor('https://docs.google.com'), + document.requestStorageAccessFor('https://accounts.google.com'), + ]) + } else if (document.hasStorageAccess && document.requestStorageAccess) { + const has = await document.hasStorageAccess() + if (!has) await document.requestStorageAccess() + } + } catch (e) { /* browser will prompt or guidance shows */ } + setPickerOpen(true) + } + + if (!status.configured) return null + // Nothing to show and can't add → hide entirely. + if ((canUse === false || !allowEdit) && attachments.length === 0) return null + // Chips-only mode (add button lives elsewhere) with nothing attached → hide. + if (!showAdd && attachments.length === 0) return null + + const action = status.connected && canEdit ? ( + variant === 'compact' ? ( + + ) : ( + + ) + ) : canUse === false ? ( + + {__('View only', 'wedevs-project-manager')} + + ) : canEdit && !status.connected ? ( + + {__('Connect Google', 'wedevs-project-manager')} + + ) : null + + const picker = pickerOpen && ( + f.file_id)} + onClose={() => setPickerOpen(false)} + /> + ) + + // Compact: a small button + chips (for comment/discussion composers). + if (variant === 'compact') { + return ( +
+ {attachments.map(file => ( + + + {file.name} + + {canEdit && ( + + )} + + ))} + {action} + {picker} +
+ ) + } + + // Section: bordered block with header (task detail / files). + return ( +
+
+
+
+ {action} +
+ + {attachments.length === 0 ? ( +

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

+ ) : ( + <> +
    + {(expanded ? attachments : attachments.slice(0, MAX_VISIBLE)).map(file => ( +
  • + + + {file.name} + + + + + + {canEdit && ( + + )} +
  • + ))} +
+ {attachments.length > MAX_VISIBLE && ( + + )} + + )} + {picker} +
+ ) +} diff --git a/views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx b/views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx new file mode 100644 index 000000000..9711f5fe0 --- /dev/null +++ b/views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx @@ -0,0 +1,62 @@ +import { __ } from '@wordpress/i18n' +/** + * GoogleDriveCommentButton — icon-only Drive add button for a comment header + * (sits beside Edit/Delete). Opens the Picker; attachments render separately + * via under the comment body. + */ +import React, { useEffect, useState } from 'react' +import { useAppDispatch, useAppSelector } from '@store/index' +import { fetchStatus, fetchCanUse, fetchAttachmentsFor } from '@store/googleWorkspaceSlice' +import { DriveMonoGlyph } from '@components/google-workspace/GoogleIcons' +import DrivePickerModal from './DrivePickerModal' + +export default function GoogleDriveCommentButton({ projectId, attachableType, attachableId, allowEdit = true, className = '' }) { + const dispatch = useAppDispatch() + const status = useAppSelector(s => s.googleWorkspace.status) + const canUse = useAppSelector(s => s.googleWorkspace.canUseByProject[projectId]) + const [pickerOpen, setPickerOpen] = useState(false) + + useEffect(() => { + if (!status.configured && !status.connected) dispatch(fetchStatus()) + }, [dispatch]) // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (projectId && canUse === undefined) dispatch(fetchCanUse({ projectId })) + }, [dispatch, projectId]) // eslint-disable-line react-hooks/exhaustive-deps + + async function openPicker() { + try { + if (document.requestStorageAccessFor) { + await Promise.allSettled([ + document.requestStorageAccessFor('https://docs.google.com'), + document.requestStorageAccessFor('https://accounts.google.com'), + ]) + } + } catch (e) { /* guidance shows elsewhere */ } + setPickerOpen(true) + } + + // Only show when the user can actually add here. + if (status.drive_comments_on === false) return null + if (!(status.configured && status.connected && status.picker_ready && canUse === true && allowEdit)) return null + + return ( + <> + + {pickerOpen && ( + setPickerOpen(false)} + /> + )} + + ) +} diff --git a/views/assets/src/components/google-workspace/GoogleDriveStage.jsx b/views/assets/src/components/google-workspace/GoogleDriveStage.jsx new file mode 100644 index 000000000..1150071a1 --- /dev/null +++ b/views/assets/src/components/google-workspace/GoogleDriveStage.jsx @@ -0,0 +1,87 @@ +import { __ } from '@wordpress/i18n' +/** + * GoogleDriveStage — pick Drive files BEFORE an entity exists (e.g. the + * new-discussion form). Controlled: holds picked files in `value`; the parent + * attaches them after the entity is created. No backend writes here. + */ +import React, { useEffect, useState } from 'react' +import { useAppDispatch, useAppSelector } from '@store/index' +import { fetchStatus, fetchCanUse } from '@store/googleWorkspaceSlice' +import { Button } from '@components/ui/button' +import { Plus, X, FileText, Link2 } from 'lucide-react' +import { DriveSolidGlyph } from '@components/google-workspace/GoogleIcons' +import DrivePickerModal from './DrivePickerModal' + +export default function GoogleDriveStage({ projectId, value = [], onChange }) { + const dispatch = useAppDispatch() + const status = useAppSelector(s => s.googleWorkspace.status) + const canUse = useAppSelector(s => s.googleWorkspace.canUseByProject[projectId]) + const [pickerOpen, setPickerOpen] = useState(false) + + useEffect(() => { + if (!status.configured && !status.connected) dispatch(fetchStatus()) + }, [dispatch]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (projectId && canUse === undefined) dispatch(fetchCanUse({ projectId })) + }, [dispatch, projectId]) // eslint-disable-line react-hooks/exhaustive-deps + + async function openPicker() { + try { + if (document.requestStorageAccessFor) { + await Promise.allSettled([ + document.requestStorageAccessFor('https://docs.google.com'), + document.requestStorageAccessFor('https://accounts.google.com'), + ]) + } + } catch (e) { /* guidance shows */ } + setPickerOpen(true) + } + + function onPicked(files) { + const existing = new Set(value.map(f => f.id)) + onChange([...value, ...files.filter(f => !existing.has(f.id))]) + } + + function remove(id) { + onChange(value.filter(f => f.id !== id)) + } + + if (!status.configured || canUse === false) return null + + return ( +
+ {value.map(file => ( + + {file.iconLink ? : } + {file.name} + + + ))} + + {status.connected ? ( + + ) : ( + + {__('Connect Google', 'wedevs-project-manager')} + + )} + + {pickerOpen && ( + f.id)} + onPicked={onPicked} + onClose={() => setPickerOpen(false)} + /> + )} +
+ ) +} diff --git a/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx new file mode 100644 index 000000000..c765ae339 --- /dev/null +++ b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx @@ -0,0 +1,18 @@ +import React from 'react' +import GoogleDriveAttach from './GoogleDriveAttach' + +/** + * Task detail Drive section — fills the task.detail.subtasks slot. + * Thin wrapper over the shared GoogleDriveAttach. + */ +export default function GoogleDriveTaskSection({ taskId, projectId }) { + if (!taskId || !projectId) return null + return ( + + ) +} diff --git a/views/assets/src/components/google-workspace/GoogleIcons.jsx b/views/assets/src/components/google-workspace/GoogleIcons.jsx new file mode 100644 index 000000000..b288accce --- /dev/null +++ b/views/assets/src/components/google-workspace/GoogleIcons.jsx @@ -0,0 +1,83 @@ +import React from 'react' + +/** Google Calendar brand glyph. Size via className (e.g. h-5 w-5). */ +export const CalendarGlyph = (props) => ( + + + + + + + + + + + + +) + +/** Monochrome Google Drive glyph (currentColor outline). Size via className. */ +export const DriveMonoGlyph = (props) => ( + +) + +/** Google Meet brand glyph. Size via className. */ +export const MeetGlyph = (props) => ( + + + + + + + + + + +) + +/** Solid monochrome Google Drive glyph (currentColor fill). Size via className. */ +export const DriveSolidGlyph = (props) => ( + +) + +/** Full-color Google Drive glyph. Size via width/height or className. */ +export const GoogleDriveColorGlyph = (props) => ( + + + + + + + + + + +) + +/** Full-color Google "G" logo. Default 20×20; override via width/height or className. */ +export const GoogleColorGlyph = (props) => ( + + + + + + +) + +/** Monochrome Google "G" logo (currentColor fill). Size via className. */ +export const GoogleMonoGlyph = (props) => ( + + + + + + +) diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx new file mode 100644 index 000000000..616feb808 --- /dev/null +++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx @@ -0,0 +1,219 @@ +import { __ } from '@wordpress/i18n' +/** + * GoogleWorkspacePage — per-user account connection (every user, incl. admin, + * connects their own Google account here). One connection powers all Google + * Workspace features (Drive now; Calendar/Meet later). + * + * Admin credential setup lives separately under Settings → Google Workspace. + */ +import React, { useEffect, useState } from 'react' +import { useAppDispatch, useAppSelector } from '@store/index' +import { fetchStatus, getAuthUrl, disconnect, saveDrivePref } from '@store/googleWorkspaceSlice' +import { Button } from '@components/ui/button' +import { Switch } from '@components/ui/switch' +import { Skeleton } from '@components/ui/skeleton' +import { + AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, + AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, +} from '@components/ui/alert-dialog' +import { ShieldCheck, Unlink, HardDrive, Settings as SettingsIcon, Lock, Info } from 'lucide-react' +import { CalendarGlyph, MeetGlyph, GoogleColorGlyph, GoogleDriveColorGlyph } from '@components/google-workspace/GoogleIcons' +import ProBadge from '@components/common/ProBadge' +import { toast } from 'sonner' +import { Slot } from '@hooks/useSlot' +import { useProModal } from '@components/common/ProUpgradeModal' + +/** + * Free card cover for a Pro Google feature section (e.g. Calendar). Pro replaces + * it by filling the matching slot with the real settings. + */ +const ProFeatureCard = ({ icon: Icon, title, description }) => { + const { setOpen } = useProModal() + return ( +
setOpen(true)} + > +
+
+ +
+
+ {title} + +
+
{description}
+
+
+ +
+
+ ) +} + +export default function GoogleWorkspacePage() { + const dispatch = useAppDispatch() + const { status, statusLoading } = useAppSelector(s => s.googleWorkspace) + const [connecting, setConnecting] = useState(false) + const [disconnectOpen, setDisconnectOpen] = useState(false) + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const result = params.get('google_connected') + if (!result) return + const messages = { + success: [__('Google account connected.', 'wedevs-project-manager'), 'success'], + error: [__('Could not connect Google account. Try again.', 'wedevs-project-manager'), 'error'], + denied: [__('Google connection was cancelled.', 'wedevs-project-manager'), 'error'], + invalid_state: [__('Security check failed. Please retry.', 'wedevs-project-manager'), 'error'], + } + const [msg, type] = messages[result] || [] + if (msg) (type === 'success' ? toast.success : toast.error)(msg) + params.delete('google_connected') + const clean = window.location.pathname + (params.toString() ? `?${params}` : '') + window.location.hash + window.history.replaceState({}, '', clean) + }, []) + + useEffect(() => { dispatch(fetchStatus()) }, [dispatch]) + + async function onConnect() { + setConnecting(true) + const res = await dispatch(getAuthUrl()) + setConnecting(false) + if (getAuthUrl.fulfilled.match(res) && res.payload) window.location.href = res.payload + else toast.error(res.payload || __('Could not start Google connection.', 'wedevs-project-manager')) + } + + async function onDisconnect() { + const res = await dispatch(disconnect()) + if (disconnect.fulfilled.match(res)) toast.success(__('Google account disconnected.', 'wedevs-project-manager')) + else toast.error(res.payload || __('Failed to disconnect.', 'wedevs-project-manager')) + } + + return ( +
+
+ +
+

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

+

{__('Connect your Google account to use Google features inside Project Manager.', '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 Workspace isn’t set up yet. An administrator needs to add the credentials under Settings → Google Workspace.', 'wedevs-project-manager')} +
+ ) : status.connected ? ( +
+
+ + {__('Connected as', 'wedevs-project-manager')} {status.account_email || __('Google account', 'wedevs-project-manager')} +
+ +
+ ) : ( +
+

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

+ +
+ )} + + {status.configured && ( +

+ + {__('All Google features — Drive, Calendar and Meet — use this one account.', 'wedevs-project-manager')} +

+ )} +
+ + {/* Connected services — one card per Google feature. */} +

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

+ + {/* Google Drive (free) — admin master gate + per-user on/off */} +
+
+
+ +
+
{__('Google Drive', 'wedevs-project-manager')}
+
{__('Attach Drive files to tasks, comments, discussions and files.', 'wedevs-project-manager')}
+
+
+ {status.drive_enabled ? ( + { + const res = await dispatch(saveDrivePref({ drive_on: v })) + if (saveDrivePref.fulfilled.match(res)) { + toast.success(v ? __('Google Drive enabled.', 'wedevs-project-manager') : __('Google Drive disabled.', 'wedevs-project-manager')) + } else { + toast.error(__('Failed to update Drive setting.', 'wedevs-project-manager')) + } + }} + /> + ) : ( + {__('Off', 'wedevs-project-manager')} + )} +
+ {!status.drive_enabled ? ( +

{__('Drive is turned off. An administrator can enable it in Settings → Google Workspace.', 'wedevs-project-manager')}

+ ) : !status.connected ? ( +

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

+ ) : null} +
+ + {/* Google Calendar — Pro fills with connect + status; free shows a cover. */} + } + /> + + {/* Google Meet — Pro (coming soon); free shows a cover. */} + } + /> + + + + + {__('Disconnect this Google account?', 'wedevs-project-manager')} + + {__('This unlinks your Google account from Project Manager. Here’s what happens:', 'wedevs-project-manager')} + + +
    +
  • {__('Calendar events created from your tasks and milestones are removed from Google Calendar.', 'wedevs-project-manager')}
  • +
  • {__('Drive files you attached stay listed, but you can’t open or attach more until you reconnect.', 'wedevs-project-manager')}
  • +
  • {__('All Google features (Drive, Calendar, Meet) stop working for you until you reconnect.', 'wedevs-project-manager')}
  • +
  • {__('You can reconnect anytime — your data isn’t deleted.', 'wedevs-project-manager')}
  • +
+ + {__('Cancel', 'wedevs-project-manager')} + { setDisconnectOpen(false); onDisconnect() }}> + {__('Disconnect', 'wedevs-project-manager')} + + +
+
+
+ ) +} diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index d3135bc85..036a58a77 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -1,6 +1,7 @@ import { __ } from '@wordpress/i18n'; import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import { useLocation, useNavigate, Link } from 'react-router-dom' +import { useSelector } from 'react-redux' import { useApi } from '@hooks/useApi' import { usePermissions } from '@hooks/usePermissions' import { useActiveProModules, isProModuleActive, isProPluginInstalled } from '@hooks/useActiveProModules' @@ -14,6 +15,7 @@ import { LayoutTemplate, } from 'lucide-react' import { cn } from '@lib/utils' +import { DriveMonoGlyph as GoogleDriveNavIcon } from '@components/google-workspace/GoogleIcons' function statusColor(p) { const s = p.status @@ -224,6 +226,15 @@ export function AppSidebar() { // Pro plugin installed (pm-pro.js loaded) — may or may not be licensed const isProInstalled = isProPluginInstalled() + // G Workspace nav visibility: show when the admin has enabled ANY feature + // (Drive, Calendar, or Meet). Prefer the live store (reacts to toggles this + // session); fall back to PM_Vars until status is first fetched. + const gwStatusFetched = useSelector(s => s.googleWorkspace?.statusFetched) + const gwStatus = useSelector(s => s.googleWorkspace?.status) + const gwVars = (typeof PM_Vars !== 'undefined' && PM_Vars.google_workspace) || {} + const anyFeature = (src) => !!(src && (src.drive_enabled || src.calendar_enabled || src.meet_enabled)) + const workspaceNavEnabled = gwStatusFetched ? anyFeature(gwStatus) : anyFeature(gwVars) + const viewNavItems = useMemo(() => { const isModuleActive = (dir) => isProModuleActive(activeModulePaths, dir) const items = [ @@ -238,8 +249,13 @@ export function AppSidebar() { items.push({ key: 'sprints', label: __('Sprints', 'wedevs-project-manager'), short: __('Sprint', 'wedevs-project-manager'), icon: Timer, route: '/sprints', pro: !isPro }) } } + // Google Workspace — free feature, shown to everyone when the admin has + // enabled Google Drive in Settings → Google Workspace. + if (workspaceNavEnabled) { + items.push({ key: 'google-workspace', label: __('G Workspace', 'wedevs-project-manager'), short: __('Workspace', 'wedevs-project-manager'), icon: GoogleDriveNavIcon, route: '/google-workspace' }) + } return items - }, [__, isPro, activeModulePaths, canManage, isManagerAnywhere]) + }, [__, isPro, activeModulePaths, canManage, isManagerAnywhere, workspaceNavEnabled]) // Auto-collapse sidebar on full-width pages (reports, calendar, progress, sprints) const autoCollapsedRef = useRef(false) @@ -284,6 +300,7 @@ export function AppSidebar() { if (path.startsWith('/progress')) return 'progress' if (path.startsWith('/reports')) return 'reports' if (path.startsWith('/sprints')) return 'sprints' + if (path.startsWith('/google-workspace')) return 'google-workspace' if (path.startsWith('/templates')) return 'templates' if (path.startsWith('/importtools')) return 'importtools' if (path.startsWith('/license')) return 'license' @@ -315,7 +332,7 @@ export function AppSidebar() { )} title={item.label} > - + {collapsed ? {item.short ?? item.label} : {item.label} diff --git a/views/assets/src/components/projects/ActivitiesPage/constants.js b/views/assets/src/components/projects/ActivitiesPage/constants.js index 7da75ae1c..a1a2ca598 100644 --- a/views/assets/src/components/projects/ActivitiesPage/constants.js +++ b/views/assets/src/components/projects/ActivitiesPage/constants.js @@ -2,7 +2,9 @@ import { __ } from '@wordpress/i18n'; import { Activity, CheckSquare, FolderKanban, MessageSquare, FileText, Milestone, Trash2, Edit3, ArrowUpDown, Plus, + Video, } from 'lucide-react'; +import { DriveMonoGlyph } from '@components/google-workspace/GoogleIcons'; export const ACTION_ICON_MAP = { create_project: FolderKanban, @@ -40,6 +42,9 @@ export const ACTION_ICON_MAP = { upload_file: FileText, create_file: FileText, delete_file: Trash2, + attach_drive_file: DriveMonoGlyph, + detach_drive_file: Trash2, + create_meet: Video, }; export const ACTION_COLOR_MAP = { @@ -77,6 +82,9 @@ export const ACTION_FALLBACKS = { comment_on_discussion_board: __('commented on a discussion', 'wedevs-project-manager'), comment_on_project: __('commented on a project', 'wedevs-project-manager'), reply_comment_on_task: __('replied to a comment', 'wedevs-project-manager'), + attach_drive_file: __('attached a Google Drive file', 'wedevs-project-manager'), + detach_drive_file: __('removed a Google Drive file', 'wedevs-project-manager'), + create_meet: __('started a Google Meet meeting', 'wedevs-project-manager'), }; -export { Activity }; +export { Activity, DriveMonoGlyph, Video }; diff --git a/views/assets/src/components/projects/ActivitiesPage/parts/ActivityItem.jsx b/views/assets/src/components/projects/ActivitiesPage/parts/ActivityItem.jsx index 3026ba97a..cffcf339b 100644 --- a/views/assets/src/components/projects/ActivitiesPage/parts/ActivityItem.jsx +++ b/views/assets/src/components/projects/ActivitiesPage/parts/ActivityItem.jsx @@ -11,6 +11,8 @@ import { ACTION_COLOR_MAP, ACTION_LABELS, Activity, + DriveMonoGlyph, + Video, } from '../constants'; import { parseMessage, formatTime } from '../utils'; @@ -32,6 +34,29 @@ export default function ActivityItem({ act, projectId: fallbackProjectId }) { const projectId = act.project?.data?.id || act.project?.id || act.meta?.project_id || fallbackProjectId; const isTask = act.resource_type === 'task' && act.resource_id && projectId; + const meta = act.meta || {}; + // Drive link only for the "attached" entry; deleted actions never link the file. + const driveUrl = act.action === 'attach_drive_file' ? meta.file_url : null; + const showDrive = act.action === 'attach_drive_file' || !!meta.has_drive; + const showMeet = !!meta.has_meet; + + const marks = (showDrive || showMeet) ? ( + + {showDrive && ( + driveUrl ? ( + + + + ) : ( + + ) + )} + {showMeet && ( + + ) : null; + const handleMessageClick = () => { if (isTask) { dispatch(openTaskSheet({ id: act.resource_id, project_id: projectId })); @@ -54,19 +79,22 @@ export default function ActivityItem({ act, projectId: fallbackProjectId }) { {badgeLabel} - {isTask ? ( - - ) : ( -

{parseMessage(act)}

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

{parseMessage(act)}

+ )} + {marks} +
{timeStr && ( - {timeStr} + {timeStr} )}
diff --git a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx index 32fac9350..090382ca1 100644 --- a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx +++ b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx @@ -13,6 +13,7 @@ import NotionPreviewContainer from "@components/common/NotionPreviewContainer"; import LoomPreviewContainer from "@components/common/LoomPreviewContainer"; import { stripAllPreviewUrls } from "@/lib/url-strippers"; import { sanitizeHtml } from "@lib/sanitize"; +import { decorateGoogleLinks } from "@lib/google-links"; import { Skeleton } from "@components/ui/skeleton"; import { UserAvatar } from "@components/common/UserAvatar"; import { Separator } from "@components/ui/separator"; @@ -46,6 +47,8 @@ import ProBadge from "@components/common/ProBadge"; import CommentAttachment from "@components/common/CommentAttachment"; import { formatPmDateTime } from "@lib/pm-utils"; import { usePermissions } from "@hooks/usePermissions"; +import GoogleDriveAttach from "@components/google-workspace/GoogleDriveAttach"; +import CommentLinkActions from "@components/google-workspace/CommentLinkActions"; import { useCurrentProject } from "@hooks/useCurrentProject"; import DiscussionFiles from "./parts/DiscussionFiles"; @@ -455,7 +458,7 @@ export default function DiscussionDetailPage() {
@@ -466,6 +469,9 @@ export default function DiscussionDetailPage() { })()} +
+ +
)}
@@ -547,6 +553,7 @@ export default function DiscussionDetailPage() { > {__("Cancel", 'wedevs-project-manager')} + setEditCommentText(prev => (prev || '') + html)} />
) : ( @@ -554,7 +561,7 @@ export default function DiscussionDetailPage() {
@@ -599,6 +606,7 @@ export default function DiscussionDetailPage() { value={commentNotifyUsers} onChange={setCommentNotifyUsers} /> + setNewComment(prev => (prev || '') + html)} />
diff --git a/views/assets/src/components/projects/DiscussionsPage/index.jsx b/views/assets/src/components/projects/DiscussionsPage/index.jsx index 304ac1c95..811817188 100644 --- a/views/assets/src/components/projects/DiscussionsPage/index.jsx +++ b/views/assets/src/components/projects/DiscussionsPage/index.jsx @@ -28,6 +28,9 @@ import { ChevronRight, } from "lucide-react"; import FileUploadArea from "@components/common/FileUploadArea"; +import CommentLinkActions from "@components/google-workspace/CommentLinkActions"; +import { useAppDispatch } from "@store/index"; +import { attachFileFor } from "@store/googleWorkspaceSlice"; import NotifyUsers from "@components/common/NotifyUsers"; import { DropdownMenu, @@ -45,6 +48,7 @@ export default function DiscussionsPage() { const { projectId } = useParams(); const navigate = useNavigate(); const api = useApi(); + const dispatch = useAppDispatch(); const toast = useToast(); const [ConfirmDialog, confirm] = useConfirm(); const project = useCurrentProject(projectId); @@ -67,6 +71,7 @@ export default function DiscussionsPage() { const [formDesc, setFormDesc] = useState(""); const [formMilestone, setFormMilestone] = useState("-1"); const [formFiles, setFormFiles] = useState([]); + const [stagedDrive, setStagedDrive] = useState([]); const [formNotifyUsers, setFormNotifyUsers] = useState([]); const [creating, setCreating] = useState(false); @@ -120,10 +125,17 @@ export default function DiscussionsPage() { formFiles.forEach((f) => fd.append("files[]", f)); const res = await api.upload(`projects/${projectId}/discussion-boards`, fd); const newDisc = res?.data ?? res; + // Attach any staged Drive files now that the discussion has an id. + if (newDisc?.id && stagedDrive.length) { + for (const file of stagedDrive) { + await dispatch(attachFileFor({ projectId, attachableType: 'discussion', attachableId: newDisc.id, file })); + } + } setFormTitle(""); setFormDesc(""); setFormMilestone("-1"); setFormFiles([]); + setStagedDrive([]); setFormNotifyUsers([]); setShowForm(false); toast.success(__("Discussion created", 'wedevs-project-manager')); @@ -137,7 +149,7 @@ export default function DiscussionsPage() { } setCreating(false); }, - [api, projectId, formTitle, formDesc, formMilestone, formFiles, formNotifyUsers, creating, toast, __, fetchDiscussions, navigate] + [api, projectId, formTitle, formDesc, formMilestone, formFiles, formNotifyUsers, creating, toast, __, fetchDiscussions, navigate, stagedDrive, dispatch] ); const handleDelete = useCallback( @@ -232,6 +244,7 @@ export default function DiscussionsPage() { + setFormDesc(prev => (prev || '') + html)} /> ) : ( -
+
)} {/* Comment files */} {!isEditing && comment.files?.data?.length > 0 && ( diff --git a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx index a2bbb7b98..8e29749d5 100644 --- a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx +++ b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx @@ -25,8 +25,10 @@ import NotionPreviewContainer from '@components/common/NotionPreviewContainer' import LoomPreviewContainer from '@components/common/LoomPreviewContainer' import { stripAllPreviewUrls } from '@/lib/url-strippers' import { sanitizeHtml } from '@lib/sanitize' +import { decorateGoogleLinks } from '@lib/google-links' import FileUploadArea from '@components/common/FileUploadArea' import CommentAttachment from '@components/common/CommentAttachment' +import CommentLinkActions from '@components/google-workspace/CommentLinkActions' import TaskStatusCircle from '@components/common/TaskStatusCircle' import NotifyUsers from '@components/common/NotifyUsers' import { UserAvatar } from '@components/common/UserAvatar' @@ -62,7 +64,9 @@ import { Pencil, FileText, Loader2, + Video, } from 'lucide-react' +import { DriveMonoGlyph } from '@components/google-workspace/GoogleIcons' import { isTaskComplete, formatPmDate, @@ -89,6 +93,19 @@ function extractMentionedUsers(html) { return ids.join(',') } +// The Google Drive Picker renders its own overlay outside this sheet's DOM. +// Treat clicks/focus on it as "inside" so the task sheet stays open while +// the user picks a file (only the X / Esc should close the sheet). +function isGooglePickerInteraction(e) { + // While the Picker session is active, never let an outside interaction close + // the sheet (the Picker overlay lives outside this DOM and its focus/pointer + // events would otherwise dismiss the task). + if (typeof window !== 'undefined' && window.__pmGooglePickerOpen) return true + const t = e?.detail?.originalEvent?.target || e?.target + if (!t || typeof t.closest !== 'function') return false + return !!t.closest('.picker-dialog, .picker-dialog-bg, .picker, .picker-dialog-content') +} + export default function TaskDetailSheet() { const dispatch = useAppDispatch() const navigate = useNavigate() @@ -486,6 +503,9 @@ export default function TaskDetailSheet() { 'overflow-y-auto p-0 transition-all duration-300', fullscreen ? 'w-full sm:max-w-full' : 'w-full sm:max-w-[560px]', )} + onPointerDownOutside={(e) => { if (isGooglePickerInteraction(e)) e.preventDefault() }} + onInteractOutside={(e) => { if (isGooglePickerInteraction(e)) e.preventDefault() }} + onFocusOutside={(e) => { if (isGooglePickerInteraction(e)) e.preventDefault() }} > {loading && !currentTask ? (
@@ -827,11 +847,12 @@ export default function TaskDetailSheet() { {savingEditComment ? __('Saving...', 'wedevs-project-manager') : __('Save', 'wedevs-project-manager')} + setEditCommentText(prev => (prev || '') + html)} />
) : ( <> -
+
@@ -865,9 +886,12 @@ export default function TaskDetailSheet() { value={commentNotifyUsers} onChange={setCommentNotifyUsers} /> - +
+ + setNewComment(prev => (prev || '') + html)} /> +
@@ -935,6 +959,18 @@ export default function TaskDetailSheet() { ) : ( {parseActivityMessage(act) || act.action} )} + {(act.action === 'attach_drive_file' || act.meta?.has_drive) && ( + act.action === 'attach_drive_file' && act.meta?.file_url ? ( + + + + ) : ( + + ) + )} + {act.meta?.has_meet && ( +
diff --git a/views/assets/src/index.jsx b/views/assets/src/index.jsx index 47d6bbd52..f69daa96c 100644 --- a/views/assets/src/index.jsx +++ b/views/assets/src/index.jsx @@ -54,6 +54,14 @@ const MyTasksPage = React.lazy(() => import('@components/my-tasks/MyTasksPag const ToolsPage = React.lazy(() => import('@components/projects/ToolsPage')) const WelcomePage = React.lazy(() => import('@components/welcome/WelcomePage')) const LicensePage = React.lazy(() => import('@components/projects/LicensePage')) +const GoogleWorkspacePage = React.lazy(() => import('@components/google-workspace/GoogleWorkspacePage')) +const GoogleDriveTaskSection = React.lazy(() => import('@components/google-workspace/GoogleDriveTaskSection')) + +// Google Drive attachments render in the task detail sheet (free feature), +// only when an admin has enabled Drive in Settings → Google Workspace. +if (typeof PM_Vars !== 'undefined' && PM_Vars.google_workspace?.drive_enabled) { + registerSlot('task.detail.subtasks', GoogleDriveTaskSection) +} // ── Free placeholder pages (shown when Pro does NOT replace them) ── const CalendarPlaceholder = React.lazy(() => import('@components/projects/CalendarPage')) @@ -125,6 +133,7 @@ function AppRoutes() { {!isFrontend && } />} {!isFrontend && } />} } /> + } /> {!isFrontend && isProInstalled && } />} {/* ── Replaceable pages — Pro overrides via registerFilter() ── */} @@ -242,6 +251,13 @@ window.PM = { // Free store actions/thunks that pro may need to dispatch thunks: { fetchTask, fetchTaskLists, fetchProjectAssignees }, + + // Google Workspace: free owns OAuth/token; pro (Calendar/Meet) dispatches these. + googleWorkspace: { + fetchStatus: require('@store/googleWorkspaceSlice').fetchStatus, + getAuthUrl: require('@store/googleWorkspaceSlice').getAuthUrl, + disconnect: require('@store/googleWorkspaceSlice').disconnect, + }, actions: { resetProjectState, openTaskSheet, closeTaskSheet, markTaskModified }, // Free hooks that pro may use (same Redux store context) @@ -259,6 +275,11 @@ window.PM = { BackButton: require('@components/common/BackButton'), FileUploadArea: require('@components/common/FileUploadArea'), CommentAttachment: require('@components/common/CommentAttachment'), + GoogleDriveAttach: require('@components/google-workspace/GoogleDriveAttach'), + GoogleDriveCommentButton: require('@components/google-workspace/GoogleDriveCommentButton'), + CommentLinkActions: require('@components/google-workspace/CommentLinkActions'), + GoogleDriveStage: require('@components/google-workspace/GoogleDriveStage'), + DrivePickerModal: require('@components/google-workspace/DrivePickerModal'), TaskStatusCircle: require('@components/common/TaskStatusCircle'), ProBadge: require('@components/common/ProBadge'), ProUpgradeModal: require('@components/common/ProUpgradeModal'), @@ -330,6 +351,7 @@ window.PM = { urlStrippers: require('@/lib/url-strippers'), sanitize: require('@lib/sanitize'), pmUtils: require('@lib/pm-utils'), + googleLinks: require('@lib/google-links'), }, // Re-export Radix UI primitives so pro uses the SAME context instances. diff --git a/views/assets/src/lib/google-links.js b/views/assets/src/lib/google-links.js new file mode 100644 index 000000000..ad2d2bb5b --- /dev/null +++ b/views/assets/src/lib/google-links.js @@ -0,0 +1,112 @@ +/** + * Render-only decoration for Google Drive / Meet links inside comment HTML. + * + * IMPORTANT: nothing here is ever stored. The DB keeps a plain link + * (no emoji, no 4-byte chars) so the comment save flow stays safe on utf8mb3 + * columns. We inject mono lucide-style SVGs at render time, AFTER sanitize. + */ + +// lucide 24x24 outline paths, mono via currentColor. Rendered at 14px, light shade. +const SW = 'width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"' +// shared file-body path used by most lucide File* glyphs +const BODY = '' +const ICONS = { + drive: + ``, + doc: `${BODY}`, + pdf: `${BODY}`, + sheet: `${BODY}`, + slides: ``, + archive:``, + audio: ``, + video: `${BODY}`, + image: `${BODY}`, + code: `${BODY}`, + meet: ``, +} + +function driveKind(href, text) { + if (/docs\.google\.com\/spreadsheets/.test(href)) return 'sheet' + if (/docs\.google\.com\/presentation/.test(href)) return 'slides' + if (/docs\.google\.com\/document/.test(href)) return 'doc' + const name = (text || '').toLowerCase() + if (/\.pdf\b/.test(name)) return 'pdf' + if (/\.(xls|xlsx|csv|ods)\b/.test(name)) return 'sheet' + if (/\.(ppt|pptx|key|odp)\b/.test(name)) return 'slides' + if (/\.(doc|docx|txt|rtf|pages|odt|md)\b/.test(name)) return 'doc' + if (/\.(zip|rar|7z|tar|gz|tgz|bz2)\b/.test(name)) return 'archive' + if (/\.(mp3|wav|flac|aac|ogg|m4a|wma)\b/.test(name)) return 'audio' + if (/\.(mp4|mov|avi|mkv|webm|flv|wmv|m4v)\b/.test(name)) return 'video' + if (/\.(jpg|jpeg|png|gif|svg|webp|heic|bmp|tiff?)\b/.test(name)) return 'image' + if (/\.(js|ts|jsx|tsx|json|html?|css|php|py|xml|java|rb|go|sh|sql|yml|yaml)\b/.test(name)) return 'code' + return 'drive' +} + +function iconHtml(kind) { + return `` +} + +/** Only allow http(s) URLs through (blocks javascript:/data: etc. in injected links). */ +function safeHttpUrl(u) { + try { + const parsed = new URL(u, window.location.origin) + return (parsed.protocol === 'https:' || parsed.protocol === 'http:') ? parsed.href : '' + } catch { + return '' + } +} + +export function decorateGoogleLinks(html) { + if (typeof html !== 'string' || html === '') return html + if (!/drive\.google\.com|docs\.google\.com|meet\.google\.com/.test(html)) return html + + let doc + try { + doc = new DOMParser().parseFromString(html, 'text/html') + } catch { + return html + } + + doc.querySelectorAll('a[href]').forEach((a) => { + const href = a.getAttribute('href') || '' + const text = a.textContent || '' + + if (/meet\.google\.com/.test(href)) { + // Build via DOM (textContent + setAttribute) so the comment-supplied + // title/href can never inject markup (stored XSS guard). The icon span is + // a trusted constant, so innerHTML is fine for it only. + const card = doc.createElement('span') + card.className = + 'pm-meet-card inline-flex flex-col gap-1 w-fit max-w-full rounded-md border border-pm-text-muted/20 px-3 py-2 my-1 text-sm align-middle' + + const top = doc.createElement('span') + top.className = 'inline-flex items-center gap-2' + top.innerHTML = iconHtml('meet') + const titleEl = doc.createElement('span') + titleEl.className = 'text-pm-text' + titleEl.textContent = text || 'Google Meet' + top.appendChild(titleEl) + + const join = doc.createElement('a') + join.className = 'text-pm-accent hover:underline shrink-0' + join.setAttribute('target', '_blank') + join.setAttribute('rel', 'noreferrer') + join.textContent = 'Join Google Meet' + const safe = safeHttpUrl(href) + if (safe) join.setAttribute('href', safe) + + card.appendChild(top) + card.appendChild(join) + a.replaceWith(card) + return + } + + if (/drive\.google\.com|docs\.google\.com/.test(href)) { + const kind = driveKind(href, text) + a.classList.add('pm-gdrive-link', 'inline-flex', 'items-center', 'gap-1', 'align-middle') + a.insertAdjacentHTML('afterbegin', iconHtml(kind)) + } + }) + + return doc.body.innerHTML +} diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js new file mode 100644 index 000000000..c2cd3ac2c --- /dev/null +++ b/views/assets/src/store/googleWorkspaceSlice.js @@ -0,0 +1,234 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import { useApi } from '@hooks/useApi' + +const api = useApi() + +const initialState = { + status: { configured: false, picker_ready: false, drive_enabled: false, calendar_enabled: false, meet_enabled: false, connected: false, account_email: '', expired: false, calendar_connected: false, meet_connected: false, drive_user_on: true, drive_comments_on: true }, + settings: { client_id: '', has_secret: false, api_key: '', app_id: '', drive_enabled: false, picker_ready: false, redirect_uri: '' }, + statusLoading: false, + statusFetched: false, // true once status/settings resolved — lets UI prefer live store over stale PM_Vars + settingsLoading: false, + saving: false, + attachmentsByTask: {}, + attachmentsByKey: {}, // { 'type:id': [files] } + attachLoading: false, + + // Per-project Drive role access + canUseByProject: {}, // { [projectId]: bool } + accessByProject: {}, // { [projectId]: { co_worker, client } } +} + +export const fetchStatus = createAsyncThunk( + 'googleWorkspace/fetchStatus', + async (_, { rejectWithValue }) => { + try { const res = await api.get('google-workspace/status'); return res.data ?? res } + catch (e) { return rejectWithValue(e.message) } + }, +) + +export const fetchSettings = createAsyncThunk( + 'googleWorkspace/fetchSettings', + async (_, { rejectWithValue }) => { + try { const res = await api.get('google-workspace/settings'); return res.data ?? res } + catch (e) { return rejectWithValue(e.message) } + }, +) + +export const saveSettings = createAsyncThunk( + 'googleWorkspace/saveSettings', + async ({ client_id, client_secret, api_key, app_id, drive_enabled, drive_comments }, { rejectWithValue }) => { + try { + const body = { client_id, client_secret, api_key, app_id, drive_enabled } + if (drive_comments !== undefined) body.drive_comments = drive_comments ? 1 : 0 + const res = await api.post('google-workspace/settings', body) + return res.data ?? res + } catch (e) { return rejectWithValue(e.message) } + }, +) + +export const getAuthUrl = createAsyncThunk( + 'googleWorkspace/getAuthUrl', + async (arg, { rejectWithValue }) => { + const params = {} + if (arg && arg.withCalendar) params.with_calendar = 1 + try { const res = await api.get('google-workspace/auth-url', Object.keys(params).length ? params : undefined); return (res.data ?? res).auth_url } + catch (e) { return rejectWithValue(e.message) } + }, +) + +export const saveDrivePref = createAsyncThunk( + 'googleWorkspace/saveDrivePref', + async ({ drive_on }, { rejectWithValue }) => { + try { const res = await api.post('google-workspace/my-prefs', { drive_on: drive_on ? 1 : 0 }); return (res.data ?? res).drive_user_on } + catch (e) { return rejectWithValue(e.message) } + }, +) + +export const disconnect = createAsyncThunk( + 'googleWorkspace/disconnect', + async (_, { rejectWithValue }) => { + try { await api.post('google-workspace/disconnect'); return true } + catch (e) { return rejectWithValue(e.message) } + }, +) + +export const fetchPickerConfig = createAsyncThunk( + 'googleWorkspace/fetchPickerConfig', + async (_, { rejectWithValue }) => { + try { const res = await api.get('google-workspace/drive/picker-config'); return res.data ?? res } + catch (e) { return rejectWithValue(e.message) } + }, +) + +export const fetchAttachments = createAsyncThunk( + 'googleWorkspace/fetchAttachments', + async ({ projectId, taskId }, { rejectWithValue }) => { + try { + const res = await api.get(`projects/${projectId}/tasks/${taskId}/google-drive`) + return { taskId, files: res.data ?? res } + } catch (e) { return rejectWithValue(e.message) } + }, +) + +export const attachFile = createAsyncThunk( + 'googleWorkspace/attachFile', + async ({ projectId, taskId, file }, { rejectWithValue }) => { + try { + const res = await api.post(`projects/${projectId}/tasks/${taskId}/google-drive`, { file }) + return { taskId, file: res.data ?? res } + } catch (e) { return rejectWithValue(e.message) } + }, +) + +const akey = (type, id) => `${type}:${id}` + +export const fetchAttachmentsFor = createAsyncThunk( + 'googleWorkspace/fetchAttachmentsFor', + async ({ projectId, attachableType, attachableId }, { rejectWithValue }) => { + try { + const res = await api.get(`projects/${projectId}/google-drive`, { attachable_type: attachableType, attachable_id: attachableId }) + return { key: akey(attachableType, attachableId), files: res.data ?? res } + } catch (e) { return rejectWithValue(e.message) } + }, +) + +export const attachFileFor = createAsyncThunk( + 'googleWorkspace/attachFileFor', + async ({ projectId, attachableType, attachableId, file }, { rejectWithValue }) => { + try { + const res = await api.post(`projects/${projectId}/google-drive`, { attachable_type: attachableType, attachable_id: attachableId, file }) + return { key: akey(attachableType, attachableId), file: res.data ?? res } + } catch (e) { return rejectWithValue(e.message) } + }, +) + +export const detachFileFor = createAsyncThunk( + 'googleWorkspace/detachFileFor', + async ({ projectId, attachableType, attachableId, id }, { rejectWithValue }) => { + try { + await api.del(`projects/${projectId}/google-drive/${id}`) + return { key: akey(attachableType, attachableId), id } + } catch (e) { return rejectWithValue(e.message) } + }, +) + +export const fetchCanUse = createAsyncThunk( + 'googleWorkspace/fetchCanUse', + async ({ projectId }, { rejectWithValue }) => { + try { + const res = await api.get(`projects/${projectId}/google-workspace/can-use`) + return { projectId, canUse: !!(res.data ?? res).can_use } + } catch (e) { return rejectWithValue(e.message) } + }, +) + +export const fetchProjectAccess = createAsyncThunk( + 'googleWorkspace/fetchProjectAccess', + async ({ projectId }, { rejectWithValue }) => { + try { + const res = await api.get(`projects/${projectId}/google-workspace/access`) + return { projectId, access: res.data ?? res } + } catch (e) { return rejectWithValue(e.message) } + }, +) + +export const saveProjectAccess = createAsyncThunk( + 'googleWorkspace/saveProjectAccess', + async ({ projectId, co_worker, client }, { rejectWithValue }) => { + try { + const res = await api.post(`projects/${projectId}/google-workspace/access`, { co_worker, client }) + return { projectId, access: res.data ?? res } + } catch (e) { return rejectWithValue(e.message) } + }, +) + +export const detachFile = createAsyncThunk( + 'googleWorkspace/detachFile', + async ({ projectId, taskId, id }, { rejectWithValue }) => { + try { + await api.del(`projects/${projectId}/tasks/${taskId}/google-drive/${id}`) + return { taskId, id } + } catch (e) { return rejectWithValue(e.message) } + }, +) + +const slice = createSlice({ + name: 'googleWorkspace', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchStatus.pending, (s) => { s.statusLoading = true }) + .addCase(fetchStatus.fulfilled, (s, a) => { s.statusLoading = false; s.statusFetched = true; s.status = a.payload }) + .addCase(fetchStatus.rejected, (s) => { s.statusLoading = false }) + + .addCase(fetchSettings.pending, (s) => { s.settingsLoading = true }) + .addCase(fetchSettings.fulfilled, (s, a) => { s.settingsLoading = false; s.settings = a.payload }) + .addCase(fetchSettings.rejected, (s) => { s.settingsLoading = false }) + + .addCase(saveSettings.pending, (s) => { s.saving = true }) + .addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.statusFetched = true; s.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready; s.status.drive_enabled = a.payload.drive_enabled; s.status.calendar_enabled = a.payload.calendar_enabled; s.status.meet_enabled = a.payload.meet_enabled; s.status.drive_comments_on = a.payload.drive_comments }) + .addCase(saveSettings.rejected, (s) => { s.saving = false }) + + .addCase(disconnect.fulfilled, (s) => { s.status = { ...s.status, connected: false, account_email: '', expired: false, calendar_connected: false, meet_connected: false } }) + .addCase(saveDrivePref.fulfilled, (s, a) => { s.status.drive_user_on = a.payload }) + + .addCase(fetchAttachmentsFor.fulfilled, (s, a) => { s.attachmentsByKey[a.payload.key] = a.payload.files }) + .addCase(attachFileFor.pending, (s) => { s.attachLoading = true }) + .addCase(attachFileFor.fulfilled, (s, a) => { + s.attachLoading = false + const list = s.attachmentsByKey[a.payload.key] || [] + if (!list.some(f => f.id === a.payload.file.id)) { + s.attachmentsByKey[a.payload.key] = [a.payload.file, ...list] + } + }) + .addCase(attachFileFor.rejected, (s) => { s.attachLoading = false }) + .addCase(detachFileFor.fulfilled, (s, a) => { + const list = s.attachmentsByKey[a.payload.key] || [] + s.attachmentsByKey[a.payload.key] = list.filter(f => f.id !== a.payload.id) + }) + + .addCase(fetchCanUse.fulfilled, (s, a) => { s.canUseByProject[a.payload.projectId] = a.payload.canUse }) + .addCase(fetchProjectAccess.fulfilled, (s, a) => { s.accessByProject[a.payload.projectId] = a.payload.access }) + .addCase(saveProjectAccess.fulfilled, (s, a) => { s.accessByProject[a.payload.projectId] = a.payload.access }) + + .addCase(fetchAttachments.fulfilled, (s, a) => { s.attachmentsByTask[a.payload.taskId] = a.payload.files }) + .addCase(attachFile.pending, (s) => { s.attachLoading = true }) + .addCase(attachFile.fulfilled, (s, a) => { + s.attachLoading = false + const list = s.attachmentsByTask[a.payload.taskId] || [] + if (!list.some(f => f.id === a.payload.file.id)) { + s.attachmentsByTask[a.payload.taskId] = [a.payload.file, ...list] + } + }) + .addCase(attachFile.rejected, (s) => { s.attachLoading = false }) + + .addCase(detachFile.fulfilled, (s, a) => { + const list = s.attachmentsByTask[a.payload.taskId] || [] + s.attachmentsByTask[a.payload.taskId] = list.filter(f => f.id !== a.payload.id) + }) + }, +}) + +export default slice.reducer diff --git a/views/assets/src/store/index.js b/views/assets/src/store/index.js index 98bc8abe0..04d1cd69e 100644 --- a/views/assets/src/store/index.js +++ b/views/assets/src/store/index.js @@ -5,6 +5,7 @@ import projectsReducer from './projectsSlice' import taskListsReducer from './taskListsSlice' import tasksReducer from './tasksSlice' import milestonesReducer from './milestonesSlice' +import googleWorkspaceReducer from './googleWorkspaceSlice' import kanbanReducer from './kanbanSlice' // Re-export for convenience @@ -17,6 +18,7 @@ const staticReducers = { taskLists: taskListsReducer, tasks: tasksReducer, milestones: milestonesReducer, + googleWorkspace: googleWorkspaceReducer, kanban: kanbanReducer, }