settingsPage Init selfhost (#4734)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
Anthony Stirling
2025-10-28 14:47:41 +00:00
committed by GitHub
parent d2b38ef4b8
commit d0c5d74471
68 changed files with 9133 additions and 282 deletions

View File

@@ -0,0 +1,166 @@
/**
* Helper utilities for handling settings with pending changes that require restart.
*
* Backend returns settings in this format:
* {
* "enableLogin": false, // Current active value
* "csrfDisabled": true,
* "_pending": { // Optional - only present if there are pending changes
* "enableLogin": true // Value that will be active after restart
* }
* }
*/
export interface SettingsWithPending<T = any> {
_pending?: Partial<T>;
[key: string]: any;
}
/**
* Merge pending changes into the settings object.
* Returns a new object with pending values overlaid on top of current values.
*
* @param settings Settings object from backend (may contain _pending block)
* @returns Merged settings with pending values applied
*/
export function mergePendingSettings<T extends SettingsWithPending>(settings: T): Omit<T, '_pending'> {
if (!settings || !settings._pending) {
// No pending changes, return as-is (without _pending property)
const { _pending, ...rest } = settings || {};
return rest as Omit<T, '_pending'>;
}
// Deep merge pending changes
const merged = deepMerge(settings, settings._pending);
// Remove _pending from result
const { _pending, ...result } = merged;
return result as Omit<T, '_pending'>;
}
/**
* Check if a specific field has a pending change awaiting restart.
*
* @param settings Settings object from backend
* @param fieldPath Dot-notation path to the field (e.g., "oauth2.clientSecret")
* @returns True if field has pending changes
*/
export function isFieldPending<T extends SettingsWithPending>(
settings: T | null | undefined,
fieldPath: string
): boolean {
if (!settings?._pending) {
return false;
}
// Navigate the pending object using dot notation
const value = getNestedValue(settings._pending, fieldPath);
return value !== undefined;
}
/**
* Check if there are any pending changes in the settings.
*
* @param settings Settings object from backend
* @returns True if there are any pending changes
*/
export function hasPendingChanges<T extends SettingsWithPending>(
settings: T | null | undefined
): boolean {
return settings?._pending !== undefined && Object.keys(settings._pending).length > 0;
}
/**
* Get the pending value for a specific field, or undefined if no pending change.
*
* @param settings Settings object from backend
* @param fieldPath Dot-notation path to the field
* @returns Pending value or undefined
*/
export function getPendingValue<T extends SettingsWithPending>(
settings: T | null | undefined,
fieldPath: string
): any {
if (!settings?._pending) {
return undefined;
}
return getNestedValue(settings._pending, fieldPath);
}
/**
* Get the current active value for a field (ignoring pending changes).
*
* @param settings Settings object from backend
* @param fieldPath Dot-notation path to the field
* @returns Current active value
*/
export function getCurrentValue<T extends SettingsWithPending>(
settings: T | null | undefined,
fieldPath: string
): any {
if (!settings) {
return undefined;
}
// Get from settings, ignoring _pending
const { _pending, ...activeSettings } = settings;
return getNestedValue(activeSettings, fieldPath);
}
// ========== Helper Functions ==========
/**
* Deep merge two objects. Second object takes priority.
*/
function deepMerge(target: any, source: any): any {
if (!source) return target;
if (!target) return source;
const result = { ...target };
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key];
const targetValue = result[key];
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
result[key] = deepMerge(targetValue, sourceValue);
} else {
result[key] = sourceValue;
}
}
}
return result;
}
/**
* Get nested value using dot notation.
*/
function getNestedValue(obj: any, path: string): any {
if (!obj || !path) return undefined;
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
current = current[part];
}
return current;
}
/**
* Check if value is a plain object (not array, not null, not Date, etc.)
*/
function isPlainObject(value: any): boolean {
return (
value !== null &&
typeof value === 'object' &&
value.constructor === Object
);
}