mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-05 11:57:54 +02:00
add remove recipient disable mark\nimprove UI SCIM modal texts
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user