diff --git a/webui/src/__tests__/Settings.IdentitySave.test.jsx b/webui/src/__tests__/Settings.IdentitySave.test.jsx
new file mode 100644
index 0000000..2e41b9f
--- /dev/null
+++ b/webui/src/__tests__/Settings.IdentitySave.test.jsx
@@ -0,0 +1,208 @@
+/**
+ * Regression tests for Cell Identity save behaviour in Settings.jsx.
+ *
+ * Covers:
+ * - Save button appears only when identity is dirty
+ * - Save button disabled while availability check in progress (pic_ngo, name changed)
+ * - Save button disabled when name is taken (pic_ngo)
+ * - Save button enabled once availability confirmed (pic_ngo, name changed)
+ * - Auto-save does NOT fire for pic_ngo cell_name changes (requires explicit Save)
+ * - Auto-save DOES fire for ip_range-only changes in pic_ngo mode
+ */
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
+import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
+
+// ── API mocks ─────────────────────────────────────────────────────────────────
+
+const mockGetConfig = vi.fn();
+const mockUpdateConfig = vi.fn();
+const mockListBackups = vi.fn();
+const mockGetStatus = vi.fn();
+const mockCheckName = vi.fn();
+const mockGetCertStatus = vi.fn();
+
+vi.mock('../services/api', () => ({
+ cellAPI: {
+ getConfig: (...a) => mockGetConfig(...a),
+ updateConfig: (...a) => mockUpdateConfig(...a),
+ listBackups: (...a) => mockListBackups(...a),
+ },
+ ddnsAPI: {
+ getStatus: (...a) => mockGetStatus(...a),
+ checkName: (...a) => mockCheckName(...a),
+ },
+ caddyAPI: {
+ getCertStatus: (...a) => mockGetCertStatus(...a),
+ },
+}));
+
+vi.mock('../contexts/ConfigContext', () => ({
+ useConfig: () => ({ domain: 'cell', cell_name: 'pic1', refresh: vi.fn() }),
+}));
+
+vi.mock('../contexts/DraftConfigContext', () => ({
+ useDraftConfig: () => ({
+ registerFlusher: vi.fn(() => vi.fn()),
+ setDirty: vi.fn(),
+ hasDirty: vi.fn(() => false),
+ flushAll: vi.fn(),
+ }),
+}));
+
+vi.mock('../utils/serviceConfig', () => ({
+ PORT_CONFLICT_FIELDS: {},
+ detectPortConflicts: vi.fn(() => ({})),
+ validateServiceConfig: vi.fn(() => ({})),
+ SERVICE_DEFS: [],
+}));
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+function makeCfg(overrides = {}) {
+ return {
+ cell_name: 'pic1',
+ domain: 'cell',
+ ip_range: '172.20.0.0/16',
+ domain_mode: 'pic_ngo',
+ domain_name: 'pic1.pic.ngo',
+ effective_domain: 'pic1.pic.ngo',
+ ddns: { has_token: true },
+ service_configs: {},
+ installed_services: [],
+ ...overrides,
+ };
+}
+
+async function renderSettings() {
+ const { default: Settings } = await import('../pages/Settings.jsx');
+ let result;
+ await act(async () => {
+ result = render( );
+ // Flush the loadAll async chain
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ // Wait until the cell_name input appears (confirms loadAll completed)
+ await waitFor(() => screen.getByDisplayValue('pic1'));
+ return result;
+}
+
+// ── shared beforeEach ─────────────────────────────────────────────────────────
+
+function defaultMocks() {
+ mockGetConfig.mockResolvedValue({ data: makeCfg() });
+ mockUpdateConfig.mockResolvedValue({ data: { warnings: [] } });
+ mockListBackups.mockResolvedValue({ data: [] });
+ mockGetStatus.mockResolvedValue({ data: { registered: true, public_ip: '1.2.3.4', last_ip: '1.2.3.4' } });
+ mockGetCertStatus.mockResolvedValue({ data: null });
+ mockCheckName.mockResolvedValue({ data: { available: true } });
+}
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+describe('Cell Identity — Save button visibility', () => {
+ beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
+ afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });
+
+ it('Save button is absent when identity is clean', async () => {
+ await renderSettings();
+ expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
+ });
+
+ it('Save button appears after editing ip_range', async () => {
+ await renderSettings();
+ fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
+ });
+});
+
+describe('Cell Identity — pic_ngo cell name change gating', () => {
+ beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
+ afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });
+
+ it('Save button is disabled while availability check is in progress', async () => {
+ mockCheckName.mockReturnValue(new Promise(() => {})); // never resolves → stays 'checking'
+ await renderSettings();
+
+ fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
+ await act(async () => { vi.advanceTimersByTime(950); }); // past 900 ms debounce
+
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+ });
+
+ it('Save button is disabled when name is taken', async () => {
+ mockCheckName.mockResolvedValue({ data: { available: false } });
+ await renderSettings();
+
+ fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'taken-name' } });
+ await act(async () => { vi.advanceTimersByTime(950); });
+ await act(async () => { await Promise.resolve(); });
+
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+ });
+
+ it('Save button is enabled once name is confirmed available', async () => {
+ mockCheckName.mockResolvedValue({ data: { available: true } });
+ await renderSettings();
+
+ fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
+ await act(async () => { vi.advanceTimersByTime(950); });
+ await act(async () => { await Promise.resolve(); });
+
+ expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled();
+ });
+
+ it('auto-save does NOT fire for pic_ngo cell_name changes', async () => {
+ mockCheckName.mockResolvedValue({ data: { available: true } });
+ await renderSettings();
+
+ fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
+
+ // Advance well past both debounces (availability check: 900 ms, auto-save: 800 ms)
+ await act(async () => { vi.advanceTimersByTime(2500); });
+ await act(async () => { await Promise.resolve(); });
+
+ expect(mockUpdateConfig).not.toHaveBeenCalled();
+ });
+
+ it('clicking Save calls updateConfig with new cell_name', async () => {
+ mockCheckName.mockResolvedValue({ data: { available: true } });
+ mockGetConfig
+ .mockResolvedValueOnce({ data: makeCfg() })
+ .mockResolvedValue({ data: makeCfg({ cell_name: 'pic2', domain_name: 'pic2.pic.ngo' }) });
+ await renderSettings();
+
+ fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
+ await act(async () => { vi.advanceTimersByTime(950); });
+ await act(async () => { await Promise.resolve(); });
+
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Save' })); });
+ await act(async () => { await Promise.resolve(); });
+
+ expect(mockUpdateConfig).toHaveBeenCalledOnce();
+ expect(mockUpdateConfig.mock.calls[0][0]).toMatchObject({ cell_name: 'pic2' });
+ });
+});
+
+describe('Cell Identity — ip_range auto-save (name unchanged in pic_ngo mode)', () => {
+ beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
+ afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });
+
+ it('auto-save fires after 800 ms when only ip_range changes', async () => {
+ mockGetConfig
+ .mockResolvedValueOnce({ data: makeCfg() })
+ .mockResolvedValue({ data: makeCfg({ ip_range: '10.0.0.0/8' }) });
+ await renderSettings();
+
+ fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });
+
+ await act(async () => { vi.advanceTimersByTime(500); });
+ expect(mockUpdateConfig).not.toHaveBeenCalled(); // not yet
+
+ await act(async () => { vi.advanceTimersByTime(400); }); // total 900 ms
+ await act(async () => { await Promise.resolve(); });
+
+ expect(mockUpdateConfig).toHaveBeenCalledOnce();
+ expect(mockUpdateConfig.mock.calls[0][0]).toMatchObject({ ip_range: '10.0.0.0/8' });
+ });
+});
diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx
index 35c01d5..884ab7c 100644
--- a/webui/src/pages/Settings.jsx
+++ b/webui/src/pages/Settings.jsx
@@ -340,7 +340,6 @@ function Settings() {
const [identityDirty, setIdentityDirty] = useState(false);
const [loadedCellName, setLoadedCellName] = useState('');
const [effectiveDomain, setEffectiveDomain] = useState('');
- const [showAdvancedZone, setShowAdvancedZone] = useState(false);
// DDNS
const [domainMode, setDomainMode] = useState('lan');
@@ -842,28 +841,6 @@ function Settings() {
managed by DDNS
-
-
setShowAdvancedZone((v) => !v)}
- className="text-xs text-gray-400 hover:text-gray-600 flex items-center gap-1"
- >
- {showAdvancedZone ? : }
- Advanced
-
- {showAdvancedZone && (
-
- 255 ? 'Domain must be 255 characters or fewer' : null} hint="Used for LAN DNS — most users should not change this">
- { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
- placeholder="cell"
- maxLength={255}
- />
-
-
- )}
-
)}
@@ -873,6 +850,20 @@ function Settings() {
placeholder="172.20.0.0/16"
/>
+ {identityDirty && (
+
+
+ Save
+
+
+ )}