diff --git a/modules/api/cmd/kubermatic-api/swagger.json b/modules/api/cmd/kubermatic-api/swagger.json index b896569d7b..e45db7a044 100644 --- a/modules/api/cmd/kubermatic-api/swagger.json +++ b/modules/api/cmd/kubermatic-api/swagger.json @@ -1453,6 +1453,38 @@ } } }, + "/api/v1/me/readannouncements": { + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Updates read announcements of the current user.", + "operationId": "patchCurrentUserReadAnnouncements", + "responses": { + "200": { + "description": "User", + "schema": { + "$ref": "#/definitions/User" + } + }, + "401": { + "$ref": "#/responses/empty" + }, + "default": { + "description": "errorResponse", + "schema": { + "$ref": "#/definitions/errorResponse" + } + } + } + } + }, "/api/v1/me/settings": { "get": { "produces": [ @@ -1504,9 +1536,9 @@ ], "responses": { "200": { - "description": "UserSettings", + "description": "User", "schema": { - "$ref": "#/definitions/UserSettings" + "$ref": "#/definitions/User" } }, "401": { @@ -39564,6 +39596,14 @@ }, "x-go-name": "Projects" }, + "readAnnouncements": { + "description": "ReadAnnouncements holds the IDs of admin announcements that the user has read.\n+optional", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "ReadAnnouncements" + }, "userSettings": { "$ref": "#/definitions/UserSettings" } diff --git a/modules/api/go.mod b/modules/api/go.mod index 91761cd611..6cf549855b 100644 --- a/modules/api/go.mod +++ b/modules/api/go.mod @@ -98,7 +98,10 @@ replace k8s.io/kube-openapi => k8s.io/kube-openapi v0.0.0-20240903163716-9e1beec replace k8s.io/client-go => k8s.io/client-go v0.31.1 -require github.com/kubeovn/kube-ovn v1.13.0 +require ( + github.com/google/uuid v1.6.0 + github.com/kubeovn/kube-ovn v1.13.0 +) require ( cel.dev/expr v0.19.0 // indirect @@ -172,7 +175,6 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.1-0.20210504230335-f78f29fc09ea // indirect github.com/google/s2a-go v0.1.8 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect diff --git a/modules/api/pkg/handler/routes_v1.go b/modules/api/pkg/handler/routes_v1.go index 4cc6d2dfd4..7072e5cb38 100644 --- a/modules/api/pkg/handler/routes_v1.go +++ b/modules/api/pkg/handler/routes_v1.go @@ -1634,7 +1634,7 @@ func (r Routing) getCurrentUserSettings() http.Handler { // // Responses: // default: errorResponse -// 200: UserSettings +// 200: User // 401: empty func (r Routing) patchCurrentUserSettings() http.Handler { return httptransport.NewServer( @@ -1648,7 +1648,7 @@ func (r Routing) patchCurrentUserSettings() http.Handler { ) } -// swagger:route PATCH /api/v1/me/readannouncements readannouncements patchCurrentUserReadAnnouncements +// swagger:route PATCH /api/v1/me/readannouncements users patchCurrentUserReadAnnouncements // // Updates read announcements of the current user. // @@ -1660,7 +1660,7 @@ func (r Routing) patchCurrentUserSettings() http.Handler { // // Responses: // default: errorResponse -// 200: []string +// 200: User // 401: empty func (r Routing) patchCurrentUserReadAnnouncements() http.Handler { return httptransport.NewServer( diff --git a/modules/api/pkg/handler/v1/admin/settings.go b/modules/api/pkg/handler/v1/admin/settings.go index 2aad8dac22..2a8594c0bb 100644 --- a/modules/api/pkg/handler/v1/admin/settings.go +++ b/modules/api/pkg/handler/v1/admin/settings.go @@ -22,6 +22,7 @@ import ( "io" "net/http" "slices" + "time" jsonpatch "github.com/evanphx/json-patch" "github.com/go-kit/kit/endpoint" @@ -35,6 +36,7 @@ import ( utilerrors "k8c.io/kubermatic/v2/pkg/util/errors" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" ) @@ -93,6 +95,7 @@ func UpdateKubermaticSettingsEndpoint(userInfoGetter provider.UserInfoGetter, se } newAnnouncement := "newAnnouncement" if announcements, ok := patchedGlobalSettingsSpec.Announcements[newAnnouncement]; ok { + announcements.CreatedAt = metav1.NewTime(time.Now()) newUUID := uuid.New().String() delete(patchedGlobalSettingsSpec.Announcements, newAnnouncement) patchedGlobalSettingsSpec.Announcements[newUUID] = announcements diff --git a/modules/api/pkg/handler/v1/user/user.go b/modules/api/pkg/handler/v1/user/user.go index c18dbad824..1efadfa5a9 100644 --- a/modules/api/pkg/handler/v1/user/user.go +++ b/modules/api/pkg/handler/v1/user/user.go @@ -404,9 +404,7 @@ func PatchReadAnnouncementsEndpoint(userProvider provider.UserProvider) endpoint if err != nil { return nil, common.KubernetesErrorToHTTPError(err) } - return updatedUser.Spec.ReadAnnouncements, nil - } } @@ -579,7 +577,7 @@ type PatchSettingsReq struct { } // PatchReadAnnouncementsReq defines HTTP request for patchCurrentUserReadAnnouncements -// swagger:parameters patchCurrentUserReadAnnouncements +// swagger:parameters readAnnouncements type PatchReadAnnouncementsReq struct { // in: body Body []string diff --git a/modules/api/pkg/test/e2e/utils/apiclient/client/settings/patch_current_user_settings_responses.go b/modules/api/pkg/test/e2e/utils/apiclient/client/settings/patch_current_user_settings_responses.go index fbbb07e5bc..dc6179360c 100644 --- a/modules/api/pkg/test/e2e/utils/apiclient/client/settings/patch_current_user_settings_responses.go +++ b/modules/api/pkg/test/e2e/utils/apiclient/client/settings/patch_current_user_settings_responses.go @@ -55,10 +55,10 @@ func NewPatchCurrentUserSettingsOK() *PatchCurrentUserSettingsOK { /* PatchCurrentUserSettingsOK describes a response with status code 200, with default header values. -UserSettings +User */ type PatchCurrentUserSettingsOK struct { - Payload *models.UserSettings + Payload *models.User } // IsSuccess returns true when this patch current user settings o k response has a 2xx status code @@ -94,13 +94,13 @@ func (o *PatchCurrentUserSettingsOK) String() string { return fmt.Sprintf("[PATCH /api/v1/me/settings][%d] patchCurrentUserSettingsOK %+v", 200, o.Payload) } -func (o *PatchCurrentUserSettingsOK) GetPayload() *models.UserSettings { +func (o *PatchCurrentUserSettingsOK) GetPayload() *models.User { return o.Payload } func (o *PatchCurrentUserSettingsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { - o.Payload = new(models.UserSettings) + o.Payload = new(models.User) // response payload if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { diff --git a/modules/api/pkg/test/e2e/utils/apiclient/client/users/patch_current_user_read_announcements_parameters.go b/modules/api/pkg/test/e2e/utils/apiclient/client/users/patch_current_user_read_announcements_parameters.go new file mode 100644 index 0000000000..1fa183b98c --- /dev/null +++ b/modules/api/pkg/test/e2e/utils/apiclient/client/users/patch_current_user_read_announcements_parameters.go @@ -0,0 +1,128 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package users + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewPatchCurrentUserReadAnnouncementsParams creates a new PatchCurrentUserReadAnnouncementsParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewPatchCurrentUserReadAnnouncementsParams() *PatchCurrentUserReadAnnouncementsParams { + return &PatchCurrentUserReadAnnouncementsParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewPatchCurrentUserReadAnnouncementsParamsWithTimeout creates a new PatchCurrentUserReadAnnouncementsParams object +// with the ability to set a timeout on a request. +func NewPatchCurrentUserReadAnnouncementsParamsWithTimeout(timeout time.Duration) *PatchCurrentUserReadAnnouncementsParams { + return &PatchCurrentUserReadAnnouncementsParams{ + timeout: timeout, + } +} + +// NewPatchCurrentUserReadAnnouncementsParamsWithContext creates a new PatchCurrentUserReadAnnouncementsParams object +// with the ability to set a context for a request. +func NewPatchCurrentUserReadAnnouncementsParamsWithContext(ctx context.Context) *PatchCurrentUserReadAnnouncementsParams { + return &PatchCurrentUserReadAnnouncementsParams{ + Context: ctx, + } +} + +// NewPatchCurrentUserReadAnnouncementsParamsWithHTTPClient creates a new PatchCurrentUserReadAnnouncementsParams object +// with the ability to set a custom HTTPClient for a request. +func NewPatchCurrentUserReadAnnouncementsParamsWithHTTPClient(client *http.Client) *PatchCurrentUserReadAnnouncementsParams { + return &PatchCurrentUserReadAnnouncementsParams{ + HTTPClient: client, + } +} + +/* +PatchCurrentUserReadAnnouncementsParams contains all the parameters to send to the API endpoint + + for the patch current user read announcements operation. + + Typically these are written to a http.Request. +*/ +type PatchCurrentUserReadAnnouncementsParams struct { + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the patch current user read announcements params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *PatchCurrentUserReadAnnouncementsParams) WithDefaults() *PatchCurrentUserReadAnnouncementsParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the patch current user read announcements params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *PatchCurrentUserReadAnnouncementsParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the patch current user read announcements params +func (o *PatchCurrentUserReadAnnouncementsParams) WithTimeout(timeout time.Duration) *PatchCurrentUserReadAnnouncementsParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the patch current user read announcements params +func (o *PatchCurrentUserReadAnnouncementsParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the patch current user read announcements params +func (o *PatchCurrentUserReadAnnouncementsParams) WithContext(ctx context.Context) *PatchCurrentUserReadAnnouncementsParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the patch current user read announcements params +func (o *PatchCurrentUserReadAnnouncementsParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the patch current user read announcements params +func (o *PatchCurrentUserReadAnnouncementsParams) WithHTTPClient(client *http.Client) *PatchCurrentUserReadAnnouncementsParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the patch current user read announcements params +func (o *PatchCurrentUserReadAnnouncementsParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WriteToRequest writes these params to a swagger request +func (o *PatchCurrentUserReadAnnouncementsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/modules/api/pkg/test/e2e/utils/apiclient/client/users/patch_current_user_read_announcements_responses.go b/modules/api/pkg/test/e2e/utils/apiclient/client/users/patch_current_user_read_announcements_responses.go new file mode 100644 index 0000000000..d3a2aa7790 --- /dev/null +++ b/modules/api/pkg/test/e2e/utils/apiclient/client/users/patch_current_user_read_announcements_responses.go @@ -0,0 +1,234 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package users + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + "k8c.io/dashboard/v2/pkg/test/e2e/utils/apiclient/models" +) + +// PatchCurrentUserReadAnnouncementsReader is a Reader for the PatchCurrentUserReadAnnouncements structure. +type PatchCurrentUserReadAnnouncementsReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *PatchCurrentUserReadAnnouncementsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) { + switch response.Code() { + case 200: + result := NewPatchCurrentUserReadAnnouncementsOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + case 401: + result := NewPatchCurrentUserReadAnnouncementsUnauthorized() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + default: + result := NewPatchCurrentUserReadAnnouncementsDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewPatchCurrentUserReadAnnouncementsOK creates a PatchCurrentUserReadAnnouncementsOK with default headers values +func NewPatchCurrentUserReadAnnouncementsOK() *PatchCurrentUserReadAnnouncementsOK { + return &PatchCurrentUserReadAnnouncementsOK{} +} + +/* +PatchCurrentUserReadAnnouncementsOK describes a response with status code 200, with default header values. + +User +*/ +type PatchCurrentUserReadAnnouncementsOK struct { + Payload *models.User +} + +// IsSuccess returns true when this patch current user read announcements o k response has a 2xx status code +func (o *PatchCurrentUserReadAnnouncementsOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this patch current user read announcements o k response has a 3xx status code +func (o *PatchCurrentUserReadAnnouncementsOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this patch current user read announcements o k response has a 4xx status code +func (o *PatchCurrentUserReadAnnouncementsOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this patch current user read announcements o k response has a 5xx status code +func (o *PatchCurrentUserReadAnnouncementsOK) IsServerError() bool { + return false +} + +// IsCode returns true when this patch current user read announcements o k response a status code equal to that given +func (o *PatchCurrentUserReadAnnouncementsOK) IsCode(code int) bool { + return code == 200 +} + +func (o *PatchCurrentUserReadAnnouncementsOK) Error() string { + return fmt.Sprintf("[PATCH /api/v1/me/readannouncements][%d] patchCurrentUserReadAnnouncementsOK %+v", 200, o.Payload) +} + +func (o *PatchCurrentUserReadAnnouncementsOK) String() string { + return fmt.Sprintf("[PATCH /api/v1/me/readannouncements][%d] patchCurrentUserReadAnnouncementsOK %+v", 200, o.Payload) +} + +func (o *PatchCurrentUserReadAnnouncementsOK) GetPayload() *models.User { + return o.Payload +} + +func (o *PatchCurrentUserReadAnnouncementsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.User) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewPatchCurrentUserReadAnnouncementsUnauthorized creates a PatchCurrentUserReadAnnouncementsUnauthorized with default headers values +func NewPatchCurrentUserReadAnnouncementsUnauthorized() *PatchCurrentUserReadAnnouncementsUnauthorized { + return &PatchCurrentUserReadAnnouncementsUnauthorized{} +} + +/* +PatchCurrentUserReadAnnouncementsUnauthorized describes a response with status code 401, with default header values. + +EmptyResponse is a empty response +*/ +type PatchCurrentUserReadAnnouncementsUnauthorized struct { +} + +// IsSuccess returns true when this patch current user read announcements unauthorized response has a 2xx status code +func (o *PatchCurrentUserReadAnnouncementsUnauthorized) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this patch current user read announcements unauthorized response has a 3xx status code +func (o *PatchCurrentUserReadAnnouncementsUnauthorized) IsRedirect() bool { + return false +} + +// IsClientError returns true when this patch current user read announcements unauthorized response has a 4xx status code +func (o *PatchCurrentUserReadAnnouncementsUnauthorized) IsClientError() bool { + return true +} + +// IsServerError returns true when this patch current user read announcements unauthorized response has a 5xx status code +func (o *PatchCurrentUserReadAnnouncementsUnauthorized) IsServerError() bool { + return false +} + +// IsCode returns true when this patch current user read announcements unauthorized response a status code equal to that given +func (o *PatchCurrentUserReadAnnouncementsUnauthorized) IsCode(code int) bool { + return code == 401 +} + +func (o *PatchCurrentUserReadAnnouncementsUnauthorized) Error() string { + return fmt.Sprintf("[PATCH /api/v1/me/readannouncements][%d] patchCurrentUserReadAnnouncementsUnauthorized ", 401) +} + +func (o *PatchCurrentUserReadAnnouncementsUnauthorized) String() string { + return fmt.Sprintf("[PATCH /api/v1/me/readannouncements][%d] patchCurrentUserReadAnnouncementsUnauthorized ", 401) +} + +func (o *PatchCurrentUserReadAnnouncementsUnauthorized) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + return nil +} + +// NewPatchCurrentUserReadAnnouncementsDefault creates a PatchCurrentUserReadAnnouncementsDefault with default headers values +func NewPatchCurrentUserReadAnnouncementsDefault(code int) *PatchCurrentUserReadAnnouncementsDefault { + return &PatchCurrentUserReadAnnouncementsDefault{ + _statusCode: code, + } +} + +/* +PatchCurrentUserReadAnnouncementsDefault describes a response with status code -1, with default header values. + +errorResponse +*/ +type PatchCurrentUserReadAnnouncementsDefault struct { + _statusCode int + + Payload *models.ErrorResponse +} + +// Code gets the status code for the patch current user read announcements default response +func (o *PatchCurrentUserReadAnnouncementsDefault) Code() int { + return o._statusCode +} + +// IsSuccess returns true when this patch current user read announcements default response has a 2xx status code +func (o *PatchCurrentUserReadAnnouncementsDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this patch current user read announcements default response has a 3xx status code +func (o *PatchCurrentUserReadAnnouncementsDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this patch current user read announcements default response has a 4xx status code +func (o *PatchCurrentUserReadAnnouncementsDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this patch current user read announcements default response has a 5xx status code +func (o *PatchCurrentUserReadAnnouncementsDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this patch current user read announcements default response a status code equal to that given +func (o *PatchCurrentUserReadAnnouncementsDefault) IsCode(code int) bool { + return o._statusCode == code +} + +func (o *PatchCurrentUserReadAnnouncementsDefault) Error() string { + return fmt.Sprintf("[PATCH /api/v1/me/readannouncements][%d] patchCurrentUserReadAnnouncements default %+v", o._statusCode, o.Payload) +} + +func (o *PatchCurrentUserReadAnnouncementsDefault) String() string { + return fmt.Sprintf("[PATCH /api/v1/me/readannouncements][%d] patchCurrentUserReadAnnouncements default %+v", o._statusCode, o.Payload) +} + +func (o *PatchCurrentUserReadAnnouncementsDefault) GetPayload() *models.ErrorResponse { + return o.Payload +} + +func (o *PatchCurrentUserReadAnnouncementsDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.ErrorResponse) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} diff --git a/modules/api/pkg/test/e2e/utils/apiclient/client/users/users_client.go b/modules/api/pkg/test/e2e/utils/apiclient/client/users/users_client.go index fe75cc57f0..3f82912e19 100644 --- a/modules/api/pkg/test/e2e/utils/apiclient/client/users/users_client.go +++ b/modules/api/pkg/test/e2e/utils/apiclient/client/users/users_client.go @@ -40,6 +40,8 @@ type ClientService interface { LogoutCurrentUser(params *LogoutCurrentUserParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*LogoutCurrentUserOK, error) + PatchCurrentUserReadAnnouncements(params *PatchCurrentUserReadAnnouncementsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PatchCurrentUserReadAnnouncementsOK, error) + SetTransport(transport runtime.ClientTransport) } @@ -273,6 +275,44 @@ func (a *Client) LogoutCurrentUser(params *LogoutCurrentUserParams, authInfo run return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) } +/* +PatchCurrentUserReadAnnouncements updates read announcements of the current user +*/ +func (a *Client) PatchCurrentUserReadAnnouncements(params *PatchCurrentUserReadAnnouncementsParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*PatchCurrentUserReadAnnouncementsOK, error) { + // TODO: Validate the params before sending + if params == nil { + params = NewPatchCurrentUserReadAnnouncementsParams() + } + op := &runtime.ClientOperation{ + ID: "patchCurrentUserReadAnnouncements", + Method: "PATCH", + PathPattern: "/api/v1/me/readannouncements", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"https"}, + Params: params, + Reader: &PatchCurrentUserReadAnnouncementsReader{formats: a.formats}, + AuthInfo: authInfo, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + success, ok := result.(*PatchCurrentUserReadAnnouncementsOK) + if ok { + return success, nil + } + // unexpected success response + unexpectedSuccess := result.(*PatchCurrentUserReadAnnouncementsDefault) + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + // SetTransport changes the transport on the client func (a *Client) SetTransport(transport runtime.ClientTransport) { a.transport = transport diff --git a/modules/api/pkg/test/e2e/utils/apiclient/models/user.go b/modules/api/pkg/test/e2e/utils/apiclient/models/user.go index 67a2329870..94a84fb6b2 100644 --- a/modules/api/pkg/test/e2e/utils/apiclient/models/user.go +++ b/modules/api/pkg/test/e2e/utils/apiclient/models/user.go @@ -54,6 +54,10 @@ type User struct { // along with the group names Projects []*ProjectGroup `json:"projects"` + // ReadAnnouncements holds the IDs of admin announcements that the user has read. + // +optional + ReadAnnouncements []string `json:"readAnnouncements"` + // user settings UserSettings *UserSettings `json:"userSettings,omitempty"` } diff --git a/modules/web/package-lock.json b/modules/web/package-lock.json index e4940f7ae7..9b4c460749 100644 --- a/modules/web/package-lock.json +++ b/modules/web/package-lock.json @@ -26,7 +26,7 @@ "@fontsource/roboto": "5.0.13", "@fontsource/roboto-mono": "5.1.0", "@fontsource/ubuntu": "4.5.11", - "@swimlane/ngx-charts": "21.0.0", + "@swimlane/ngx-charts": "21.1.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "browserslist": "^4.24.2", @@ -69,7 +69,7 @@ "@typescript-eslint/parser": "8.16.0", "browserlist": "1.0.1", "btoa": "1.2.1", - "concurrently": "9.1.0", + "concurrently": "9.1.2", "cypress": "13.17.0", "cypress-fail-fast": "7.1.0", "del": "8.0.0", @@ -91,7 +91,7 @@ "start-server-and-test": "2.0.5", "stream": "0.0.3", "stylelint": "16.10.0", - "stylelint-config-standard-scss": "13.1.0", + "stylelint-config-standard-scss": "14.0.0", "stylelint-no-unsupported-browser-features": "8.0.2", "stylelint-order": "6.0.4", "ts-jest": "29.1.2", @@ -5154,9 +5154,9 @@ } }, "node_modules/@swimlane/ngx-charts": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-21.0.0.tgz", - "integrity": "sha512-4YQNWevbVPekiuLz6w3wLdJY9rD2Pk21xskTUtfpUirUFXdkKZdUByJkSUlup+F8UPvkeZIEC5bhBtOr0yTktA==", + "version": "21.1.2", + "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-21.1.2.tgz", + "integrity": "sha512-Cb5+zxupyVWoBHAR3APLRLCJS/oC72t6QMHI60cELZVLHhxP69lbI3VMXWvpjOr2Cxgc38Md9WkfAHvGAUSy1A==", "license": "MIT", "dependencies": { "d3-array": "^3.2.0", @@ -5172,6 +5172,7 @@ "d3-shape": "^3.2.0", "d3-time-format": "^4.1.0", "d3-transition": "^3.0.1", + "gradient-path": "^2.3.0", "tslib": "^2.3.1" }, "peerDependencies": { @@ -5613,9 +5614,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", - "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "version": "18.3.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.14.tgz", + "integrity": "sha512-NzahNKvjNhVjuPBQ+2G7WlxstQ+47kXZNHlUvFakDViuIEfGY926GqhMueQFZ7woG+sPiQKlF36XfrIUVSUfFg==", "dev": true, "license": "MIT", "dependencies": { @@ -5710,6 +5711,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", + "license": "MIT" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -8517,9 +8524,9 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.0.tgz", - "integrity": "sha512-VxkzwMAn4LP7WyMnJNbHN5mKV9L2IbyDjpzemKr99sXNR3GqRNMMHdm7prV1ws9wg7ETj6WUkNOigZVsptwbgg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz", + "integrity": "sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12081,6 +12088,15 @@ "dev": true, "license": "ISC" }, + "node_modules/gradient-path": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/gradient-path/-/gradient-path-2.3.0.tgz", + "integrity": "sha512-vZdF/Z0EpqUztzWXFjFC16lqcialHacYoRonslk/bC6CuujkuIrqx7etlzdYHY4SnUU94LRWESamZKfkGh7yYQ==", + "license": "MIT", + "dependencies": { + "tinygradient": "^1.0.0" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -17356,6 +17372,7 @@ "version": "19.0.2", "resolved": "https://registry.npmjs.org/ngx-monaco-editor-v2/-/ngx-monaco-editor-v2-19.0.2.tgz", "integrity": "sha512-hkPiCnLU0vdIF2DW7Ko/EHoGCtLxuN85eygKuk3fXL2GRbEIl5VcbUXmRX9ItfLOI1F5QcH80HhavY5r0gNfEw==", + "license": "MIT", "dependencies": { "tslib": "^2.1.0" }, @@ -21454,21 +21471,21 @@ } }, "node_modules/stylelint-config-standard-scss": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/stylelint-config-standard-scss/-/stylelint-config-standard-scss-13.1.0.tgz", - "integrity": "sha512-Eo5w7/XvwGHWkeGLtdm2FZLOMYoZl1omP2/jgFCXyl2x5yNz7/8vv4Tj6slHvMSSUNTaGoam/GAZ0ZhukvalfA==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-standard-scss/-/stylelint-config-standard-scss-14.0.0.tgz", + "integrity": "sha512-6Pa26D9mHyi4LauJ83ls3ELqCglU6VfCXchovbEqQUiEkezvKdv6VgsIoMy58i00c854wVmOw0k8W5FTpuaVqg==", "dev": true, "license": "MIT", "dependencies": { - "stylelint-config-recommended-scss": "^14.0.0", - "stylelint-config-standard": "^36.0.0" + "stylelint-config-recommended-scss": "^14.1.0", + "stylelint-config-standard": "^36.0.1" }, "engines": { "node": ">=18.12.0" }, "peerDependencies": { "postcss": "^8.3.3", - "stylelint": "^16.3.1" + "stylelint": "^16.11.0" }, "peerDependenciesMeta": { "postcss": { @@ -22216,6 +22233,22 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinygradient": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", + "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", + "license": "MIT", + "dependencies": { + "@types/tinycolor2": "^1.4.0", + "tinycolor2": "^1.0.0" + } + }, "node_modules/tldts": { "version": "6.1.66", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.66.tgz", diff --git a/modules/web/package.json b/modules/web/package.json index 15f9493726..23b18f67ea 100644 --- a/modules/web/package.json +++ b/modules/web/package.json @@ -73,7 +73,7 @@ "@fontsource/roboto": "5.0.13", "@fontsource/roboto-mono": "5.1.0", "@fontsource/ubuntu": "4.5.11", - "@swimlane/ngx-charts": "21.0.0", + "@swimlane/ngx-charts": "21.1.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "browserslist": "^4.24.2", @@ -116,7 +116,7 @@ "@typescript-eslint/parser": "8.16.0", "browserlist": "1.0.1", "btoa": "1.2.1", - "concurrently": "9.1.0", + "concurrently": "9.1.2", "cypress": "13.17.0", "cypress-fail-fast": "7.1.0", "del": "8.0.0", @@ -138,7 +138,7 @@ "start-server-and-test": "2.0.5", "stream": "0.0.3", "stylelint": "16.10.0", - "stylelint-config-standard-scss": "13.1.0", + "stylelint-config-standard-scss": "14.0.0", "stylelint-no-unsupported-browser-features": "8.0.2", "stylelint-order": "6.0.4", "ts-jest": "29.1.2", diff --git a/modules/web/src/app/component.ts b/modules/web/src/app/component.ts index 1d8019c8eb..e2725bbf37 100644 --- a/modules/web/src/app/component.ts +++ b/modules/web/src/app/component.ts @@ -19,7 +19,7 @@ import {NavigationEnd, Router} from '@angular/router'; import {Auth} from '@core/services/auth/service'; import {PageTitleService} from '@core/services/page-title'; import {SettingsService} from '@core/services/settings'; -import {AdminSettings, CustomLink} from '@shared/entity/settings'; +import {AdminAnnouncement, AdminSettings, CustomLink} from '@shared/entity/settings'; import {VersionInfo} from '@shared/entity/version-info'; import {Config} from '@shared/model/Config'; import _ from 'lodash'; @@ -42,6 +42,7 @@ export class KubermaticComponent implements OnInit, OnDestroy { @ViewChild('sidenav') sidenav: MatSidenav; config: Config = {}; settings: AdminSettings; + adminAnnouncements = new Map(); customLinks: CustomLink[] = []; version: VersionInfo; showMenuSwitchAndProjectSelector = false; @@ -79,6 +80,23 @@ export class KubermaticComponent implements OnInit, OnDestroy { this._settingsService.adminSettings.pipe(takeUntil(this._unsubscribe)).subscribe(settings => { if (!_.isEqual(this.settings, settings)) { this.settings = settings; + const announcements = settings.announcements; + const updatedAnnouncements = new Map(); + if (announcements) { + Object.keys(announcements) + .sort( + (a, b) => new Date(announcements[b].createdAt).getTime() - new Date(announcements[a].createdAt).getTime() + ) + .forEach(id => { + if ( + announcements[id]?.isActive && + (!announcements[id]?.expires || new Date(announcements[id]?.expires) > new Date()) + ) { + updatedAnnouncements.set(id, announcements[id]); + } + }); + } + this.adminAnnouncements = updatedAnnouncements; } }); } diff --git a/modules/web/src/app/core/components/help-panel/component.ts b/modules/web/src/app/core/components/help-panel/component.ts index 115f2a021c..a68a4e289a 100644 --- a/modules/web/src/app/core/components/help-panel/component.ts +++ b/modules/web/src/app/core/components/help-panel/component.ts @@ -16,7 +16,7 @@ import {Component, ElementRef, HostListener, OnDestroy, OnInit} from '@angular/c import {MatDialog} from '@angular/material/dialog'; import {Router} from '@angular/router'; import {AppConfigService} from '@app/config.service'; -import {AnnouncementDialogComponent} from '@app/shared/components/announcement/component'; +import {AnnouncementsDialogComponent} from '@app/shared/components/announcements/component'; import {SettingsService} from '@core/services/settings'; import {UserService} from '@core/services/user'; import {slideOut} from '@shared/animations/slide'; @@ -113,22 +113,12 @@ export class HelpPanelComponent implements OnInit, OnDestroy { } openAnnouncementsDialog(): void { - const sortedAnnouncements = Object.entries(this.adminSettings.announcements).sort( - ([, a], [, b]) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); - this._matDialog - .open(AnnouncementDialogComponent, {data: Object.fromEntries(sortedAnnouncements)}) - .afterClosed() - .pipe(take(1)) - .subscribe(data => { - if (data) { - const readAnnouncements = data.filter((value, index, self) => self.indexOf(value) === index); - this._updateUserReadAnnouncements(readAnnouncements); - } - }); - } - - private _updateUserReadAnnouncements(announcements: string[]): void { - this._userService.patchReadAnnouncements(announcements).pipe(take(1)).subscribe(); + const sortedAnnouncements = this.adminSettings?.announcements + ? Object.entries(this.adminSettings?.announcements).sort( + ([, a], [, b]) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + : []; + const announcementsObject = Object.fromEntries(sortedAnnouncements); + this._matDialog.open(AnnouncementsDialogComponent, {data: announcementsObject}); } } diff --git a/modules/web/src/app/core/components/help-panel/template.html b/modules/web/src/app/core/components/help-panel/template.html index 79eb03c7fe..a1cfd0f147 100644 --- a/modules/web/src/app/core/components/help-panel/template.html +++ b/modules/web/src/app/core/components/help-panel/template.html @@ -64,11 +64,10 @@ API Documentation -
- Announcement + Announcements
{ - this.onDateChange(time); + this.onTimeChange(time); }); } @@ -127,9 +132,6 @@ export class AdminAnnouncementDialogComponent implements OnInit, OnDestroy { message: this.form.get(Controls.Message).value, isActive: this.form.get(Controls.IsActive).value, expires: this.expiresDate ? this.expiresDate.toISOString() : null, - createdAt: this._data?.announcement?.createdAt - ? new Date(this._data?.announcement.createdAt).toISOString() - : new Date().toISOString(), }; const adminSettings: AdminSettings = {} as AdminSettings; if (this._data?.id) { @@ -149,7 +151,7 @@ export class AdminAnnouncementDialogComponent implements OnInit, OnDestroy { this._notificationService.success('created new announcement'); } - onDateChange(time: string): void { + onTimeChange(time: string): void { const [hours, minutes] = time.split(':').map(Number); if (!isNaN(hours) && !isNaN(minutes)) { this.expiresDate.setHours(hours, minutes, 0, 0); diff --git a/modules/web/src/app/settings/admin/announcement/announcement-dialog/style.scss b/modules/web/src/app/settings/admin/announcements/announcement-dialog/style.scss similarity index 92% rename from modules/web/src/app/settings/admin/announcement/announcement-dialog/style.scss rename to modules/web/src/app/settings/admin/announcements/announcement-dialog/style.scss index 3405d191a3..6ebbf90b5a 100644 --- a/modules/web/src/app/settings/admin/announcement/announcement-dialog/style.scss +++ b/modules/web/src/app/settings/admin/announcements/announcement-dialog/style.scss @@ -1,4 +1,4 @@ -// Copyright 2024 The Kubermatic Kubernetes Platform contributors. +// Copyright 2025 The Kubermatic Kubernetes Platform contributors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/modules/web/src/app/settings/admin/announcement/announcement-dialog/template.html b/modules/web/src/app/settings/admin/announcements/announcement-dialog/template.html similarity index 90% rename from modules/web/src/app/settings/admin/announcement/announcement-dialog/template.html rename to modules/web/src/app/settings/admin/announcements/announcement-dialog/template.html index d05832bfa1..f7df6520a4 100644 --- a/modules/web/src/app/settings/admin/announcement/announcement-dialog/template.html +++ b/modules/web/src/app/settings/admin/announcements/announcement-dialog/template.html @@ -1,5 +1,5 @@ -
-

{{bannerMessage}}

-
- see all announcement -
diff --git a/modules/web/src/app/shared/components/announcement-banner/theme.scss b/modules/web/src/app/shared/components/announcement-banner/theme.scss index c3ab26653f..035cd3314c 100644 --- a/modules/web/src/app/shared/components/announcement-banner/theme.scss +++ b/modules/web/src/app/shared/components/announcement-banner/theme.scss @@ -32,7 +32,7 @@ } } - .mat-mdc-icon-button { + .mdc-button{ &:hover { background-color: map.get($colors, primary-hover); } diff --git a/modules/web/src/app/shared/components/announcement/component.ts b/modules/web/src/app/shared/components/announcements/component.ts similarity index 72% rename from modules/web/src/app/shared/components/announcement/component.ts rename to modules/web/src/app/shared/components/announcements/component.ts index 979a74e8c8..b487135984 100644 --- a/modules/web/src/app/shared/components/announcement/component.ts +++ b/modules/web/src/app/shared/components/announcements/component.ts @@ -1,4 +1,4 @@ -// Copyright 2024 The Kubermatic Kubernetes Platform contributors. +// Copyright 2025 The Kubermatic Kubernetes Platform contributors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -25,34 +25,35 @@ enum Column { } @Component({ - // check the name - selector: 'km-announcement', + selector: 'km-announcements-dialog', templateUrl: './template.html', + styleUrl: './style.scss', }) -export class AnnouncementDialogComponent implements OnInit { +export class AnnouncementsDialogComponent implements OnInit { readonly Column = Column; dataSource = new MatTableDataSource(); displayedColumns: string[] = Object.values(Column); announcements = new Map(); readAnnouncements: string[] = []; + awaitedAnnouncementID: string = ''; constructor( - public _matDialogRef: MatDialogRef, + public _matDialogRef: MatDialogRef, private readonly _userService: UserService, @Inject(MAT_DIALOG_DATA) public data: Map ) {} ngOnInit(): void { this._getAnnouncements(); - this._getReadAnnouncements(); } hasAnnouncements(): boolean { - return !!Object.keys(this.announcements).length; + return !!this.announcements?.size; } markAsRead(announcement: string): void { - this.readAnnouncements?.push(announcement); + this.awaitedAnnouncementID = announcement; + this._updateUserReadAnnouncements([...this.readAnnouncements, announcement]); } isMessageRead(announcementId: string): boolean { @@ -64,7 +65,7 @@ export class AnnouncementDialogComponent implements OnInit { } closeDialog(): void { - this._matDialogRef.close(this.readAnnouncements); + this._matDialogRef.close(); } private _getAnnouncements(): void { @@ -79,13 +80,26 @@ export class AnnouncementDialogComponent implements OnInit { } }); this.dataSource.data = Array.from(this.announcements.keys()); + this._getReadAnnouncements(); } private _getReadAnnouncements(): void { this._userService.currentUser.pipe(take(1)).subscribe(settings => { if (settings.readAnnouncements) { - this.readAnnouncements = settings.readAnnouncements; + this.readAnnouncements = settings.readAnnouncements.filter(id => + Array.from(this.announcements.keys()).includes(id) + ); } }); } + + private _updateUserReadAnnouncements(announcements: string[]): void { + this._userService + .patchReadAnnouncements(announcements) + .pipe(take(1)) + .subscribe(announcements => { + this.readAnnouncements = announcements; + this.awaitedAnnouncementID = ''; + }); + } } diff --git a/modules/web/src/app/shared/components/announcements/style.scss b/modules/web/src/app/shared/components/announcements/style.scss new file mode 100644 index 0000000000..e55d2e5edf --- /dev/null +++ b/modules/web/src/app/shared/components/announcements/style.scss @@ -0,0 +1,28 @@ +// Copyright 2025 The Kubermatic Kubernetes Platform contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +.events-long-text { + word-break: break-word; +} + +.mat-mdc-cell { + .mdc-icon-button { + position: relative; + top: 10px; + + .km-icon-check { + margin: 0; + } + } +} diff --git a/modules/web/src/app/shared/components/announcement/template.html b/modules/web/src/app/shared/components/announcements/template.html similarity index 67% rename from modules/web/src/app/shared/components/announcement/template.html rename to modules/web/src/app/shared/components/announcements/template.html index df3bae7d2b..9c92899b0f 100644 --- a/modules/web/src/app/shared/components/announcement/template.html +++ b/modules/web/src/app/shared/components/announcements/template.html @@ -1,5 +1,5 @@ -Announcement +Announcements - +
+ class="km-header-cell p-85">Message @@ -33,23 +34,27 @@ *matHeaderCellDef class="km-header-cell"> + + + + + class="km-mat-row + ">
Message {{getMessage(element)}}
No backups available.
+ *ngIf="!hasAnnouncements()">No announcements available.