Restore settings preferences and simplify services editor

This commit is contained in:
2026-04-02 01:25:38 +03:00
parent 91856beec9
commit f5ae311a82
16 changed files with 934 additions and 333 deletions

View File

@@ -69,13 +69,13 @@ describe('panel api', () => {
expect(typeof response.body.expiresAt).toBe('string');
});
it('rejects user creation against a non-assignable service', async () => {
it('rejects user creation against a disabled service', async () => {
const app = await createTestApp();
const token = await authorize(app);
const response = await request(app).post('/api/users').set('Authorization', `Bearer ${token}`).send({
username: 'bad-admin-user',
username: 'bad-proxy-user',
password: 'secret123',
serviceId: 'admin',
serviceId: 'proxy',
quotaMb: 100,
});
@@ -132,6 +132,26 @@ describe('panel api', () => {
expect(response.body.error).toMatch(/night-shift/i);
});
it('removes linked users when a service is deleted from settings', async () => {
const app = await createTestApp();
const token = await authorize(app);
const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`);
const system = createSystemPayload(initial.body);
system.services = system.services.filter((service) => service.id !== 'socks-main');
const response = await request(app).put('/api/system').set('Authorization', `Bearer ${token}`).send(system);
expect(response.status).toBe(200);
expect(response.body.userRecords.some((entry: { username: string }) => entry.username === 'night-shift')).toBe(
false,
);
expect(response.body.userRecords.some((entry: { username: string }) => entry.username === 'ops-east')).toBe(
false,
);
expect(response.body.service.lastEvent).toMatch(/removed 2 linked users/i);
});
it('updates system settings and regenerates the rendered config', async () => {
const app = await createTestApp();
const token = await authorize(app);

View File

@@ -129,8 +129,23 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
app.put('/api/system', async (request, response, next) => {
try {
const state = await store.read();
state.system = validateSystemInput(request.body as Partial<UpdateSystemInput>, state.userRecords);
state.service.lastEvent = 'System configuration updated from panel';
const requestedSystem = request.body as Partial<UpdateSystemInput>;
const nextServiceIds = new Set(
(Array.isArray(requestedSystem.services) ? requestedSystem.services : []).map((service) => service.id),
);
const removedServiceIds = new Set(
state.system.services
.map((service) => service.id)
.filter((serviceId) => !nextServiceIds.has(serviceId)),
);
const removedUsers = state.userRecords.filter((user) => removedServiceIds.has(user.serviceId));
state.userRecords = state.userRecords.filter((user) => !removedServiceIds.has(user.serviceId));
state.system = validateSystemInput(requestedSystem, state.userRecords);
state.service.lastEvent =
removedUsers.length > 0
? `System configuration updated from panel and removed ${removedUsers.length} linked users`
: 'System configuration updated from panel';
await persistRuntimeMutation(store, runtime, state, runtimePaths);
response.json(await getSnapshot(store, runtime, runtimePaths));
} catch (error) {

View File

@@ -15,7 +15,7 @@ describe('render3proxyConfig', () => {
expect(config).toContain('socks -p1080 -u2');
expect(config).toContain('socks -p2080 -u2');
expect(config).toContain('admin -p8081 -s');
expect(config).not.toContain('admin -p');
expect(config).toContain('allow night-shift,ops-east');
expect(config).toContain('allow lab-unlimited,burst-user');
});

View File

@@ -198,10 +198,6 @@ function renderUserCredential(user: ProxyUserRecord): string {
}
function renderServiceCommand(service: ProxyServiceRecord): string {
if (service.command === 'admin') {
return `admin -p${service.port} -s`;
}
if (service.command === 'socks') {
return `socks -p${service.port} -u2`;
}

View File

@@ -11,7 +11,14 @@ export class StateStore {
try {
const raw = await fs.readFile(this.statePath, 'utf8');
return JSON.parse(raw) as ControlPlaneState;
const state = JSON.parse(raw) as ControlPlaneState;
const migrated = migrateLegacyAdminServices(state);
if (migrated !== state) {
await this.write(migrated);
}
return migrated;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
@@ -28,3 +35,28 @@ export class StateStore {
await fs.writeFile(this.statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
}
function migrateLegacyAdminServices(state: ControlPlaneState): ControlPlaneState {
const legacyServiceIds = new Set(
state.system.services
.filter((service) => (service as { command?: unknown }).command === 'admin')
.map((service) => service.id),
);
if (legacyServiceIds.size === 0) {
return state;
}
return {
...state,
service: {
...state.service,
lastEvent: 'Legacy admin service removed from stored panel state',
},
userRecords: state.userRecords.filter((user) => !legacyServiceIds.has(user.serviceId)),
system: {
...state.system,
services: state.system.services.filter((service) => !legacyServiceIds.has(service.id)),
},
};
}