add remove recipient disable mark\nimprove UI SCIM modal texts

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-06-12 10:30:52 +02:00
parent 2c4a2533d9
commit 02f87b3e3a
5 changed files with 147 additions and 16 deletions
+2
View File
@@ -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).
+23
View File
@@ -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"`
+52
View File
@@ -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.
+4
View File
@@ -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`), {});
}
},
/**
@@ -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;
</script>
<Modal headerText={`SCIM`} bind:visible>
<div class="w-[600px] p-6 space-y-6">
<div class="w-[780px] p-6 space-y-6">
{#if isLoading}
<div class="flex items-center justify-center py-10">
<p class="text-gray-500 dark:text-gray-400 transition-colors duration-200">Loading...</p>
@@ -357,17 +384,26 @@
<button
type="button"
disabled={isBusy}
on:click={() => (isPruneAlertVisible = true)}
title="Permanently remove this company's disabled recipients now"
class="bg-slate-400 dark:bg-gray-700/80 hover:bg-slate-300 dark:hover:bg-gray-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"
on:click={() => (isRestoreAlertVisible = true)}
title="Clear the disabled mark from this company's disabled recipients"
class="bg-slate-400 dark:bg-gray-700/80 hover:bg-slate-300 dark:hover:bg-gray-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"
>
{isPruning ? 'Pruning...' : 'Prune Removed Users'}
{isRestoring ? 'Removing...' : 'Remove Disabled Mark'}
</button>
<button
type="button"
disabled={isBusy}
on:click={() => (isPruneAlertVisible = true)}
title="Permanently delete this company's disabled users"
class="bg-slate-400 dark:bg-gray-700/80 hover:bg-slate-300 dark:hover:bg-gray-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"
>
{isPruning ? 'Pruning...' : 'Prune Disabled Users'}
</button>
<button
type="button"
disabled={isBusy}
on:click={() => (isRotateAlertVisible = true)}
class="bg-slate-400 dark:bg-gray-700/80 hover:bg-slate-300 dark:hover:bg-gray-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-slate-400 dark:bg-gray-700/80 hover:bg-slate-300 dark:hover:bg-gray-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"
>
Rotate Token
</button>
@@ -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
</button>
@@ -401,20 +437,34 @@
</p>
</Alert>
<!-- remove disabled mark confirmation -->
<Alert
headline="Remove disabled mark"
bind:visible={isRestoreAlertVisible}
onConfirm={onConfirmRestore}
>
<div class="bg-gray-50 dark:bg-gray-700 p-3 rounded mb-4">
<p class="font-medium">{company?.name}</p>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Clears the disabled mark from all disabled recipients, making them active again and cancelling
any pending prune.
</p>
</Alert>
<!-- prune disabled recipients confirmation -->
<Alert
headline="Remove disabled recipients"
headline="Prune disabled users"
bind:visible={isPruneAlertVisible}
onConfirm={onConfirmPrune}
verification="purge"
>
<p>
Permanently remove all <strong>disabled</strong> recipients for
<strong>{company?.name}</strong> now?
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
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.
<div class="bg-gray-50 dark:bg-gray-700 p-3 rounded mb-4">
<p class="font-medium">{company?.name}</p>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Permanently deletes all disabled recipients. Their identity cannot be recovered; campaign
results are kept anonymized.
</p>
</Alert>