add auto remove orphans

fix orphans in dynamic groups not included

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-03-28 20:19:40 +01:00
parent 8fa58ded8f
commit 83f9e8f279
21 changed files with 721 additions and 54 deletions
+45
View File
@@ -1241,6 +1241,30 @@ export class API {
*/
delete: async (id) => {
return await deleteJSON(this.getPath(`/company/${id}`));
},
/**
* Get the auto-prune orphaned recipients setting for a company.
* Returns only the per-company enabled flag.
*
* @param {string} id - company ID
* @returns {Promise<ApiResponse>}
*/
getAutoPrune: async (id) => {
return await getJSON(this.getPath(`/company/${id}/option/auto-prune`));
},
/**
* Set the auto-prune orphaned recipients setting for a company.
*
* @param {string} id - company ID
* @param {boolean} enabled
* @returns {Promise<ApiResponse>}
*/
setAutoPrune: async (id, enabled) => {
return await postJSON(this.getPath(`/company/${id}/option/auto-prune`), {
enabled: enabled
});
}
};
@@ -2267,6 +2291,27 @@ export class API {
return await getJSON(this.getPath(`/option/${key}`));
},
/**
* Get the global auto-prune orphaned recipients setting.
* Returns the full option including per-company entries.
*
* @returns {Promise<ApiResponse>}
*/
getAutoPrune: async () => {
return await getJSON(this.getPath(`/option/auto-prune`));
},
/**
* Set the global auto-prune orphaned recipients setting.
* The full option object (including per-company entries) must be supplied.
*
* @param {{ enabled: boolean, companies?: string[] }} option
* @returns {Promise<ApiResponse>}
*/
setAutoPrune: async (option) => {
return await postJSON(this.getPath(`/option/auto-prune`), option);
},
/**
* Set setting by key and value.
*
+77 -1
View File
@@ -25,6 +25,8 @@
// bindings
let form = null;
let companyAutoPruneEnabled = false;
let companyAutoPruneEnabledOriginal = false;
const formValues = {
name: null,
comment: null
@@ -151,6 +153,9 @@
modalError = res.error;
return;
}
if (companyAutoPruneEnabled !== companyAutoPruneEnabledOriginal) {
await saveCompanyAutoPrune(formValues.id, companyAutoPruneEnabled);
}
addToast('Company updated', 'Success');
closeUpdateModal();
} catch (e) {
@@ -160,6 +165,18 @@
refreshCompanies();
};
const saveCompanyAutoPrune = async (id, enabled) => {
try {
const res = await api.company.setAutoPrune(id, enabled);
if (!res.success) {
addToast('Failed to save auto-prune setting', 'Error');
}
} catch (e) {
addToast('Failed to save auto-prune setting', 'Error');
console.error('failed to save company auto-prune', e);
}
};
const openDeleteAlert = async (company) => {
isDeleteAlertVisible = true;
deleteValues.id = company.id;
@@ -216,6 +233,13 @@
formValues.id = company.id;
formValues.name = company.name;
formValues.comment = company.comment || null;
try {
const optRes = await api.company.getAutoPrune(id);
companyAutoPruneEnabled = optRes.success && optRes.data?.enabled === true;
companyAutoPruneEnabledOriginal = companyAutoPruneEnabled;
} catch (_) {
companyAutoPruneEnabled = false;
}
isModalVisible = true;
} catch (e) {
addToast('Failed to get company', 'Error');
@@ -232,6 +256,8 @@
formValues.id = null;
formValues.name = null;
formValues.comment = null;
companyAutoPruneEnabled = false;
companyAutoPruneEnabledOriginal = false;
form.reset();
};
@@ -383,11 +409,61 @@
id="company-comment"
bind:value={formValues.comment}
maxlength={1000000}
rows="20"
rows="8"
placeholder="Add notes about this company..."
class="w-full p-4 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<div class="flex items-center mb-2">
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Auto-Prune Orphaned Recipients
</p>
</div>
<div class="inline-flex flex-col space-y-2 min-w-64 mb-4">
<label
class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors {companyAutoPruneEnabled
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-600'
: 'border-gray-300 dark:border-gray-600'}"
>
<input
type="radio"
checked={companyAutoPruneEnabled}
on:change={() => (companyAutoPruneEnabled = true)}
class="mt-0.5 w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:ring-2"
/>
<div class="text-left flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block"
>Enabled</span
>
<span class="text-xs text-gray-500 dark:text-gray-400 block mt-0.5"
>Orphaned recipients are deleted automatically each hour</span
>
</div>
</label>
<label
class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors {!companyAutoPruneEnabled
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-600'
: 'border-gray-300 dark:border-gray-600'}"
>
<input
type="radio"
checked={!companyAutoPruneEnabled}
on:change={() => (companyAutoPruneEnabled = false)}
class="mt-0.5 w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:ring-2"
/>
<div class="text-left flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block"
>Disabled</span
>
<span class="text-xs text-gray-500 dark:text-gray-400 block mt-0.5"
>Orphaned recipients are kept until manually deleted</span
>
</div>
</label>
</div>
</div>
</div>
<FormError message={modalError} />
@@ -424,6 +424,7 @@
<div class="flex gap-3">
<BigButton on:click={openCreateModal}>New recipient</BigButton>
<BigButton on:click={openImportModal}>Import from CSV</BigButton>
<BigButton on:click={() => goto('/recipient/orphaned/')}>View Orphaned</BigButton>
</div>
<Table
isGhost={isRecipientsTableLoading}
@@ -341,7 +341,6 @@
<div class="flex gap-3">
<BigButton on:click={openCreateModal}>New group</BigButton>
<BigButton on:click={openDynamicCreateModal}>New dynamic group</BigButton>
<BigButton on:click={() => goto('/recipient/orphaned/')}>View Orphaned</BigButton>
</div>
<Table
columns={[
+98
View File
@@ -62,6 +62,11 @@
let isSSOEnabled = false;
let isSSODeleteAlertVisible = false;
// auto-prune settings
let autoPruneOption = { enabled: false, companies: [] };
let autoPruneEnabled = false;
let autoPruneError = '';
// Import functionality
let importError = '';
let isImportSubmitting = false;
@@ -108,6 +113,7 @@
await refreshUpdateCached();
await refreshBackupList();
await refreshDisplayMode();
await refreshAutoPrune();
if (!ssoSettingsFormValues.redirectURL) {
ssoSettingsFormValues.redirectURL = `${location.origin}/api/v1/sso/entra-id/auth`;
}
@@ -117,6 +123,37 @@
});
// component logic
async function refreshAutoPrune() {
try {
const res = await api.option.getAutoPrune();
if (res.success) {
autoPruneOption = res.data;
autoPruneEnabled = res.data.enabled === true;
}
} catch (e) {
console.error('failed to load auto-prune setting', e);
}
}
async function setAutoPruneValue(enabled) {
autoPruneError = '';
// read-modify-write: preserve per-company entries
const updated = { ...autoPruneOption, enabled };
try {
const res = await api.option.setAutoPrune(updated);
if (!res.success) {
autoPruneError = res.error;
return;
}
autoPruneOption = updated;
autoPruneEnabled = enabled;
addToast('Auto-prune setting saved', 'Success');
} catch (e) {
autoPruneError = 'Failed to save auto-prune setting';
console.error('failed to set auto-prune setting', e);
}
}
async function refreshSettings() {
try {
const res = immediateResponseHandler(await api.option.get('max_file_upload_size_mb'));
@@ -579,6 +616,67 @@
</div>
</div>
<!-- Auto-Prune Recipients Card -->
<div
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-sm dark:shadow-gray-900/50 border border-gray-100 dark:border-gray-700 min-h-[300px] flex flex-col transition-colors duration-200"
>
<h2
class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-6 transition-colors duration-200"
>
Auto-Prune Recipients
</h2>
<div class="flex flex-col h-full">
<div class="space-y-4">
<p class="text-gray-600 dark:text-gray-300 text-sm transition-colors duration-200">
Automatically delete orphaned recipients (not in any group) on a hourly schedule.
</p>
<div class="space-y-3">
<label
class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors {autoPruneEnabled
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-600'
: 'border-gray-300 dark:border-gray-600'}"
>
<input
type="radio"
checked={autoPruneEnabled}
on:change={() => setAutoPruneValue(true)}
class="mt-0.5 w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:ring-2"
/>
<div class="text-left flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block">
Enabled
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
Orphaned recipients are deleted automatically each hour
</span>
</div>
</label>
<label
class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors {!autoPruneEnabled
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-600'
: 'border-gray-300 dark:border-gray-600'}"
>
<input
type="radio"
checked={!autoPruneEnabled}
on:change={() => setAutoPruneValue(false)}
class="mt-0.5 w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:ring-2"
/>
<div class="text-left flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block">
Disabled
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
Orphaned recipients are kept until manually deleted
</span>
</div>
</label>
</div>
<FormError message={autoPruneError} />
</div>
</div>
</div>
<!-- General Settings Card -->
<div
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-sm dark:shadow-gray-900/50 border border-gray-100 dark:border-gray-700 min-h-[300px] flex flex-col transition-colors duration-200"