diff --git a/api/datastore/migrator/migrate_dbversion81.go b/api/datastore/migrator/migrate_dbversion90.go similarity index 50% rename from api/datastore/migrator/migrate_dbversion81.go rename to api/datastore/migrator/migrate_dbversion90.go index 1e28096e1..4d890a40a 100644 --- a/api/datastore/migrator/migrate_dbversion81.go +++ b/api/datastore/migrator/migrate_dbversion90.go @@ -6,11 +6,15 @@ import ( portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors" ) -func (m *Migrator) migrateDBVersionToDB81() error { - return m.updateEdgeStackStatusForDB81() +func (m *Migrator) migrateDBVersionToDB90() error { + if err := m.updateUserThemForDB90(); err != nil { + return err + } + + return m.updateEdgeStackStatusForDB90() } -func (m *Migrator) updateEdgeStackStatusForDB81() error { +func (m *Migrator) updateEdgeStackStatusForDB90() error { log.Info().Msg("clean up deleted endpoints from edge jobs") edgeJobs, err := m.edgeJobService.EdgeJobs() @@ -34,3 +38,25 @@ func (m *Migrator) updateEdgeStackStatusForDB81() error { return nil } + +func (m *Migrator) updateUserThemForDB90() error { + log.Info().Msg("updating existing user theme settings") + + users, err := m.userService.Users() + if err != nil { + return err + } + + for i := range users { + user := &users[i] + if user.UserTheme != "" { + user.ThemeSettings.Color = user.UserTheme + } + + if err := m.userService.UpdateUser(user.ID, user); err != nil { + return err + } + } + + return nil +} diff --git a/api/datastore/migrator/migrator.go b/api/datastore/migrator/migrator.go index cd2a07749..75645c42c 100644 --- a/api/datastore/migrator/migrator.go +++ b/api/datastore/migrator/migrator.go @@ -209,7 +209,7 @@ func (m *Migrator) initMigrations() { m.addMigrations("2.16", m.migrateDBVersionToDB70) m.addMigrations("2.16.1", m.migrateDBVersionToDB71) m.addMigrations("2.17", m.migrateDBVersionToDB80) - m.addMigrations("2.18", m.migrateDBVersionToDB81) + m.addMigrations("2.18", m.migrateDBVersionToDB90) // Add new migrations below... // One function per migration, each versions migration funcs in the same file. diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index 8294c13f9..a4d4b2ac8 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -902,6 +902,10 @@ "PortainerUserRevokeToken": true }, "Role": 1, + "ThemeSettings": { + "color": "", + "subtleUpgradeButton": false + }, "TokenIssueAt": 0, "UserTheme": "", "Username": "admin" @@ -929,6 +933,10 @@ "PortainerUserRevokeToken": true }, "Role": 1, + "ThemeSettings": { + "color": "", + "subtleUpgradeButton": false + }, "TokenIssueAt": 0, "UserTheme": "", "Username": "prabhat" diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go index 868610fad..43d8b4dab 100644 --- a/api/http/handler/users/user_update.go +++ b/api/http/handler/users/user_update.go @@ -15,10 +15,18 @@ import ( "github.com/asaskevich/govalidator" ) +type themePayload struct { + // Color represents the color theme of the UI + Color *string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"` + // SubtleUpgradeButton indicates if the upgrade banner should be displayed in a subtle way + SubtleUpgradeButton *bool `json:"subtleUpgradeButton" example:"false"` +} + type userUpdatePayload struct { - Username string `validate:"required" example:"bob"` - Password string `validate:"required" example:"cg9Wgky3"` - UserTheme string `example:"dark"` + Username string `validate:"required" example:"bob"` + Password string `validate:"required" example:"cg9Wgky3"` + Theme *themePayload + // User role (1 for administrator account and 2 for regular account) Role int `validate:"required" enums:"1,2" example:"2"` } @@ -108,8 +116,14 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http user.TokenIssueAt = time.Now().Unix() } - if payload.UserTheme != "" { - user.UserTheme = payload.UserTheme + if payload.Theme != nil { + if payload.Theme.Color != nil { + user.ThemeSettings.Color = *payload.Theme.Color + } + + if payload.Theme.SubtleUpgradeButton != nil { + user.ThemeSettings.SubtleUpgradeButton = *payload.Theme.SubtleUpgradeButton + } } if payload.Role != 0 { diff --git a/api/portainer.go b/api/portainer.go index cad0cb7ce..cd4e2f33e 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1235,16 +1235,19 @@ type ( ID UserID `json:"Id" example:"1"` Username string `json:"Username" example:"bob"` Password string `json:"Password,omitempty" swaggerignore:"true"` - // User Theme - UserTheme string `example:"dark"` // User role (1 for administrator account and 2 for regular account) - Role UserRole `json:"Role" example:"1"` - TokenIssueAt int64 `json:"TokenIssueAt" example:"1"` + Role UserRole `json:"Role" example:"1"` + TokenIssueAt int64 `json:"TokenIssueAt" example:"1"` + ThemeSettings UserThemeSettings // Deprecated fields + + // Deprecated + UserTheme string `example:"dark"` // Deprecated in DBVersion == 25 - PortainerAuthorizations Authorizations `json:"PortainerAuthorizations"` - EndpointAuthorizations EndpointAuthorizations `json:"EndpointAuthorizations"` + PortainerAuthorizations Authorizations + // Deprecated in DBVersion == 25 + EndpointAuthorizations EndpointAuthorizations } // UserAccessPolicies represent the association of an access policy and a user @@ -1263,6 +1266,14 @@ type ( // or a regular user UserRole int + // UserThemeSettings represents the theme settings for a user + UserThemeSettings struct { + // Color represents the color theme of the UI + Color string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"` + // SubtleUpgradeButton indicates if the upgrade banner should be displayed in a subtle way + SubtleUpgradeButton bool `json:"subtleUpgradeButton"` + } + // Webhook represents a url webhook that can be used to update a service Webhook struct { // Webhook Identifier diff --git a/app/portainer/components/theme/theme-settings.controller.js b/app/portainer/components/theme/theme-settings.controller.js index ff00d1a95..97f4ad6de 100644 --- a/app/portainer/components/theme/theme-settings.controller.js +++ b/app/portainer/components/theme/theme-settings.controller.js @@ -1,34 +1,51 @@ +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { queryKeys } from '@/portainer/users/queries/queryKeys'; +import { queryClient } from '@/react-tools/react-query'; import { options } from './options'; export default class ThemeSettingsController { /* @ngInject */ - constructor($async, Authentication, ThemeManager, StateManager, UserService, Notifications) { + constructor($async, Authentication, ThemeManager, StateManager, UserService) { this.$async = $async; this.Authentication = Authentication; this.ThemeManager = ThemeManager; this.StateManager = StateManager; this.UserService = UserService; - this.Notifications = Notifications; - this.setTheme = this.setTheme.bind(this); + this.setThemeColor = this.setThemeColor.bind(this); + this.setSubtleUpgradeButton = this.setSubtleUpgradeButton.bind(this); } - async setTheme(theme) { - try { - if (theme === 'auto' || !theme) { + async setThemeColor(color) { + return this.$async(async () => { + if (color === 'auto' || !color) { this.ThemeManager.autoTheme(); } else { - this.ThemeManager.setTheme(theme); + this.ThemeManager.setTheme(color); } - this.state.userTheme = theme; + this.state.themeColor = color; + this.updateThemeSettings({ color }); + }); + } + + async setSubtleUpgradeButton(value) { + return this.$async(async () => { + this.state.subtleUpgradeButton = value; + this.updateThemeSettings({ subtleUpgradeButton: value }); + }); + } + + async updateThemeSettings(theme) { + try { if (!this.state.isDemo) { - await this.UserService.updateUserTheme(this.state.userId, this.state.userTheme); + await this.UserService.updateUserTheme(this.state.userId, theme); + await queryClient.invalidateQueries(queryKeys.user(this.state.userId)); } - this.Notifications.success('Success', 'User theme successfully updated'); + notifySuccess('Success', 'User theme settings successfully updated'); } catch (err) { - this.Notifications.error('Failure', err, 'Unable to update user theme'); + notifyError('Failure', err, 'Unable to update user theme settings'); } } @@ -38,19 +55,21 @@ export default class ThemeSettingsController { this.state = { userId: null, - userTheme: '', - defaultTheme: 'auto', + themeColor: 'auto', isDemo: state.application.demoEnvironment.enabled, + subtleUpgradeButton: false, }; this.state.availableThemes = options; try { this.state.userId = await this.Authentication.getUserDetails().ID; - const data = await this.UserService.user(this.state.userId); - this.state.userTheme = data.UserTheme || this.state.defaultTheme; + const user = await this.UserService.user(this.state.userId); + + this.state.themeColor = user.ThemeSettings.color || this.state.themeColor; + this.state.subtleUpgradeButton = !!user.ThemeSettings.subtleUpgradeButton; } catch (err) { - this.Notifications.error('Failure', err, 'Unable to get user details'); + notifyError('Failure', err, 'Unable to get user details'); } }); } diff --git a/app/portainer/components/theme/theme-settings.html b/app/portainer/components/theme/theme-settings.html index ae9bde6ef..e3fa245e8 100644 --- a/app/portainer/components/theme/theme-settings.html +++ b/app/portainer/components/theme/theme-settings.html @@ -3,12 +3,23 @@
- + + +

+ + Dark and High-contrast theme are experimental. Some UI components might not display properly. +

+ +
+ +
-

- - Dark and High-contrast theme are experimental. Some UI components might not display properly. -

diff --git a/app/portainer/models/user.js b/app/portainer/models/user.js index 8f16e08c1..4c5fa6dc4 100644 --- a/app/portainer/models/user.js +++ b/app/portainer/models/user.js @@ -2,7 +2,7 @@ export function UserViewModel(data) { this.Id = data.Id; this.Username = data.Username; this.Role = data.Role; - this.UserTheme = data.UserTheme; + this.ThemeSettings = data.ThemeSettings; if (data.Role === 1) { this.RoleName = 'administrator'; } else { diff --git a/app/portainer/services/api/userService.js b/app/portainer/services/api/userService.js index 705c3f065..efa798760 100644 --- a/app/portainer/services/api/userService.js +++ b/app/portainer/services/api/userService.js @@ -67,8 +67,8 @@ export function UserService($q, Users, TeamService, TeamMembershipService) { return Users.updatePassword({ id: id }, payload).$promise; }; - service.updateUserTheme = function (id, userTheme) { - return Users.updateTheme({ id }, { userTheme }).$promise; + service.updateUserTheme = function (id, theme) { + return Users.updateTheme({ id }, { theme }).$promise; }; service.userMemberships = function (id) { diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index a3c8bed4f..1ffc9b1a1 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -102,7 +102,7 @@ angular.module('portainer.app').factory('Authentication', [ const data = await UserService.user(user.ID); // Initialize user theme base on UserTheme from database - const userTheme = data.UserTheme; + const userTheme = data.ThemeSettings ? data.ThemeSettings.color : 'auto'; if (userTheme === 'auto' || !userTheme) { ThemeManager.autoTheme(); } else { diff --git a/app/portainer/services/themeManager.js b/app/portainer/services/themeManager.js index b5b634645..a950977d2 100644 --- a/app/portainer/services/themeManager.js +++ b/app/portainer/services/themeManager.js @@ -1,7 +1,6 @@ angular.module('portainer.app').service('ThemeManager', ThemeManager); /* @ngInject */ - export function ThemeManager(StateManager) { return { setTheme, diff --git a/app/portainer/users/queries/queryKeys.ts b/app/portainer/users/queries/queryKeys.ts new file mode 100644 index 000000000..c6f335d98 --- /dev/null +++ b/app/portainer/users/queries/queryKeys.ts @@ -0,0 +1,6 @@ +import { UserId } from '../types'; + +export const queryKeys = { + base: () => ['users'] as const, + user: (id: UserId) => [...queryKeys.base(), id] as const, +}; diff --git a/app/portainer/users/queries/useUser.ts b/app/portainer/users/queries/useUser.ts index ec1915f5b..8986270a6 100644 --- a/app/portainer/users/queries/useUser.ts +++ b/app/portainer/users/queries/useUser.ts @@ -6,11 +6,13 @@ import { withError } from '@/react-tools/react-query'; import { buildUrl } from '../user.service'; import { User, UserId } from '../types'; +import { queryKeys } from './queryKeys'; + export function useUser( id: UserId, { staleTime }: { staleTime?: number } = {} ) { - return useQuery(['users', id], () => getUser(id), { + return useQuery(queryKeys.user(id), () => getUser(id), { ...withError('Unable to retrieve user details'), staleTime, }); diff --git a/app/portainer/users/types.ts b/app/portainer/users/types.ts index a02cc031e..5b5a9ecf4 100644 --- a/app/portainer/users/types.ts +++ b/app/portainer/users/types.ts @@ -20,15 +20,8 @@ export type User = { EndpointAuthorizations: { [endpointId: EnvironmentId]: AuthorizationMap; }; - // UserTheme: string; - // this.EndpointAuthorizations = data.EndpointAuthorizations; - // this.PortainerAuthorizations = data.PortainerAuthorizations; - // if (data.Role === 1) { - // this.RoleName = 'administrator'; - // } else { - // this.RoleName = 'user'; - // } - // this.AuthenticationMethod = data.AuthenticationMethod; - // this.Checked = false; - // this.EndpointAuthorizations = data.EndpointAuthorizations; + ThemeSettings: { + color: 'dark' | 'light' | 'highcontrast' | 'auto'; + subtleUpgradeButton: boolean; + }; }; diff --git a/app/portainer/views/account/accountController.js b/app/portainer/views/account/accountController.js index a53b41f05..b1c9c1885 100644 --- a/app/portainer/views/account/accountController.js +++ b/app/portainer/views/account/accountController.js @@ -10,13 +10,11 @@ angular.module('portainer.app').controller('AccountController', [ 'Notifications', 'SettingsService', 'StateManager', - 'ThemeManager', - function ($scope, $state, Authentication, UserService, Notifications, SettingsService, StateManager, ThemeManager) { + function ($scope, $state, Authentication, UserService, Notifications, SettingsService, StateManager) { $scope.formValues = { currentPassword: '', newPassword: '', confirmPassword: '', - userTheme: '', }; $scope.updatePassword = async function () { @@ -98,24 +96,6 @@ angular.module('portainer.app').controller('AccountController', [ }); }; - // Update DOM for theme attribute & LocalStorage - $scope.setTheme = function (theme) { - ThemeManager.setTheme(theme); - StateManager.updateTheme(theme); - }; - - // Rest API Call to update theme with userID in DB - $scope.updateTheme = function () { - UserService.updateUserTheme($scope.userID, $scope.formValues.userTheme) - .then(function success() { - Notifications.success('Success', 'User theme successfully updated'); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, err.msg); - }); - }; - async function initView() { const state = StateManager.getState(); const userDetails = Authentication.getUserDetails(); @@ -128,10 +108,6 @@ angular.module('portainer.app').controller('AccountController', [ $scope.isDemoUser = state.application.demoEnvironment.users.includes($scope.userID); } - const data = await UserService.user($scope.userID); - - $scope.formValues.userTheme = data.UserTheme; - SettingsService.publicSettings() .then(function success(data) { $scope.AuthenticationMethod = data.AuthenticationMethod; diff --git a/app/react-tools/test-mocks.ts b/app/react-tools/test-mocks.ts index 07b5a0923..0f1d478ab 100644 --- a/app/react-tools/test-mocks.ts +++ b/app/react-tools/test-mocks.ts @@ -12,12 +12,15 @@ export function createMockUsers( Id: value, Username: `user${value}`, Role: getRoles(roles, value), - UserTheme: '', RoleName: '', AuthenticationMethod: '', Checked: false, EndpointAuthorizations: {}, PortainerAuthorizations: {}, + ThemeSettings: { + color: 'auto', + subtleUpgradeButton: false, + }, })); } diff --git a/app/react/components/UsersSelector/UsersSelector.mocks.ts b/app/react/components/UsersSelector/UsersSelector.mocks.ts index 793f8c1f4..2ebc35b7f 100644 --- a/app/react/components/UsersSelector/UsersSelector.mocks.ts +++ b/app/react/components/UsersSelector/UsersSelector.mocks.ts @@ -5,7 +5,6 @@ export function createMockUser(id: number, username: string): UserViewModel { Id: id, Username: username, Role: 2, - UserTheme: '', EndpointAuthorizations: {}, PortainerAuthorizations: { PortainerDockerHubInspect: true, @@ -25,5 +24,9 @@ export function createMockUser(id: number, username: string): UserViewModel { RoleName: 'user', Checked: false, AuthenticationMethod: '', + ThemeSettings: { + color: 'auto', + subtleUpgradeButton: false, + }, }; } diff --git a/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.mocks.ts b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.mocks.ts index 33f7366a7..e2e196cea 100644 --- a/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.mocks.ts +++ b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.mocks.ts @@ -20,7 +20,10 @@ export function mockExampleData() { Id: 10, Username: 'user1', Role: 2, - UserTheme: '', + ThemeSettings: { + color: 'auto', + subtleUpgradeButton: false, + }, EndpointAuthorizations: {}, PortainerAuthorizations: { PortainerDockerHubInspect: true, @@ -45,7 +48,10 @@ export function mockExampleData() { Id: 13, Username: 'user2', Role: 2, - UserTheme: '', + ThemeSettings: { + color: 'auto', + subtleUpgradeButton: false, + }, EndpointAuthorizations: {}, PortainerAuthorizations: { PortainerDockerHubInspect: true, diff --git a/app/react/sidebar/UpgradeBEBanner/GetLicenseDialog.tsx b/app/react/sidebar/UpgradeBEBanner/GetLicenseDialog.tsx index f88da9f19..4ae25e95a 100644 --- a/app/react/sidebar/UpgradeBEBanner/GetLicenseDialog.tsx +++ b/app/react/sidebar/UpgradeBEBanner/GetLicenseDialog.tsx @@ -1,3 +1,5 @@ +import { useAnalytics } from '@/angulartics.matomo/analytics-services'; + import { HubspotForm } from '@@/HubspotForm'; import { Modal } from '@@/modals/Modal'; @@ -11,6 +13,7 @@ export function GetLicenseDialog({ // form is loaded from hubspot, so it won't have the same styling as the rest of the app // since it won't support darkmode, we enforce a white background and black text for the components we use // (Modal, CloseButton, loading text) + const { trackEvent } = useAnalytics(); return ( goToUploadLicense(true)} + onSubmitted={() => { + trackEvent('portainer-upgrade-license-key-requested', { + category: 'portainer', + metadata: { 'Upgrade-key-requested': true }, + }); + + goToUploadLicense(true); + }} loading={
Loading...
} /> diff --git a/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx b/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx index 54e80905f..f39c08e32 100644 --- a/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx +++ b/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx @@ -1,5 +1,6 @@ -import { ArrowRight } from 'lucide-react'; +import { ArrowUpCircle } from 'lucide-react'; import { useState } from 'react'; +import clsx from 'clsx'; import { useAnalytics } from '@/angulartics.matomo/analytics-services'; import { useNodesCount } from '@/react/portainer/system/useNodesCount'; @@ -7,9 +8,10 @@ import { ContainerPlatform, useSystemInfo, } from '@/react/portainer/system/useSystemInfo'; -import { useUser } from '@/react/hooks/useUser'; +import { useCurrentUser } from '@/react/hooks/useUser'; import { withEdition } from '@/react/portainer/feature-flags/withEdition'; import { withHideOnExtension } from '@/react/hooks/withHideOnExtension'; +import { useUser } from '@/portainer/users/queries/useUser'; import { useSidebarState } from '../useSidebarState'; @@ -25,15 +27,21 @@ const enabledPlatforms: Array = [ ]; function UpgradeBEBanner() { - const { isAdmin } = useUser(); + const { + isAdmin, + user: { Id }, + } = useCurrentUser(); + const { trackEvent } = useAnalytics(); const { isOpen: isSidebarOpen } = useSidebarState(); + const nodesCountQuery = useNodesCount(); const systemInfoQuery = useSystemInfo(); + const userQuery = useUser(Id); const [isOpen, setIsOpen] = useState(false); - if (!nodesCountQuery.isSuccess || !systemInfoQuery.data) { + if (!nodesCountQuery.isSuccess || !systemInfoQuery.data || !userQuery.data) { return null; } @@ -49,19 +57,42 @@ function UpgradeBEBanner() { agents: systemInfo.agents, }; - if (!enabledPlatforms.includes(systemInfo.platform)) { + if ( + !enabledPlatforms.includes(systemInfo.platform) && + process.env.NODE_ENV !== 'development' + ) { return null; } + const subtleButton = userQuery.data.ThemeSettings.subtleUpgradeButton; + return ( <> {isOpen && setIsOpen(false)} />} diff --git a/app/react/sidebar/UpgradeBEBanner/UploadLicenseDialog.tsx b/app/react/sidebar/UpgradeBEBanner/UploadLicenseDialog.tsx index df48bf3c6..942943e47 100644 --- a/app/react/sidebar/UpgradeBEBanner/UploadLicenseDialog.tsx +++ b/app/react/sidebar/UpgradeBEBanner/UploadLicenseDialog.tsx @@ -3,6 +3,7 @@ import { object, SchemaOf, string } from 'yup'; import { useUpgradeEditionMutation } from '@/react/portainer/system/useUpgradeEditionMutation'; import { notifySuccess } from '@/portainer/services/notifications'; +import { useAnalytics } from '@/angulartics.matomo/analytics-services'; import { Button, LoadingButton } from '@@/buttons'; import { FormControl } from '@@/form-components/FormControl'; @@ -30,6 +31,7 @@ export function UploadLicenseDialog({ isGetLicenseSubmitted: boolean; }) { const upgradeMutation = useUpgradeEditionMutation(); + const { trackEvent } = useAnalytics(); return (