From ad4f2915afa814771c8c7314dab592adaf26c9e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Sch=C3=A4fer?= Date: Tue, 30 Apr 2024 14:43:38 +0200 Subject: [PATCH] Feature: Desktop view - Implement the personal setting section to list and delete user devices. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Florian Liebe Co-authored-by: Tobias Schäfer Co-authored-by: Dusan Vuckovic Co-authored-by: Dominik Klein --- app/controllers/user_devices_controller.rb | 19 +- .../CommonActionMenu/CommonActionMenu.vue | 26 +- .../CommonPopover/CommonPopoverMenu.vue | 7 +- .../CommonPopover/CommonPopoverMenuItem.vue | 10 +- .../desktop/components/CommonPopover/types.ts | 2 +- .../CommonSimpleTable/CommonSimpleTable.vue | 42 +- .../__tests__/CommonSimpleTable.spec.ts | 34 +- .../CommonSimpleTable.spec.ts.snapshot.txt | 69 ++- .../components/CommonSimpleTable/types.ts | 1 + .../personal-setting-appearance-a11y.spec.ts | 2 +- .../personal-setting-devices-a11y.spec.ts | 57 +++ .../personal-setting-devices.spec.ts | 202 +++++++++ .../personal-setting-password-a11y.spec.ts | 2 +- .../fragments/userDeviceAttributes.api.ts | 20 + .../fragments/userDeviceAttributes.graphql | 15 + .../mutations/accountDeviceDelete.api.ts | 22 + .../mutations/accountDeviceDelete.graphql | 8 + .../mutations/accountDeviceDelete.mocks.ts | 12 + .../graphql/queries/accountDeviceList.api.ts | 22 + .../graphql/queries/accountDeviceList.graphql | 5 + .../queries/accountDeviceList.mocks.ts | 12 + .../accountDevicesUpdates.api.ts | 21 + .../accountDevicesUpdates.graphql | 7 + .../accountDevicesUpdates.mocks.ts | 8 + .../views/PersonalSettingDevices.vue | 155 ++++++- .../cache/initializer/accountDeviceList.ts.ts | 8 + .../apps/desktop/styles/tailwind.desktop.js | 7 + .../__tests__/ObjectAttributes.spec.ts | 6 + app/frontend/shared/graphql/types.ts | 79 ++++ .../support/components/initializeStore.ts | 1 + .../gql/mutations/account/device/delete.rb | 26 ++ .../gql/queries/account/device/list.rb | 18 + .../subscriptions/account_devices_updates.rb | 20 + app/graphql/gql/types/user_device_type.rb | 30 ++ app/graphql/graphql_introspection.json | 407 ++++++++++++++++++ app/models/user_device.rb | 2 + .../user_device/triggers_subscriptions.rb | 21 + app/services/service/user/device/delete.rb | 29 ++ i18n/zammad.pot | 24 ++ spec/factories/session.rb | 8 + .../mutations/account/device/delete_spec.rb | 139 ++++++ .../gql/queries/account/device/list_spec.rb | 58 +++ .../account_devices_updates_spec.rb | 50 +++ .../service/user/device/delete_spec.rb | 56 +++ 44 files changed, 1706 insertions(+), 63 deletions(-) create mode 100644 app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-devices-a11y.spec.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-devices.spec.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/fragments/userDeviceAttributes.api.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/fragments/userDeviceAttributes.graphql create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountDeviceDelete.api.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountDeviceDelete.graphql create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountDeviceDelete.mocks.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/queries/accountDeviceList.api.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/queries/accountDeviceList.graphql create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/queries/accountDeviceList.mocks.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/subscriptions/accountDevicesUpdates.api.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/subscriptions/accountDevicesUpdates.graphql create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/subscriptions/accountDevicesUpdates.mocks.ts create mode 100644 app/frontend/apps/desktop/server/apollo/cache/initializer/accountDeviceList.ts.ts create mode 100644 app/graphql/gql/mutations/account/device/delete.rb create mode 100644 app/graphql/gql/queries/account/device/list.rb create mode 100644 app/graphql/gql/subscriptions/account_devices_updates.rb create mode 100644 app/graphql/gql/types/user_device_type.rb create mode 100644 app/models/user_device/triggers_subscriptions.rb create mode 100644 app/services/service/user/device/delete.rb create mode 100644 spec/factories/session.rb create mode 100644 spec/graphql/gql/mutations/account/device/delete_spec.rb create mode 100644 spec/graphql/gql/queries/account/device/list_spec.rb create mode 100644 spec/graphql/gql/subscriptions/account_devices_updates_spec.rb create mode 100644 spec/services/service/user/device/delete_spec.rb diff --git a/app/controllers/user_devices_controller.rb b/app/controllers/user_devices_controller.rb index 0123951a81d4..22eaed96105f 100644 --- a/app/controllers/user_devices_controller.rb +++ b/app/controllers/user_devices_controller.rb @@ -26,21 +26,12 @@ def index end def destroy - - # find device - user_device = UserDevice.find_by(user_id: current_user.id, id: params[:id]) - - # delete device and session's - if user_device - SessionHelper.list.each do |session| - next if !session.data['user_id'] - next if !session.data['user_device_id'] - next if session.data['user_device_id'] != user_device.id - - SessionHelper.destroy(session.id) - end - user_device.destroy + begin + Service::User::Device::Delete.new(user: current_user, device: UserDevice.find_by(user_id: current_user.id, id: params[:id])).execute + rescue Exceptions::UnprocessableEntity + # noop end + render json: {}, status: :ok end diff --git a/app/frontend/apps/desktop/components/CommonActionMenu/CommonActionMenu.vue b/app/frontend/apps/desktop/components/CommonActionMenu/CommonActionMenu.vue index 901fef403573..0c140e96bea7 100644 --- a/app/frontend/apps/desktop/components/CommonActionMenu/CommonActionMenu.vue +++ b/app/frontend/apps/desktop/components/CommonActionMenu/CommonActionMenu.vue @@ -10,10 +10,7 @@ import CommonPopover from '#desktop/components/CommonPopover/CommonPopover.vue' import CommonButton from '#desktop/components/CommonButton/CommonButton.vue' import CommonPopoverMenu from '#desktop/components/CommonPopover/CommonPopoverMenu.vue' import { usePopover } from '#desktop/components/CommonPopover/usePopover.ts' -import type { - ButtonSize, - ButtonVariant, -} from '#desktop/components/CommonButton/types.ts' +import type { ButtonSize } from '#desktop/components/CommonButton/types.ts' import type { MenuItem, Orientation, @@ -25,16 +22,14 @@ interface Props { actions: MenuItem[] entity?: ObjectLike buttonSize?: ButtonSize - buttonVariant?: ButtonVariant placement?: Placement orientation?: Orientation noSingleActionMode?: boolean } const props = withDefaults(defineProps(), { - buttonSize: 'large', - buttonVariant: 'neutral', - placement: 'start', + buttonSize: 'medium', + placement: 'end', orientation: 'autoVertical', }) @@ -54,14 +49,23 @@ const singleActionMode = computed(() => { return singleMenuItemPresent.value }) + +const buttonVariantClass = computed(() => { + if (singleMenuItem.value?.variant === 'secondary') return 'text-blue-800' + if (singleMenuItem.value?.variant === 'danger') return 'text-red-500' + return 'text-stone-200 dark:text-neutral-500' +}) diff --git a/app/frontend/apps/desktop/server/apollo/cache/initializer/accountDeviceList.ts.ts b/app/frontend/apps/desktop/server/apollo/cache/initializer/accountDeviceList.ts.ts new file mode 100644 index 000000000000..4ec96e3b285f --- /dev/null +++ b/app/frontend/apps/desktop/server/apollo/cache/initializer/accountDeviceList.ts.ts @@ -0,0 +1,8 @@ +// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +import type { InMemoryCacheConfig } from '@apollo/client/cache/inmemory/types' +import registerIncomingMerge from '#shared/server/apollo/cache/utils/registerIncomingMerge.ts' + +export default function register(config: InMemoryCacheConfig) { + return registerIncomingMerge(config, 'accountDeviceList') +} diff --git a/app/frontend/apps/desktop/styles/tailwind.desktop.js b/app/frontend/apps/desktop/styles/tailwind.desktop.js index c78be11b35f9..6d3ae6ec69c6 100644 --- a/app/frontend/apps/desktop/styles/tailwind.desktop.js +++ b/app/frontend/apps/desktop/styles/tailwind.desktop.js @@ -93,8 +93,15 @@ module.exports = { }, }, extend: { + width: { + 150: '600px', + }, minWidth: { 58: '232px', + 150: '600px', + }, + maxWidth: { + 150: '600px', }, // NB: daisyUI overrides `neutral` color as a last step. // Here we apply our original theme values by using the same way they were overridden (via full class name). diff --git a/app/frontend/shared/components/ObjectAttributes/__tests__/ObjectAttributes.spec.ts b/app/frontend/shared/components/ObjectAttributes/__tests__/ObjectAttributes.spec.ts index d9cc05989618..a4fd7a824f68 100644 --- a/app/frontend/shared/components/ObjectAttributes/__tests__/ObjectAttributes.spec.ts +++ b/app/frontend/shared/components/ObjectAttributes/__tests__/ObjectAttributes.spec.ts @@ -17,6 +17,12 @@ vi.hoisted(() => { const attributesByKey = keyBy(attributes, 'name') describe('common object attributes interface', () => { + beforeEach(() => { + mockApplicationConfig({ + pretty_date_format: 'absolute', + }) + }) + test('renders all available attributes', () => { mockPermissions(['admin.user', 'ticket.agent']) diff --git a/app/frontend/shared/graphql/types.ts b/app/frontend/shared/graphql/types.ts index d2585819d902..cd126dba972a 100644 --- a/app/frontend/shared/graphql/types.ts +++ b/app/frontend/shared/graphql/types.ts @@ -73,6 +73,22 @@ export type AccountChangePasswordPayload = { success?: Maybe; }; +/** Autogenerated return type of AccountDeviceDelete. */ +export type AccountDeviceDeletePayload = { + __typename?: 'AccountDeviceDeletePayload'; + /** Errors encountered during execution of the mutation. */ + errors?: Maybe>; + /** This indicates if deleting the user (session) device was successful. */ + success?: Maybe; +}; + +/** Autogenerated return type of AccountDevicesUpdates. */ +export type AccountDevicesUpdatesPayload = { + __typename?: 'AccountDevicesUpdatesPayload'; + /** List of devices for the user */ + devices?: Maybe>; +}; + /** Autogenerated return type of AccountLocale. */ export type AccountLocalePayload = { __typename?: 'AccountLocalePayload'; @@ -1171,6 +1187,8 @@ export type Mutations = { accountAvatarSelect?: Maybe; /** Change user password. */ accountChangePassword?: Maybe; + /** Delete a user (session) device. */ + accountDeviceDelete?: Maybe; /** Update the language of the currently logged in user */ accountLocale?: Maybe; /** Update user profile out of office settings */ @@ -1307,6 +1325,12 @@ export type MutationsAccountChangePasswordArgs = { }; +/** All available mutations */ +export type MutationsAccountDeviceDeleteArgs = { + deviceId: Scalars['ID']['input']; +}; + + /** All available mutations */ export type MutationsAccountLocaleArgs = { locale: Scalars['String']['input']; @@ -1981,6 +2005,8 @@ export type Queries = { accountAvatarActive?: Maybe; /** Fetch available avatar list of the currently logged-in user */ accountAvatarList?: Maybe>; + /** Fetch available device list of the currently logged-in user */ + accountDeviceList?: Maybe>; /** Checksum of the currently built front-end application. If this changes, the front-end(s) should reload. */ applicationBuildChecksum: Scalars['String']['output']; /** Configuration required for front end operation (more results returned for authenticated users) */ @@ -2307,6 +2333,8 @@ export type Subscriptions = { __typename?: 'Subscriptions'; /** Updates to account avatar records */ accountAvatarUpdates: AccountAvatarUpdatesPayload; + /** Updates to account devices records */ + accountDevicesUpdates: AccountDevicesUpdatesPayload; /** Application update/change events */ appMaintenance: AppMaintenancePayload; /** Updates to configuration settings */ @@ -2338,6 +2366,12 @@ export type SubscriptionsAccountAvatarUpdatesArgs = { }; +/** All available subscriptions */ +export type SubscriptionsAccountDevicesUpdatesArgs = { + userId: Scalars['ID']['input']; +}; + + /** All available subscriptions */ export type SubscriptionsOnlineNotificationsCountArgs = { userId: Scalars['ID']['input']; @@ -3227,6 +3261,30 @@ export type UserConnection = { totalCount: Scalars['Int']['output']; }; +/** Users (session) device */ +export type UserDevice = { + __typename?: 'UserDevice'; + browser?: Maybe; + /** Create date/time of the record */ + createdAt: Scalars['ISO8601DateTime']['output']; + /** User that created this record */ + createdBy?: Maybe; + deviceDetails?: Maybe; + fingerprint?: Maybe; + id: Scalars['ID']['output']; + ip?: Maybe; + location?: Maybe; + locationDetails?: Maybe; + name: Scalars['String']['output']; + os?: Maybe; + /** Last update date/time of the record */ + updatedAt: Scalars['ISO8601DateTime']['output']; + /** Last user that updated this record */ + updatedBy?: Maybe; + userAgent?: Maybe; + userId: Scalars['ID']['output']; +}; + /** An edge in a connection. */ export type UserEdge = { __typename?: 'UserEdge'; @@ -3555,6 +3613,8 @@ export type SystemSetupInfoQueryVariables = Exact<{ [key: string]: never; }>; export type SystemSetupInfoQuery = { __typename?: 'Queries', systemSetupInfo: { __typename?: 'SystemSetupInfo', status: EnumSystemSetupInfoStatus, type?: EnumSystemSetupInfoType | null } }; +export type UserDeviceAttributesFragment = { __typename?: 'UserDevice', id: string, userId: string, name: string, os?: string | null, browser?: string | null, location?: string | null, deviceDetails?: any | null, locationDetails?: any | null, fingerprint?: string | null, userAgent?: string | null, ip?: string | null, createdAt: string, updatedAt: string }; + export type AccountAppearanceMutationVariables = Exact<{ theme: EnumAppearanceTheme; }>; @@ -3577,6 +3637,13 @@ export type AccountChangePasswordMutationVariables = Exact<{ export type AccountChangePasswordMutation = { __typename?: 'Mutations', accountChangePassword?: { __typename?: 'AccountChangePasswordPayload', success?: boolean | null, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null }> | null } | null }; +export type AccountDeviceDeleteMutationVariables = Exact<{ + deviceId: Scalars['ID']['input']; +}>; + + +export type AccountDeviceDeleteMutation = { __typename?: 'Mutations', accountDeviceDelete?: { __typename?: 'AccountDeviceDeletePayload', success?: boolean | null, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null }> | null } | null }; + export type AccountOutOfOfficeMutationVariables = Exact<{ input: OutOfOfficeInput; }>; @@ -3589,6 +3656,11 @@ export type AccountAvatarListQueryVariables = Exact<{ [key: string]: never; }>; export type AccountAvatarListQuery = { __typename?: 'Queries', accountAvatarList?: Array<{ __typename?: 'Avatar', id: string, default: boolean, deletable: boolean, initial: boolean, imageHash?: string | null, createdAt: string, updatedAt: string }> | null }; +export type AccountDeviceListQueryVariables = Exact<{ [key: string]: never; }>; + + +export type AccountDeviceListQuery = { __typename?: 'Queries', accountDeviceList?: Array<{ __typename?: 'UserDevice', id: string, userId: string, name: string, os?: string | null, browser?: string | null, location?: string | null, deviceDetails?: any | null, locationDetails?: any | null, fingerprint?: string | null, userAgent?: string | null, ip?: string | null, createdAt: string, updatedAt: string }> | null }; + export type AccountAvatarUpdatesSubscriptionVariables = Exact<{ userId: Scalars['ID']['input']; }>; @@ -3596,6 +3668,13 @@ export type AccountAvatarUpdatesSubscriptionVariables = Exact<{ export type AccountAvatarUpdatesSubscription = { __typename?: 'Subscriptions', accountAvatarUpdates: { __typename?: 'AccountAvatarUpdatesPayload', avatars?: Array<{ __typename?: 'Avatar', id: string, default: boolean, deletable: boolean, initial: boolean, imageHash?: string | null, createdAt: string, updatedAt: string }> | null } }; +export type AccountDevicesUpdatesSubscriptionVariables = Exact<{ + userId: Scalars['ID']['input']; +}>; + + +export type AccountDevicesUpdatesSubscription = { __typename?: 'Subscriptions', accountDevicesUpdates: { __typename?: 'AccountDevicesUpdatesPayload', devices?: Array<{ __typename?: 'UserDevice', id: string, userId: string, name: string, os?: string | null, browser?: string | null, location?: string | null, deviceDetails?: any | null, locationDetails?: any | null, fingerprint?: string | null, userAgent?: string | null, ip?: string | null, createdAt: string, updatedAt: string }> | null } }; + export type FormUploadCacheAddMutationVariables = Exact<{ formId: Scalars['FormId']['input']; files: Array | UploadFileInput; diff --git a/app/frontend/tests/support/components/initializeStore.ts b/app/frontend/tests/support/components/initializeStore.ts index d62c6a5a5750..12f3de8b11f6 100644 --- a/app/frontend/tests/support/components/initializeStore.ts +++ b/app/frontend/tests/support/components/initializeStore.ts @@ -24,6 +24,7 @@ export const initializeStore = () => { app.config.ui_ticket_overview_ticket_limit = 5 app.config.product_name = 'Zammad' app.config.api_path = '/api' + app.config.pretty_date_format = 'relative' return pinia } diff --git a/app/graphql/gql/mutations/account/device/delete.rb b/app/graphql/gql/mutations/account/device/delete.rb new file mode 100644 index 000000000000..c56271b88739 --- /dev/null +++ b/app/graphql/gql/mutations/account/device/delete.rb @@ -0,0 +1,26 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +module Gql::Mutations + class Account::Device::Delete < BaseMutation + + description 'Delete a user (session) device.' + + argument :device_id, GraphQL::Types::ID, required: true, loads: Gql::Types::UserDeviceType, description: 'The identifier for the device to be deleted.' + + field :success, Boolean, description: 'This indicates if deleting the user (session) device was successful.' + + def self.authorize(_obj, ctx) + ctx.current_user.permissions?('user_preferences.device') + end + + def authorized?(device:) + context.current_user.id == device.user_id + end + + def resolve(device:) + Service::User::Device::Delete.new(user: context.current_user, device:).execute + + { success: true } + end + end +end diff --git a/app/graphql/gql/queries/account/device/list.rb b/app/graphql/gql/queries/account/device/list.rb new file mode 100644 index 000000000000..7012aba6301a --- /dev/null +++ b/app/graphql/gql/queries/account/device/list.rb @@ -0,0 +1,18 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +module Gql::Queries + class Account::Device::List < BaseQuery + + description 'Fetch available device list of the currently logged-in user' + + type [Gql::Types::UserDeviceType], null: true + + def authorized?(...) + context.current_user.permissions?('user_preferences.device') + end + + def resolve(...) + UserDevice.where(user_id: context.current_user.id).reorder(updated_at: :desc, name: :asc) + end + end +end diff --git a/app/graphql/gql/subscriptions/account_devices_updates.rb b/app/graphql/gql/subscriptions/account_devices_updates.rb new file mode 100644 index 000000000000..0493c3aee28c --- /dev/null +++ b/app/graphql/gql/subscriptions/account_devices_updates.rb @@ -0,0 +1,20 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +module Gql::Subscriptions + class AccountDevicesUpdates < BaseSubscription + + argument :user_id, GraphQL::Types::ID, 'ID of the user to receive devices updates for', loads: Gql::Types::UserType + + description 'Updates to account devices records' + + field :devices, [Gql::Types::UserDeviceType], null: true, description: 'List of devices for the user' + + def authorized?(user:) + context.current_user.permissions?('user_preferences.device') && user.id == context.current_user.id + end + + def update(user:) + { devices: UserDevice.where(user_id: user.id).reorder(updated_at: :desc, name: :asc) } + end + end +end diff --git a/app/graphql/gql/types/user_device_type.rb b/app/graphql/gql/types/user_device_type.rb new file mode 100644 index 000000000000..e5799a340064 --- /dev/null +++ b/app/graphql/gql/types/user_device_type.rb @@ -0,0 +1,30 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +module Gql::Types + class UserDeviceType < Gql::Types::BaseObject + include Gql::Types::Concerns::IsModelObject + + description 'Users (session) device' + + field :user_id, ID, null: false + field :name, String, null: false + field :os, String + field :browser, String + field :location, String + field :device_details, GraphQL::Types::JSON + field :location_details, GraphQL::Types::JSON + field :fingerprint, String + field :user_agent, String + field :ip, String + + def self.authorize(_object, ctx) + ctx.current_user + end + + def location + return object.location if object.location_details['city_name'].blank? + + "#{object.location}, #{object.location_details['city_name']}" + end + end +end diff --git a/app/graphql/graphql_introspection.json b/app/graphql/graphql_introspection.json index 01352b9897bc..22c48d88311c 100644 --- a/app/graphql/graphql_introspection.json +++ b/app/graphql/graphql_introspection.json @@ -303,6 +303,90 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "AccountDeviceDeletePayload", + "description": "Autogenerated return type of AccountDeviceDelete.", + "fields": [ + { + "name": "errors", + "description": "Errors encountered during execution of the mutation.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "UserError", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "success", + "description": "This indicates if deleting the user (session) device was successful.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AccountDevicesUpdatesPayload", + "description": "Autogenerated return type of AccountDevicesUpdates.", + "fields": [ + { + "name": "devices", + "description": "List of devices for the user", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "UserDevice", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "AccountLocalePayload", @@ -7658,6 +7742,33 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "accountDeviceDelete", + "description": "Delete a user (session) device.", + "args": [ + { + "name": "deviceId", + "description": "The identifier for the device to be deleted.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AccountDeviceDeletePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "accountLocale", "description": "Update the language of the currently logged in user", @@ -11844,6 +11955,28 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "accountDeviceList", + "description": "Fetch available device list of the currently logged-in user", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "UserDevice", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "applicationBuildChecksum", "description": "Checksum of the currently built front-end application. If this changes, the front-end(s) should reload.", @@ -13725,6 +13858,37 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "accountDevicesUpdates", + "description": "Updates to account devices records", + "args": [ + { + "name": "userId", + "description": "ID of the user to receive devices updates for", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AccountDevicesUpdatesPayload", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "appMaintenance", "description": "Application update/change events", @@ -19888,6 +20052,249 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "UserDevice", + "description": "Users (session) device", + "fields": [ + { + "name": "browser", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": "Create date/time of the record", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdBy", + "description": "User that created this record", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "User", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deviceDetails", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fingerprint", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ip", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "location", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "locationDetails", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "os", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": "Last update date/time of the record", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedBy", + "description": "Last user that updated this record", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "User", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userAgent", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userId", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "UserEdge", diff --git a/app/models/user_device.rb b/app/models/user_device.rb index 5b4290c2bf3d..35acb8ef9a5c 100644 --- a/app/models/user_device.rb +++ b/app/models/user_device.rb @@ -1,6 +1,8 @@ # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ class UserDevice < ApplicationModel + include UserDevice::TriggersSubscriptions + store :device_details store :location_details validates :name, presence: true diff --git a/app/models/user_device/triggers_subscriptions.rb b/app/models/user_device/triggers_subscriptions.rb new file mode 100644 index 000000000000..81a0da9d0c8e --- /dev/null +++ b/app/models/user_device/triggers_subscriptions.rb @@ -0,0 +1,21 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +# Trigger GraphQL subscriptions on user device changes. +module UserDevice::TriggersSubscriptions + extend ActiveSupport::Concern + + included do + after_commit :trigger_subscriptions + end + + private + + def trigger_subscriptions + Gql::Subscriptions::AccountDevicesUpdates.trigger( + nil, + arguments: { + user_id: Gql::ZammadSchema.id_from_internal_id('User', user_id) + } + ) + end +end diff --git a/app/services/service/user/device/delete.rb b/app/services/service/user/device/delete.rb new file mode 100644 index 000000000000..d5e12d9aa1f7 --- /dev/null +++ b/app/services/service/user/device/delete.rb @@ -0,0 +1,29 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +class Service::User::Device::Delete < Service::Base + attr_reader :user, :device + + def initialize(user:, device:) + super() + + raise Exceptions::UnprocessableEntity, __('UserDevice could not be found.') if device.blank? + + @user = user + @device = device + end + + def execute + Session.all.each do |session| + next if session.data['user_id'] != user.id + next if session.data['user_device_fingerprint'] != device.fingerprint + + begin + session.destroy! + rescue + # noop + end + end + + device.destroy! + end +end diff --git a/i18n/zammad.pot b/i18n/zammad.pot index 602efd44188e..11603edd550f 100644 --- a/i18n/zammad.pot +++ b/i18n/zammad.pot @@ -1140,6 +1140,7 @@ msgid "All colleagues are busy." msgstr "" #: app/assets/javascripts/app/views/profile/devices.jst.eco +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingDevices.vue msgid "All computers and browsers that have access to your Zammad appear here." msgstr "" @@ -4406,6 +4407,10 @@ msgstr "" msgid "Delete this avatar" msgstr "" +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingDevices.vue +msgid "Delete this device" +msgstr "" + #: app/assets/javascripts/app/views/object_manager/index.jst.eco msgid "Delete:" msgstr "" @@ -4502,6 +4507,10 @@ msgstr "" msgid "Device" msgstr "" +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingDevices.vue +msgid "Device has been revoked." +msgstr "" + #: app/assets/javascripts/app/controllers/_profile/devices.coffee #: app/assets/javascripts/app/views/profile/devices.jst.eco #: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/devices.ts @@ -7672,6 +7681,7 @@ msgstr "" #: app/assets/javascripts/app/views/generic/calender_preview.jst.eco #: app/assets/javascripts/app/views/profile/devices.jst.eco #: app/assets/javascripts/app/views/session.jst.eco +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingDevices.vue msgid "Location" msgstr "" @@ -8168,6 +8178,7 @@ msgid "More ticket overviews" msgstr "" #: app/assets/javascripts/app/views/profile/devices.jst.eco +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingDevices.vue msgid "Most recent activity" msgstr "" @@ -8306,6 +8317,7 @@ msgstr "" #: app/assets/javascripts/app/views/widget/organization.jst.eco #: app/assets/javascripts/app/views/widget/user.jst.eco #: app/controllers/time_accountings_controller.rb +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingDevices.vue #: db/seeds/object_manager_attributes.rb #: public/assets/form/form.js msgid "Name" @@ -12637,6 +12649,10 @@ msgstr "" msgid "The default font size is 12px." msgstr "" +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingDevices.vue +msgid "The device could not be deleted." +msgstr "" + #: db/seeds/settings.rb msgid "The divider between TicketHook and ticket number. E. g. ': '." msgstr "" @@ -13474,6 +13490,10 @@ msgstr "" msgid "This class gets added to the button on initialization and will be removed once the chat connection is established." msgstr "" +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingDevices.vue +msgid "This device" +msgstr "" + #: app/assets/javascripts/app/controllers/_ui_element/holiday_selector.coffee msgid "This entry already exists!" msgstr "" @@ -15039,6 +15059,10 @@ msgstr "" msgid "User with specified ID was not found. Try checking the URL for errors." msgstr "" +#: app/services/service/user/device/delete.rb +msgid "UserDevice could not be found." +msgstr "" + #: app/assets/javascripts/app/controllers/_integration/slack.coffee msgid "Username" msgstr "" diff --git a/spec/factories/session.rb b/spec/factories/session.rb new file mode 100644 index 000000000000..0f2961f3402f --- /dev/null +++ b/spec/factories/session.rb @@ -0,0 +1,8 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +FactoryBot.define do + factory :session do + session_id { SecureRandom.urlsafe_base64(64) } + data { { 'persistent' => true } } + end +end diff --git a/spec/graphql/gql/mutations/account/device/delete_spec.rb b/spec/graphql/gql/mutations/account/device/delete_spec.rb new file mode 100644 index 000000000000..1bd282889e78 --- /dev/null +++ b/spec/graphql/gql/mutations/account/device/delete_spec.rb @@ -0,0 +1,139 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +require 'rails_helper' + +RSpec.describe Gql::Mutations::Account::Device::Delete, :aggregate_failures, type: :graphql do + context 'when destroying user (session) device' do + let(:mutation) do + <<~MUTATION + mutation accountDeviceDelete($deviceId: ID!) { + accountDeviceDelete(deviceId: $deviceId) { + success + errors { + message + } + } + } + MUTATION + end + + let(:variables) { { deviceId: Gql::ZammadSchema.id_from_internal_id(UserDevice, device.id) } } + + def execute_graphql_query + gql.execute(mutation, variables: variables) + end + + context 'with authenticated user having one device and one related session', authenticated_as: :agent do + let(:agent) { create(:agent) } + let(:device) { create(:user_device, user_id: agent.id) } + + it 'destroys the device and the related session' do + create(:session, + data: { + 'user_id' => agent.id, + 'user_device_fingerprint' => device.fingerprint, + 'persistent' => true + }) + + expect { execute_graphql_query }.to change(UserDevice, :count).by(-1).and change(Session, :count).by(-1) + end + end + + context 'with authenticated user having one device and multiple related session', authenticated_as: :agent do + let(:agent) { create(:agent) } + let(:device) { create(:user_device, user_id: agent.id) } + + it 'destroys the device and all the related session' do + sessions = Faker::Number.within(range: 2..42) # rubocop:disable Zammad/FakerUnique + create_list(:session, sessions, + data: { + 'user_id' => agent.id, + 'user_device_fingerprint' => device.fingerprint, + 'persistent' => true + }) + + expect { execute_graphql_query }.to change(UserDevice, :count).by(-1).and change(Session, :count).by(-1 * sessions) + end + end + + context 'with authenticated user having multiple devices and multiple related session', authenticated_as: :agent do + let(:agent) { create(:agent) } + let(:device) { create(:user_device, user_id: agent.id) } + + let(:agents) { create_list(:agent, Faker::Number.within(range: 2..42)) } # rubocop:disable Zammad/FakerUnique + let(:devices) do + agents.map do |agent| + create(:user_device, user_id: agent.id) + end + end + + it 'destroys only the selected device and all the related session' do + sessions = Faker::Number.within(range: 2..42) # rubocop:disable Zammad/FakerUnique + create_list(:session, sessions, + data: { + 'user_id' => agent.id, + 'user_device_fingerprint' => device.fingerprint, + 'persistent' => true + }) + + devices.each do |device| + create_list(:session, Faker::Number.within(range: 2..42), # rubocop:disable Zammad/FakerUnique + data: { + 'user_id' => device.user_id, + 'user_device_fingerprint' => device.fingerprint, + 'persistent' => true + }) + end + + expect { execute_graphql_query }.to change(UserDevice, :count).by(-1).and change(Session, :count).by(-1 * sessions) + end + end + + context 'with multiple authenticated users having identical device (fingerprint) and multiple related session', authenticated_as: :agent do + let(:agent) { create(:agent) } + let(:device) { create(:user_device, user_id: agent.id) } + + let(:agents) { create_list(:agent, Faker::Number.within(range: 2..42)) } # rubocop:disable Zammad/FakerUnique + let(:devices) do + agents.map do |agent| + create(:user_device, user_id: agent.id, fingerprint: device.fingerprint) + end + end + + it 'destroys only the selected device and all the related session' do + sessions = Faker::Number.within(range: 2..42) # rubocop:disable Zammad/FakerUnique + create_list(:session, sessions, + data: { + 'user_id' => agent.id, + 'user_device_fingerprint' => device.fingerprint, + 'persistent' => true + }) + + devices.each do |device| + create_list(:session, Faker::Number.within(range: 2..42), # rubocop:disable Zammad/FakerUnique + data: { + 'user_id' => device.user_id, + 'user_device_fingerprint' => device.fingerprint, + 'persistent' => true + }) + end + + expect { execute_graphql_query }.to change(UserDevice, :count).by(-1).and change(Session, :count).by(-1 * sessions) + end + end + + context 'when device is not owned from current user', authenticated_as: :agent do + let(:agent) { create(:agent) } + let(:agent_other) { create(:agent) } + let(:device) { create(:user_device, user_id: agent_other.id) } + + before do + execute_graphql_query + end + + it 'returns an error' do + expect(gql.result.error_type).to eq(Exceptions::Forbidden) + end + end + end +end diff --git a/spec/graphql/gql/queries/account/device/list_spec.rb b/spec/graphql/gql/queries/account/device/list_spec.rb new file mode 100644 index 000000000000..e475c5a8f3eb --- /dev/null +++ b/spec/graphql/gql/queries/account/device/list_spec.rb @@ -0,0 +1,58 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +require 'rails_helper' + +RSpec.describe Gql::Queries::Account::Device::List, type: :graphql do + context 'when listing user (session) devices' do + let(:agent) { create(:agent) } + let(:query) do + <<~QUERY + query accountDeviceList { + accountDeviceList { + id + userId + name + os + browser + location + deviceDetails + locationDetails + fingerprint + userAgent + ip + createdAt + updatedAt + } + } + QUERY + end + + before do + create(:user_device, user_id: agent.id) + create(:user_device, user_id: agent.id, location_details: { city_name: 'Berlin' }) + gql.execute(query, variables: { fingerprint: 'dummy' }) + end + + context 'when user is not authenticated' do + it 'returns an error' do + expect(gql.result.error_message).to eq('Authentication required') + end + end + + context 'when user is authenticated, but has no permission', authenticated_as: :agent do + let(:agent) { create(:agent, roles: []) } + + it 'returns an error' do + expect(gql.result.error_type).to eq(Exceptions::Forbidden) + end + end + + context 'when user is authenticated', :aggregate_failures, authenticated_as: :agent do + it 'returns a list of devices' do + expect(gql.result.data.length).to eq(2) + # This works because the devices list is ordered by updated_at. + expect(gql.result.data.first['location']).to include(', Berlin') + end + end + end +end diff --git a/spec/graphql/gql/subscriptions/account_devices_updates_spec.rb b/spec/graphql/gql/subscriptions/account_devices_updates_spec.rb new file mode 100644 index 000000000000..f8588c0422bc --- /dev/null +++ b/spec/graphql/gql/subscriptions/account_devices_updates_spec.rb @@ -0,0 +1,50 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +require 'rails_helper' + +RSpec.describe Gql::Subscriptions::AccountDevicesUpdates, type: :graphql do + let(:subscription) do + <<~QUERY + subscription accountDevicesUpdates($userId: ID!) { + accountDevicesUpdates(userId: $userId) { + devices { + name + } + } + } + QUERY + end + + let(:mock_channel) { build_mock_channel } + let(:target) { create(:agent) } + let(:variables) { { userId: gql.id(target) } } + + context 'with authenticated user', authenticated_as: :target do + it 'subscribes' do + gql.execute(subscription, variables: variables, context: { channel: mock_channel }) + expect(gql.result.data).to eq({ 'devices' => nil }) + end + + it 'receives user device updates for target user' do + gql.execute(subscription, variables: variables, context: { channel: mock_channel }) + create(:user_device, user_id: target.id) + expect(mock_channel.mock_broadcasted_messages.first[:result]['data']['accountDevicesUpdates']['devices'].count).to eq(1) + end + + it 'does not receive user device updates for other users' do + gql.execute(subscription, variables: variables, context: { channel: mock_channel }) + create(:user_device, user_id: create(:agent).id) + expect(mock_channel.mock_broadcasted_messages).to be_empty + end + end + + context 'when subscribing for unauthenticated users' do + let(:agent_without_device) { create(:agent, roles: []) } + let(:variables) { { userId: gql.id(create(:agent)) } } + + it 'does not subscribe but returns an authorization error' do + gql.execute(subscription, variables: variables, context: { channel: mock_channel }) + expect(gql.result.error_type).to eq(Exceptions::NotAuthorized) + end + end +end diff --git a/spec/services/service/user/device/delete_spec.rb b/spec/services/service/user/device/delete_spec.rb new file mode 100644 index 000000000000..51de367fe288 --- /dev/null +++ b/spec/services/service/user/device/delete_spec.rb @@ -0,0 +1,56 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +require 'rails_helper' + +RSpec.describe Service::User::Device::Delete do + subject(:service) { described_class.new(user: agent, device: device) } + + context 'with given user having one device and one related session' do + let(:agent) { create(:agent) } + let(:device) { create(:user_device, user_id: agent.id) } + + it 'destroys the device and the related session' do + create(:session, + data: { + 'user_id' => agent.id, + 'user_device_fingerprint' => device.fingerprint, + 'persistent' => true + }) + + expect { service.execute }.to change(UserDevice, :count).by(-1).and change(Session, :count).by(-1) + end + end + + context 'with given user having multiple devices and multiple related session' do + let(:agent) { create(:agent) } + let(:device) { create(:user_device, user_id: agent.id) } + + let(:agents) { create_list(:agent, Faker::Number.within(range: 2..42)) } # rubocop:disable Zammad/FakerUnique + let(:devices) do + agents.map do |agent| + create(:user_device, user_id: agent.id) + end + end + + it 'destroys only the selected device and all the related session' do + sessions = Faker::Number.within(range: 2..42) # rubocop:disable Zammad/FakerUnique + create_list(:session, sessions, + data: { + 'user_id' => agent.id, + 'user_device_fingerprint' => device.fingerprint, + 'persistent' => true + }) + + devices.each do |device| + create_list(:session, Faker::Number.within(range: 2..42), # rubocop:disable Zammad/FakerUnique + data: { + 'user_id' => device.user_id, + 'user_device_fingerprint' => device.fingerprint, + 'persistent' => true + }) + end + + expect { service.execute }.to change(UserDevice, :count).by(-1).and change(Session, :count).by(-1 * sessions) + end + end +end