From da8a670a681e4660a85c3988139b23f9702f65e9 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Mon, 5 Jan 2026 12:38:39 +0100 Subject: [PATCH] Improve scim event UI/UX Signed-off-by: Bryan Frimin --- .../settings/_components/SCIMEventList.tsx | 7 +- .../_components/SCIMEventListItem.tsx | 85 ++++++-- pkg/iam/scim/service.go | 40 +--- pkg/server/api/connect/v1/scim_handler.go | 204 +++++++++++------- 4 files changed, 201 insertions(+), 135 deletions(-) diff --git a/apps/console/src/pages/iam/organizations/settings/_components/SCIMEventList.tsx b/apps/console/src/pages/iam/organizations/settings/_components/SCIMEventList.tsx index 0bce0c326..4f72046cb 100644 --- a/apps/console/src/pages/iam/organizations/settings/_components/SCIMEventList.tsx +++ b/apps/console/src/pages/iam/organizations/settings/_components/SCIMEventList.tsx @@ -50,16 +50,13 @@ export function SCIMEventList(props: { fKey: SCIMEventListFragment$key }) { {__("Time")} {__("Method")} {__("Path")} - {__("Status")} - {__("User")} - {__("IP Address")} - {__("Error")} + {__("Result")} {eventsPagination.data.events.edges.length === 0 ? ( - + {__("No SCIM events recorded yet.")} diff --git a/apps/console/src/pages/iam/organizations/settings/_components/SCIMEventListItem.tsx b/apps/console/src/pages/iam/organizations/settings/_components/SCIMEventListItem.tsx index d3483dc5b..38fcc883d 100644 --- a/apps/console/src/pages/iam/organizations/settings/_components/SCIMEventListItem.tsx +++ b/apps/console/src/pages/iam/organizations/settings/_components/SCIMEventListItem.tsx @@ -1,7 +1,8 @@ +import { useState } from "react"; import { useFragment } from "react-relay"; import { graphql } from "relay-runtime"; import type { SCIMEventListItemFragment$key } from "/__generated__/iam/SCIMEventListItemFragment.graphql"; -import { Td, Tr, Badge } from "@probo/ui"; +import { Td, Tr, Badge, IconChevronDown, IconChevronRight } from "@probo/ui"; import { formatDate } from "@probo/helpers"; const SCIMEventListItemFragment = graphql` @@ -22,15 +23,11 @@ const SCIMEventListItemFragment = graphql` } `; -const getStatusBadge = (statusCode: number) => { +const getResultBadge = (statusCode: number) => { if (statusCode >= 200 && statusCode < 300) { - return {statusCode}; - } else if (statusCode >= 400 && statusCode < 500) { - return {statusCode}; - } else if (statusCode >= 500) { - return {statusCode}; + return Info; } - return {statusCode}; + return Error; }; const getMethodBadge = (method: string) => { @@ -45,27 +42,77 @@ const getMethodBadge = (method: string) => { return {method}; }; +const decodePath = (path: string): string => { + try { + return decodeURIComponent(path); + } catch { + return path; + } +}; + export function SCIMEventListItem(props: { fKey: SCIMEventListItemFragment$key; }) { const { fKey } = props; + const [isExpanded, setIsExpanded] = useState(false); const event = useFragment( SCIMEventListItemFragment, fKey ); + const hasError = !!event.errorMessage; + return ( - - {formatDate(event.createdAt)} - {getMethodBadge(event.method)} - {event.path} - {getStatusBadge(event.statusCode)} - {event.membership?.profile?.fullName || "-"} - {event.ipAddress} - - {event.errorMessage || "-"} - - + <> + setIsExpanded(!isExpanded)} + > + +
+ {isExpanded ? ( + + ) : ( + + )} + {formatDate(event.createdAt)} +
+ + {getMethodBadge(event.method)} + {decodePath(event.path)} + {getResultBadge(event.statusCode)} + + {isExpanded && ( + + +
+
+
+ User: + {event.membership?.profile?.fullName || "-"} +
+
+ IP Address: + {event.ipAddress} +
+
+ Status Code: + {event.statusCode} +
+
+ {hasError && ( +
+
Error
+
+                    {event.errorMessage}
+                  
+
+ )} +
+ + + )} + ); } diff --git a/pkg/iam/scim/service.go b/pkg/iam/scim/service.go index 2fdd894fc..080965cf9 100644 --- a/pkg/iam/scim/service.go +++ b/pkg/iam/scim/service.go @@ -94,7 +94,6 @@ func (s *Service) CreateUser( ctx context.Context, config *coredata.SCIMConfiguration, attributes scim.ResourceAttributes, - ipAddress net.IP, ) (scim.Resource, error) { email, fullName := ParseUserFromAttributes(attributes) if email == "" { @@ -183,13 +182,6 @@ func (s *Service) CreateUser( } } - // Log SCIM event - event := s.createEvent(config, "POST", "/Users", membership.ID, ipAddress, 201, nil) - err = event.Insert(ctx, tx, scope) - if err != nil { - s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err)) - } - return nil }) @@ -205,7 +197,6 @@ func (s *Service) GetUser( ctx context.Context, config *coredata.SCIMConfiguration, membershipID gid.GID, - ipAddress net.IP, ) (scim.Resource, error) { scope := coredata.NewScopeFromObjectID(config.OrganizationID) @@ -246,7 +237,6 @@ func (s *Service) ListUsers( filterExpr scimfilter.Expression, startIndex int, count int, - ipAddress net.IP, ) ([]scim.Resource, int, error) { filter, err := ParseUserFilter(filterExpr) if err != nil { @@ -297,10 +287,9 @@ func (s *Service) ReplaceUser( config *coredata.SCIMConfiguration, membershipID gid.GID, attributes scim.ResourceAttributes, - ipAddress net.IP, ) (scim.Resource, error) { fullName, active := ParseUserFromReplaceAttributes(attributes) - membership, deactivated, err := s.updateUser(ctx, config, membershipID, fullName, active, "PUT", ipAddress) + membership, deactivated, err := s.updateUser(ctx, config, membershipID, fullName, active) if err != nil { return scim.Resource{}, err } @@ -313,10 +302,9 @@ func (s *Service) PatchUser( config *coredata.SCIMConfiguration, membershipID gid.GID, operations []scim.PatchOperation, - ipAddress net.IP, ) (scim.Resource, error) { fullName, active := ParseUserFromPatchOperations(operations) - membership, deactivated, err := s.updateUser(ctx, config, membershipID, fullName, active, "PATCH", ipAddress) + membership, deactivated, err := s.updateUser(ctx, config, membershipID, fullName, active) if err != nil { return scim.Resource{}, err } @@ -329,8 +317,6 @@ func (s *Service) updateUser( membershipID gid.GID, fullName string, active *bool, - method string, - ipAddress net.IP, ) (*coredata.Membership, bool, error) { scope := coredata.NewScopeFromObjectID(config.OrganizationID) now := time.Now() @@ -362,13 +348,6 @@ func (s *Service) updateUser( deactivated = true - // Log SCIM event for deactivation - event := s.createEvent(config, method, fmt.Sprintf("/Users/%s", membershipID), membershipID, ipAddress, 200, nil) - err = event.Insert(ctx, tx, scope) - if err != nil { - s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err)) - } - return nil } @@ -398,13 +377,6 @@ func (s *Service) updateUser( } } - // Log SCIM event - event := s.createEvent(config, method, fmt.Sprintf("/Users/%s", membershipID), membership.ID, ipAddress, 200, nil) - err = event.Insert(ctx, tx, scope) - if err != nil { - s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err)) - } - return nil }) @@ -420,7 +392,6 @@ func (s *Service) DeleteUser( ctx context.Context, config *coredata.SCIMConfiguration, membershipID gid.GID, - ipAddress net.IP, ) error { scope := coredata.NewScopeFromObjectID(config.OrganizationID) @@ -444,13 +415,6 @@ func (s *Service) DeleteUser( return fmt.Errorf("cannot delete membership: %w", err) } - // Log SCIM event - event := s.createEvent(config, "DELETE", fmt.Sprintf("/Users/%s", membershipID), membershipID, ipAddress, 204, nil) - err = event.Insert(ctx, tx, scope) - if err != nil { - s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err)) - } - return nil }) } diff --git a/pkg/server/api/connect/v1/scim_handler.go b/pkg/server/api/connect/v1/scim_handler.go index 238394453..6b73aafd4 100644 --- a/pkg/server/api/connect/v1/scim_handler.go +++ b/pkg/server/api/connect/v1/scim_handler.go @@ -32,20 +32,35 @@ import ( scimservice "go.probo.inc/probo/pkg/iam/scim" ) -type SCIMHandler struct { - iam *iam.Service - logger *log.Logger -} +type ( + SCIMHandler struct { + iam *iam.Service + logger *log.Logger + } + + scimResourceHandler struct { + handler *SCIMHandler + } + + scimRequestContext struct { + ctx context.Context + config *coredata.SCIMConfiguration + ipAddress net.IP + method string + path string + membershipID *gid.GID + handler *scimResourceHandler + } +) + +var ( + scimConfigCtxKey = &ctxKey{name: "scim_config"} +) func NewSCIMHandler(iam *iam.Service, logger *log.Logger) *SCIMHandler { return &SCIMHandler{iam: iam, logger: logger} } -// Context key for SCIM configuration -type scimCtxKey struct{ name string } - -var scimConfigCtxKey = &scimCtxKey{name: "scim_config"} - func scimConfigFromContext(ctx context.Context) *coredata.SCIMConfiguration { config, _ := ctx.Value(scimConfigCtxKey).(*coredata.SCIMConfiguration) return config @@ -122,68 +137,101 @@ func (h *SCIMHandler) BearerTokenMiddleware(next http.Handler) http.Handler { }) } -// scimResourceHandler implements the elimity-com/scim ResourceHandler interface -type scimResourceHandler struct { - handler *SCIMHandler +func (rc *scimRequestContext) logAndWrapError(err error, logMsg string) error { + var scimErr scimerrors.ScimError + if errors.As(err, &scimErr) { + errMsg := scimErr.Detail + + // Don't reference membershipID for 404 errors - the resource doesn't exist + membershipID := rc.membershipID + if scimErr.Status == http.StatusNotFound { + membershipID = nil + } + rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, membershipID, rc.ipAddress, scimErr.Status, &errMsg) + return err + } + + rc.handler.handler.logger.ErrorCtx(rc.ctx, logMsg, log.Error(err)) + errMsg := "internal server error" + rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, rc.membershipID, rc.ipAddress, 500, &errMsg) + return scimerrors.ScimErrorInternal +} + +func (rc *scimRequestContext) logSuccess(statusCode int) { + rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, rc.membershipID, rc.ipAddress, statusCode, nil) } func (h *scimResourceHandler) Create(r *http.Request, attributes scim.ResourceAttributes) (scim.Resource, error) { - ctx := r.Context() - config := scimConfigFromContext(ctx) - - resource, err := h.handler.iam.SCIMService.CreateUser(ctx, config, attributes, getIPAddress(r)) - if err != nil { - var scimErr scimerrors.ScimError - if errors.As(err, &scimErr) { - return scim.Resource{}, err - } - h.handler.logger.ErrorCtx(ctx, "cannot create user", log.Error(err)) - return scim.Resource{}, scimerrors.ScimErrorInternal + rc := &scimRequestContext{ + ctx: r.Context(), + config: scimConfigFromContext(r.Context()), + ipAddress: getIPAddress(r), + method: "POST", + path: "/Users", + handler: h, } + resource, err := h.handler.iam.SCIMService.CreateUser(rc.ctx, rc.config, attributes) + if err != nil { + return scim.Resource{}, rc.logAndWrapError(err, "cannot create user") + } + + membershipID, _ := gid.ParseGID(resource.ID) + rc.membershipID = &membershipID + rc.logSuccess(201) return resource, nil } func (h *scimResourceHandler) Get(r *http.Request, id string) (scim.Resource, error) { - ctx := r.Context() - config := scimConfigFromContext(ctx) + rc := &scimRequestContext{ + ctx: r.Context(), + config: scimConfigFromContext(r.Context()), + ipAddress: getIPAddress(r), + method: "GET", + path: "/Users/" + id, + handler: h, + } membershipID, err := gid.ParseGID(id) if err != nil { - return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) + return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid membership ID") } + rc.membershipID = &membershipID - resource, err := h.handler.iam.SCIMService.GetUser(ctx, config, membershipID, getIPAddress(r)) + resource, err := h.handler.iam.SCIMService.GetUser(rc.ctx, rc.config, membershipID) if err != nil { - var scimErr scimerrors.ScimError - if errors.As(err, &scimErr) { - return scim.Resource{}, err - } - h.handler.logger.ErrorCtx(ctx, "cannot get user", log.Error(err)) - return scim.Resource{}, scimerrors.ScimErrorInternal + return scim.Resource{}, rc.logAndWrapError(err, "cannot get user") } + rc.logSuccess(200) return resource, nil } func (h *scimResourceHandler) GetAll(r *http.Request, params scim.ListRequestParams) (scim.Page, error) { - ctx := r.Context() - config := scimConfigFromContext(ctx) + path := "/Users" + if r.URL.RawQuery != "" { + path += "?" + r.URL.RawQuery + } + + rc := &scimRequestContext{ + ctx: r.Context(), + config: scimConfigFromContext(r.Context()), + ipAddress: getIPAddress(r), + method: "GET", + path: path, + handler: h, + } if err := params.FilterValidator.Validate(); err != nil { - return scim.Page{}, scimerrors.ScimErrorBadRequest(err.Error()) + return scim.Page{}, rc.logAndWrapError(scimerrors.ScimErrorBadRequest(err.Error()), "invalid filter") } - resources, totalCount, err := h.handler.iam.SCIMService.ListUsers(ctx, config, params.FilterValidator.GetFilter(), params.StartIndex, params.Count, getIPAddress(r)) + resources, totalCount, err := h.handler.iam.SCIMService.ListUsers(rc.ctx, rc.config, params.FilterValidator.GetFilter(), params.StartIndex, params.Count) if err != nil { - var scimErr scimerrors.ScimError - if errors.As(err, &scimErr) { - return scim.Page{}, err - } - h.handler.logger.ErrorCtx(ctx, "cannot list users", log.Error(err)) - return scim.Page{}, scimerrors.ScimErrorInternal + return scim.Page{}, rc.logAndWrapError(err, "cannot list users") } + rc.logSuccess(200) return scim.Page{ TotalResults: totalCount, Resources: resources, @@ -191,68 +239,78 @@ func (h *scimResourceHandler) GetAll(r *http.Request, params scim.ListRequestPar } func (h *scimResourceHandler) Replace(r *http.Request, id string, attributes scim.ResourceAttributes) (scim.Resource, error) { - ctx := r.Context() - config := scimConfigFromContext(ctx) + rc := &scimRequestContext{ + ctx: r.Context(), + config: scimConfigFromContext(r.Context()), + ipAddress: getIPAddress(r), + method: "PUT", + path: "/Users/" + id, + handler: h, + } membershipID, err := gid.ParseGID(id) if err != nil { - return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) + return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid membership ID") } + rc.membershipID = &membershipID - resource, err := h.handler.iam.SCIMService.ReplaceUser(ctx, config, membershipID, attributes, getIPAddress(r)) + resource, err := h.handler.iam.SCIMService.ReplaceUser(rc.ctx, rc.config, membershipID, attributes) if err != nil { - var scimErr scimerrors.ScimError - if errors.As(err, &scimErr) { - return scim.Resource{}, err - } - h.handler.logger.ErrorCtx(ctx, "cannot update user", log.Error(err)) - return scim.Resource{}, scimerrors.ScimErrorInternal + return scim.Resource{}, rc.logAndWrapError(err, "cannot update user") } + rc.logSuccess(200) return resource, nil } func (h *scimResourceHandler) Patch(r *http.Request, id string, operations []scim.PatchOperation) (scim.Resource, error) { - ctx := r.Context() - config := scimConfigFromContext(ctx) + rc := &scimRequestContext{ + ctx: r.Context(), + config: scimConfigFromContext(r.Context()), + ipAddress: getIPAddress(r), + method: "PATCH", + path: "/Users/" + id, + handler: h, + } membershipID, err := gid.ParseGID(id) if err != nil { - return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) + return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid membership ID") } + rc.membershipID = &membershipID - resource, err := h.handler.iam.SCIMService.PatchUser(ctx, config, membershipID, operations, getIPAddress(r)) + resource, err := h.handler.iam.SCIMService.PatchUser(rc.ctx, rc.config, membershipID, operations) if err != nil { - var scimErr scimerrors.ScimError - if errors.As(err, &scimErr) { - return scim.Resource{}, err - } - h.handler.logger.ErrorCtx(ctx, "cannot patch user", log.Error(err)) - return scim.Resource{}, scimerrors.ScimErrorInternal + return scim.Resource{}, rc.logAndWrapError(err, "cannot patch user") } + rc.logSuccess(200) return resource, nil } func (h *scimResourceHandler) Delete(r *http.Request, id string) error { - ctx := r.Context() - config := scimConfigFromContext(ctx) + rc := &scimRequestContext{ + ctx: r.Context(), + config: scimConfigFromContext(r.Context()), + ipAddress: getIPAddress(r), + method: "DELETE", + path: "/Users/" + id, + handler: h, + } membershipID, err := gid.ParseGID(id) if err != nil { - return scimerrors.ScimErrorResourceNotFound(id) + return rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid membership ID") } + rc.membershipID = &membershipID - err = h.handler.iam.SCIMService.DeleteUser(ctx, config, membershipID, getIPAddress(r)) + err = h.handler.iam.SCIMService.DeleteUser(rc.ctx, rc.config, membershipID) if err != nil { - var scimErr scimerrors.ScimError - if errors.As(err, &scimErr) { - return err - } - h.handler.logger.ErrorCtx(ctx, "cannot delete user", log.Error(err)) - return scimerrors.ScimErrorInternal + return rc.logAndWrapError(err, "cannot delete user") } + rc.membershipID = nil + rc.logSuccess(204) return nil }