diff --git a/backend/app/administration.go b/backend/app/administration.go index 74e9d2e..47bc945 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -76,6 +76,7 @@ const ( ROUTE_V1_COMPANY_SCIM = "/api/v1/company/scim/:companyID" ROUTE_V1_COMPANY_SCIM_TOKEN = "/api/v1/company/scim/:companyID/token" ROUTE_V1_COMPANY_SCIM_PRUNE = "/api/v1/company/scim/:companyID/prune" + ROUTE_V1_COMPANY_SCIM_RESTORE = "/api/v1/company/scim/:companyID/restore" // scim v2 provisioning endpoints (public — authenticated via bearer token) ROUTE_SCIM_V2_SERVICE_PROVIDER_CONFIG = "/api/v1/scim/v2/:companyID/ServiceProviderConfig" ROUTE_SCIM_V2_RESOURCE_TYPES = "/api/v1/scim/v2/:companyID/ResourceTypes" @@ -360,6 +361,7 @@ func setupRoutes( DELETE(ROUTE_V1_COMPANY_SCIM, middleware.SessionHandler, controllers.CompanyScimConfig.Delete). POST(ROUTE_V1_COMPANY_SCIM_TOKEN, middleware.SessionHandler, controllers.CompanyScimConfig.RotateToken). POST(ROUTE_V1_COMPANY_SCIM_PRUNE, middleware.SessionHandler, controllers.CompanyScimConfig.Prune). + POST(ROUTE_V1_COMPANY_SCIM_RESTORE, middleware.SessionHandler, controllers.CompanyScimConfig.Restore). // options GET(ROUTE_V1_OPTION_GET, middleware.SessionHandler, controllers.Option.Get). POST(ROUTE_V1_OPTION, middleware.SessionHandler, middleware.SessionHandler, controllers.Option.Update). diff --git a/backend/controller/companyScimConfig.go b/backend/controller/companyScimConfig.go index eb24148..6257180 100644 --- a/backend/controller/companyScimConfig.go +++ b/backend/controller/companyScimConfig.go @@ -37,6 +37,29 @@ func (c *CompanyScimConfig) Prune(g *gin.Context) { c.Response.OK(g, gin.H{"pruned": pruned}) } +// Restore clears the SCIM-disabled mark from the company's deprovisioned recipients. +func (c *CompanyScimConfig) Restore(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + companyID, err := uuid.Parse(g.Param("companyID")) + if err != nil { + c.Logger.Debugw("failed to parse companyID param", "error", err) + c.Response.BadRequestMessage(g, errs.MsgFailedToParseUUID) + return + } + restored, err := c.ScimService.RestoreSoftDeletedAuthorized( + g.Request.Context(), + session, + &companyID, + ) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{"restored": restored}) +} + // upsertScimRequest is the request body for the Upsert handler type upsertScimRequest struct { Enabled bool `json:"enabled"` diff --git a/backend/service/scim.go b/backend/service/scim.go index f215b60..2a20be7 100644 --- a/backend/service/scim.go +++ b/backend/service/scim.go @@ -1384,6 +1384,58 @@ func (s *Scim) PruneSoftDeletedAuthorized( return pruned, nil } +// RestoreSoftDeletedAuthorized is the admin (session-authenticated) on-demand +// restore for a company. It clears the SCIM-disabled mark from all currently +// disabled recipients, returning them to active without re-provisioning. +func (s *Scim) RestoreSoftDeletedAuthorized( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, +) (int, error) { + ae := NewAuditEvent("Scim.RestoreSoftDeletedAuthorized", session) + if companyID != nil { + ae.Details["companyID"] = companyID.String() + } + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + s.LogAuthError(err) + return 0, errs.Wrap(err) + } + if !isAuthorized { + s.AuditLogNotAuthorized(ae) + return 0, errs.ErrAuthorizationFailed + } + restored, err := s.restoreSoftDeleted(ctx, companyID, time.Now()) + if err != nil { + return 0, err + } + ae.Details["restored"] = restored + s.AuditLogAuthorized(ae) + return restored, nil +} + +// restoreSoftDeleted clears the disabled mark for every recipient of the company +// whose scim_soft_deleted_at is set before the given time. +func (s *Scim) restoreSoftDeleted(ctx context.Context, companyID *uuid.UUID, before time.Time) (int, error) { + recipients, err := s.RecipientRepository.GetScimSoftDeletedBefore(ctx, companyID, before) + if err != nil { + return 0, errs.Wrap(err) + } + restored := 0 + for _, r := range recipients { + id, idErr := r.ID.Get() + if idErr != nil { + continue + } + if clearErr := s.RecipientRepository.ClearScimSoftDeleted(ctx, &id); clearErr != nil { + s.Logger.Errorw("scim restore: failed to clear soft-deleted recipient", "error", clearErr, "recipientID", id.String()) + continue + } + restored++ + } + return restored, nil +} + // auditScim emits an audit event for an externally driven SCIM mutation. // SCIM has no admin session, so the actor is identified by the company and the // token prefix instead of a user id. diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index f8981ab..c320402 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -1284,6 +1284,10 @@ export class API { // prune the company's disabled (soft-deleted) recipients past the retention window prune: async (companyID) => { return await postJSON(this.getPath(`/company/scim/${companyID}/prune`), {}); + }, + // clear the disabled mark from the company's deprovisioned recipients + restore: async (companyID) => { + return await postJSON(this.getPath(`/company/scim/${companyID}/restore`), {}); } }, /** diff --git a/frontend/src/lib/components/modal/ScimModal.svelte b/frontend/src/lib/components/modal/ScimModal.svelte index a90bab1..89b6cd1 100644 --- a/frontend/src/lib/components/modal/ScimModal.svelte +++ b/frontend/src/lib/components/modal/ScimModal.svelte @@ -20,6 +20,7 @@ let isSettingUp = false; let isDeleting = false; let isPruning = false; + let isRestoring = false; // token reveal — only populated immediately after create or rotate let revealedToken = ''; @@ -29,6 +30,7 @@ let isRotateAlertVisible = false; let isDeleteAlertVisible = false; let isPruneAlertVisible = false; + let isRestoreAlertVisible = false; // reactive: reload when modal opens $: { @@ -51,6 +53,7 @@ isRotateAlertVisible = false; isDeleteAlertVisible = false; isPruneAlertVisible = false; + isRestoreAlertVisible = false; }; const loadAll = async () => { @@ -195,6 +198,29 @@ } }; + const onConfirmRestore = async () => { + isRestoring = true; + try { + const res = await api.company.scim.restore(company.id); + if (res && res.success) { + const n = res.data?.restored ?? 0; + addToast( + `Removed disabled mark from ${n} ${n === 1 ? 'recipient' : 'recipients'}`, + 'Success' + ); + return { success: true }; + } + addToast(res?.error ?? 'Failed to remove disabled mark', 'Error'); + return { success: false }; + } catch (e) { + console.error('failed to remove disabled mark', e); + addToast('Failed to remove disabled mark', 'Error'); + return { success: false }; + } finally { + isRestoring = false; + } + }; + const copyToClipboard = async (text) => { try { await navigator.clipboard.writeText(text); @@ -221,11 +247,12 @@ $: scimBaseURL = company && scimDomain ? `https://${scimDomain}/api/v1/scim/v2/${company.id}` : ''; - $: isBusy = isSettingUp || isTogglingEnabled || isRotating || isDeleting || isPruning; + $: isBusy = + isSettingUp || isTogglingEnabled || isRotating || isDeleting || isPruning || isRestoring; -
+
{#if isLoading}

Loading...

@@ -357,17 +384,26 @@ + @@ -375,7 +411,7 @@ type="button" disabled={isBusy} on:click={() => (isDeleteAlertVisible = true)} - class="bg-red-600 dark:bg-red-700/80 hover:bg-red-500 dark:hover:bg-red-600/80 text-sm uppercase font-bold px-4 py-2 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" + class="bg-red-600 dark:bg-red-700/80 hover:bg-red-500 dark:hover:bg-red-600/80 text-sm uppercase font-bold px-4 py-2 text-white rounded-md whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" > Delete @@ -401,20 +437,34 @@

+ + +
+

{company?.name}

+
+

+ Clears the disabled mark from all disabled recipients, making them active again and cancelling + any pending prune. +

+
+ -

- Permanently remove all disabled recipients for - {company?.name} now? -

-

- this removes them immediately, before their retention window ends. their identity is deleted and - cannot be recovered; historical campaign results are kept in anonymized form. +

+

{company?.name}

+
+

+ Permanently deletes all disabled recipients. Their identity cannot be recovered; campaign + results are kept anonymized.