diff --git a/e2e/fixtures/admin-login.ts b/e2e/fixtures/admin-login.ts index 2a868b13..f4f8d1d6 100644 --- a/e2e/fixtures/admin-login.ts +++ b/e2e/fixtures/admin-login.ts @@ -10,7 +10,11 @@ export const ADMIN_PASSWORD = * and userData in-memory; a synthetic localStorage seed does not work * because no code path hydrates userData from storage at bootstrap. */ -export async function loginAsAdmin(page: Page) { +export async function loginAsAdmin( + page: Page, + adminEmail = ADMIN_EMAIL, + adminPassword = ADMIN_PASSWORD +) { await page.context().clearCookies(); await page.goto('/dreamfactory/dist/#/auth/login'); // Labels come from the userManagement i18n bundle — "Enter Email" / @@ -18,8 +22,8 @@ export async function loginAsAdmin(page: Page) { // language-agnostic. const email = page.locator('input').first(); await expect(email).toBeVisible({ timeout: 15_000 }); - await email.fill(ADMIN_EMAIL); - await page.locator('input').nth(1).fill(ADMIN_PASSWORD); + await email.fill(adminEmail); + await page.locator('input').nth(1).fill(adminPassword); const loginReq = page.waitForResponse( r => diff --git a/e2e/fixtures/df-api.ts b/e2e/fixtures/df-api.ts index ef4cc6d0..6c8fcb67 100644 --- a/e2e/fixtures/df-api.ts +++ b/e2e/fixtures/df-api.ts @@ -141,6 +141,82 @@ export class DfApi { } } + /** Effective log level as system/environment reports it. */ + async getEnvironmentLogLevel(): Promise { + const resp = await this.ctx.get('/api/v2/system/environment', { + headers: this.headers(), + }); + expect(resp.ok()).toBe(true); + return (await resp.json()).platform.log_level; + } + + /** Remove a server-setting override (back to the .env value). */ + async resetServerSetting(name: string): Promise { + const resp = await this.ctx.delete( + `/api/v2/system/server_setting/${name}`, + { headers: this.headers() } + ); + expect(resp.ok(), `DELETE server_setting/${name} -> ${resp.status()}`).toBe( + true + ); + } + + /** Create a restricted admin limited to the given tabs; returns the admin id. */ + async createRestrictedAdmin( + email: string, + password: string, + tabs: string[] + ): Promise { + const resp = await this.ctx.post('/api/v2/system/admin', { + headers: this.headers(), + data: { + resource: [ + { + email, + password, + name: 'E2E Restricted', + first_name: 'E2E', + last_name: 'Restricted', + is_active: true, + is_restricted_admin: true, + access_by_tabs: tabs, + }, + ], + }, + }); + expect( + resp.ok(), + `POST admin -> HTTP ${resp.status()}: ${await resp.text()}` + ).toBe(true); + return (await resp.json()).resource[0].id; + } + + /** API key of the admin UI app; a role only applies to requests that carry it. */ + async getAdminAppApiKey(): Promise { + const resp = await this.ctx.get( + '/api/v2/system/app?filter=name%3Dadmin&fields=api_key', + { headers: this.headers() } + ); + expect(resp.ok()).toBe(true); + return (await resp.json()).resource[0].api_key; + } + + /** Delete an admin and the role created for it (restricted admins get "'s role"). */ + async deleteRestrictedAdmin(id: number, email: string): Promise { + await this.ctx.delete(`/api/v2/system/admin/${id}`, { + headers: this.headers(), + }); + const roles = await this.ctx.get( + `/api/v2/system/role?filter=${encodeURIComponent(`name=${email}'s role`)}`, + { headers: this.headers() } + ); + for (const r of (await roles.json())?.resource ?? []) { + await this.ctx.delete(`/api/v2/system/role/${r.id}`, { + headers: this.headers(), + }); + } + } + /** Delete every service whose name starts with the e2e prefix. */ async deleteByNamePrefix(prefix: string = E2E_SERVICE_PREFIX): Promise { const rows = await this.listServices(); diff --git a/e2e/server-settings.spec.ts b/e2e/server-settings.spec.ts new file mode 100644 index 00000000..3e9abc88 --- /dev/null +++ b/e2e/server-settings.spec.ts @@ -0,0 +1,119 @@ +import { APIRequestContext, expect, request, test } from '@playwright/test'; +import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login'; +import { DfApi } from './fixtures/df-api'; + +/** + * Server settings (#552): override the log level from the UI, see the + * override badge and the new System Info value, then reset to .env. + * Non-destructive: the override is always removed in afterAll. + */ +test.describe('Server settings', () => { + let ctx: APIRequestContext; + let api: DfApi; + let original: string; + + test.beforeAll(async ({ baseURL }) => { + ctx = await request.newContext({ baseURL }); + api = await DfApi.login(ctx); + original = await api.getEnvironmentLogLevel(); + }); + + test.afterAll(async () => { + await api.resetServerSetting('log_level'); + await ctx.dispose(); + }); + + test('change the log level, then reset it to .env', async ({ page }) => { + const target = original === 'error' ? 'debug' : 'error'; + + await loginAsAdmin(page); + await waitForAppReady(page); + // Hash-only change: no reload, so the in-memory login survives. + await page.goto('/dreamfactory/dist/#/system-settings/config/server-settings'); + + // sidenav label and page header must be translated, not raw i18n keys + await expect(page.locator('body')).toContainText('Server Settings'); + await expect(page.locator('body')).not.toContainText(/nav\.system-settings/); + + const row = page.getByTestId('server-setting-log_level'); + await expect(row.getByTestId('server-setting-source')).toHaveText(/\.env/); + await expect(row.getByTestId('server-setting-reset')).toBeDisabled(); + + await row.getByTestId('server-setting-select').click(); + await page.getByRole('option', { name: target, exact: true }).click(); + await row.getByTestId('server-setting-save').click(); + + await expect(row.getByTestId('server-setting-source')).toHaveText( + /override/ + ); + await expect(page.getByTestId('server-settings-view-info')).toBeVisible(); + expect(await api.getEnvironmentLogLevel()).toBe(target); + + await row.getByTestId('server-setting-reset').click(); + await page.getByTestId('confirm-dialog-confirm').click(); + + await expect(row.getByTestId('server-setting-source')).toHaveText(/\.env/); + expect(await api.getEnvironmentLogLevel()).toBe(original); + }); + + test.describe('restricted admins', () => { + const password = 'E2e-restricted-pass1!'; + const created: { id: number; email: string }[] = []; + + const makeAdmin = async (tabs: string[]) => { + const email = `e2e-ss-${tabs[0]}-${Date.now()}@example.com`; + created.push({ + id: await api.createRestrictedAdmin(email, password, tabs), + email, + }); + return email; + }; + + test.afterAll(async () => { + for (const a of created) { + await api.deleteRestrictedAdmin(a.id, a.email); + } + }); + + test('without the Config tab: API 403, no nav entry, deep link lands Home', async ({ + page, + }) => { + const email = await makeAdmin(['apps']); + + const session = await ctx.post('/api/v2/system/admin/session', { + data: { email, password }, + }); + expect(session.ok()).toBe(true); + const denied = await ctx.get('/api/v2/system/server_setting', { + headers: { + 'X-DreamFactory-API-Key': await api.getAdminAppApiKey(), + 'X-DreamFactory-Session-Token': (await session.json()) + .session_token, + }, + }); + expect(denied.status()).toBe(403); + + await loginAsAdmin(page, email, password); + await waitForAppReady(page); + await page.goto( + '/dreamfactory/dist/#/system-settings/config/server-settings' + ); + await expect(page).toHaveURL(/#\/home/); + await expect(page.getByTestId('server-setting-log_level')).toHaveCount(0); + await expect( + page.getByRole('link', { name: 'Server Settings' }) + ).toHaveCount(0); + }); + + test('with the Config tab: opens the page', async ({ page }) => { + const email = await makeAdmin(['config']); + + await loginAsAdmin(page, email, password); + await waitForAppReady(page); + await page.goto( + '/dreamfactory/dist/#/system-settings/config/server-settings' + ); + await expect(page.getByTestId('server-setting-log_level')).toBeVisible(); + }); + }); +}); diff --git a/jest.config.ci.js b/jest.config.ci.js index 302ad813..38b87652 100644 --- a/jest.config.ci.js +++ b/jest.config.ci.js @@ -66,5 +66,11 @@ module.exports = { '/src/app/shared/components/df-secret/df-secret.component.spec.ts', // 2026-10-05 searchable pickers (#534): dfSearch filter + componentTypes. '/src/app/shared/components/df-select-search/df-select-search.spec.ts', + // Runtime server settings (#552): service, access rule + guard, page, nav link. + '/src/app/shared/utilities/server-settings-access.spec.ts', + '/src/app/shared/services/df-server-settings.service.spec.ts', + '/src/app/shared/guards/admin.guard.spec.ts', + '/src/app/adf-config/df-server-settings/df-server-settings.component.spec.ts', + '/src/app/adf-config/df-system-info/system-info-link.spec.ts', ], }; diff --git a/src/app/adf-config/df-server-settings/df-server-settings.component.html b/src/app/adf-config/df-server-settings/df-server-settings.component.html new file mode 100644 index 00000000..9939d88b --- /dev/null +++ b/src/app/adf-config/df-server-settings/df-server-settings.component.html @@ -0,0 +1,70 @@ +
+

{{ 'serverSettings.description' | transloco }}

+

{{ 'serverSettings.hint' | transloco }}

+ + +

+ {{ 'serverSettings.saved' | transloco }} + {{ + 'serverSettings.viewSystemInfo' | transloco + }} +

+ +

{{ 'serverSettings.loading' | transloco }}

+

+ {{ 'serverSettings.empty' | transloco }} +

+ +
+
+
+ {{ row.setting.label }} + + {{ + (row.setting.source === 'override' + ? 'serverSettings.sourceOverride' + : 'serverSettings.sourceEnv' + ) | transloco + }} + +
+
+ + {{ + v + }} + + + +
+ {{ + 'serverSettings.envValue' | transloco: { value: row.setting.envValue } + }} +
+
+
diff --git a/src/app/adf-config/df-server-settings/df-server-settings.component.scss b/src/app/adf-config/df-server-settings/df-server-settings.component.scss new file mode 100644 index 00000000..023c7470 --- /dev/null +++ b/src/app/adf-config/df-server-settings/df-server-settings.component.scss @@ -0,0 +1,56 @@ +.server-settings-container { + > p { + max-width: 720px; + margin: 0 0 12px; + color: var(--df-text-2); + font-size: 1.4rem; + line-height: 1.5; + } + + .error { + color: var(--df-danger, #b3261e); + } + + .settings-list { + max-height: 60vh; + overflow-y: auto; + } + + .setting-row { + max-width: 720px; + margin-bottom: 16px; + padding: 16px; + border: 1px solid var(--df-border, rgba(128, 128, 128, 0.3)); + border-radius: 8px; + } + + .setting-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + } + + .badge { + padding: 2px 8px; + border-radius: 10px; + font-size: 1.2rem; + border: 1px solid var(--df-border, rgba(128, 128, 128, 0.4)); + + &.override { + font-weight: 600; + } + } + + .setting-body { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 8px; + + mat-select { + width: 200px; + } + } +} diff --git a/src/app/adf-config/df-server-settings/df-server-settings.component.spec.ts b/src/app/adf-config/df-server-settings/df-server-settings.component.spec.ts new file mode 100644 index 00000000..5143351f --- /dev/null +++ b/src/app/adf-config/df-server-settings/df-server-settings.component.spec.ts @@ -0,0 +1,84 @@ +import { of, throwError } from 'rxjs'; +import { DfServerSettingsComponent } from './df-server-settings.component'; +import { ServerSetting } from '../../shared/types/server-setting'; + +const row = (over: Partial = {}): ServerSetting => ({ + name: 'log_level', + label: 'Log level', + value: 'info', + envValue: 'info', + source: 'env', + allowedValues: ['debug', 'info', 'error'], + ...over, +}); + +// Pure-class spec: the transloco TestBed is quarantined under ESM. +describe('DfServerSettingsComponent', () => { + let service: { getAll: jest.Mock; update: jest.Mock; reset: jest.Mock }; + let dialog: { open: jest.Mock }; + let component: DfServerSettingsComponent; + + const make = () => + new DfServerSettingsComponent(service as never, dialog as never); + + beforeEach(() => { + service = { + getAll: jest.fn(() => of([row()])), + update: jest.fn(), + reset: jest.fn(), + }; + dialog = { open: jest.fn(() => ({ afterClosed: () => of(true) })) }; + component = make(); + component.ngOnInit(); + }); + + it('maps rows with a draft equal to the current value', () => { + expect(component.rows).toHaveLength(1); + expect(component.rows[0].draft).toBe('info'); + expect(component.loading).toBe(false); + }); + + it('disables reset while the source is env', () => { + expect(component.canReset(component.rows[0])).toBe(false); + component.rows[0].setting = row({ source: 'override', value: 'error' }); + expect(component.canReset(component.rows[0])).toBe(true); + }); + + it('save calls update then swaps the row', () => { + service.update.mockReturnValue( + of(row({ value: 'error', source: 'override' })) + ); + component.rows[0].draft = 'error'; + component.save(component.rows[0]); + expect(service.update).toHaveBeenCalledWith('log_level', 'error'); + expect(component.rows[0].setting.source).toBe('override'); + expect(component.rows[0].setting.value).toBe('error'); + expect(component.saved).toBe(true); + }); + + it('a failed save keeps the old value and sets the error', () => { + service.update.mockReturnValue(throwError(() => new Error('boom'))); + component.rows[0].draft = 'error'; + component.save(component.rows[0]); + expect(component.rows[0].setting.value).toBe('info'); + expect(component.rows[0].draft).toBe('info'); + expect(component.error).toBeTruthy(); + }); + + it('reset asks for confirmation, then swaps the row back to env', () => { + component.rows[0].setting = row({ source: 'override', value: 'error' }); + service.reset.mockReturnValue(of(row())); + component.reset(component.rows[0]); + expect(dialog.open).toHaveBeenCalled(); + expect(service.reset).toHaveBeenCalledWith('log_level'); + expect(component.rows[0].setting.source).toBe('env'); + }); + + it('sets the error when loading fails', () => { + service.getAll.mockReturnValue(throwError(() => new Error('nope'))); + component = make(); + component.ngOnInit(); + expect(component.error).toBeTruthy(); + expect(component.loading).toBe(false); + }); +}); diff --git a/src/app/adf-config/df-server-settings/df-server-settings.component.ts b/src/app/adf-config/df-server-settings/df-server-settings.component.ts new file mode 100644 index 00000000..f720cf27 --- /dev/null +++ b/src/app/adf-config/df-server-settings/df-server-settings.component.ts @@ -0,0 +1,109 @@ +import { NgFor, NgIf } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatSelectModule } from '@angular/material/select'; +import { RouterLink } from '@angular/router'; +import { TranslocoPipe } from '@ngneat/transloco'; +import { DfConfirmDialogComponent } from '../../shared/components/df-confirm-dialog/df-confirm-dialog.component'; +import { DfServerSettingsService } from '../../shared/services/df-server-settings.service'; +import { ROUTES } from '../../shared/types/routes'; +import { ServerSetting } from '../../shared/types/server-setting'; + +interface SettingRow { + setting: ServerSetting; + draft: string; +} + +@Component({ + selector: 'df-server-settings', + templateUrl: './df-server-settings.component.html', + styleUrls: ['./df-server-settings.component.scss'], + standalone: true, + imports: [ + FormsModule, + MatButtonModule, + MatDialogModule, + MatSelectModule, + NgFor, + NgIf, + RouterLink, + TranslocoPipe, + ], +}) +export class DfServerSettingsComponent implements OnInit { + rows: SettingRow[] = []; + loading = true; + error: string | null = null; + saved = false; + systemInfoLink = `/${ROUTES.SYSTEM_SETTINGS}/${ROUTES.CONFIG}/${ROUTES.SYSTEM_INFO}`; + + constructor( + private service: DfServerSettingsService, + private dialog: MatDialog + ) {} + + ngOnInit() { + this.service.getAll().subscribe({ + next: settings => { + this.rows = settings.map(setting => ({ + setting, + draft: setting.value, + })); + this.loading = false; + }, + error: () => { + this.error = 'serverSettings.loadError'; + this.loading = false; + }, + }); + } + + canReset(row: SettingRow): boolean { + return row.setting.source === 'override'; + } + + save(row: SettingRow) { + this.error = null; + this.saved = false; + this.service.update(row.setting.name, row.draft).subscribe({ + next: setting => { + this.swap(row, setting); + this.saved = true; + }, + error: () => { + row.draft = row.setting.value; + this.error = 'serverSettings.saveError'; + }, + }); + } + + reset(row: SettingRow) { + this.error = null; + this.saved = false; + this.dialog + .open(DfConfirmDialogComponent, { + data: { + title: 'serverSettings.resetTitle', + message: 'serverSettings.resetMessage', + }, + }) + .afterClosed() + .subscribe(confirmed => { + if (!confirmed) return; + this.service.reset(row.setting.name).subscribe({ + next: setting => { + this.swap(row, setting); + this.saved = true; + }, + error: () => (this.error = 'serverSettings.saveError'), + }); + }); + } + + private swap(row: SettingRow, setting: ServerSetting) { + row.setting = setting; + row.draft = setting.value; + } +} diff --git a/src/app/adf-config/df-system-info/df-system-info.component.html b/src/app/adf-config/df-system-info/df-system-info.component.html index e525e17b..ae7125bc 100644 --- a/src/app/adf-config/df-system-info/df-system-info.component.html +++ b/src/app/adf-config/df-system-info/df-system-info.component.html @@ -56,6 +56,12 @@

DreamFactory {{ 'systemInfo.instance.instance' | transloco }}

  • {{ 'systemInfo.instance.logLevel' | transloco }}: {{ environment.platform?.logLevel }} + {{ 'systemInfo.instance.logLevelChange' | transloco }}
  • {{ 'systemInfo.instance.cacheDriver' | transloco }}: diff --git a/src/app/adf-config/df-system-info/df-system-info.component.ts b/src/app/adf-config/df-system-info/df-system-info.component.ts index 50b57bdf..bc0fa7c5 100644 --- a/src/app/adf-config/df-system-info/df-system-info.component.ts +++ b/src/app/adf-config/df-system-info/df-system-info.component.ts @@ -1,6 +1,7 @@ import { AsyncPipe, DatePipe, NgFor, NgIf } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { DfBreakpointService } from '../../shared/services/df-breakpoint.service'; +import { RouterLink } from '@angular/router'; import { TranslocoPipe } from '@ngneat/transloco'; import { UntilDestroy } from '@ngneat/until-destroy'; import { DfSystemConfigDataService } from 'src/app/shared/services/df-system-config-data.service'; @@ -8,6 +9,9 @@ import { CheckResponse } from 'src/app/shared/types/check'; import { DfLicenseCheckService } from 'src/app/shared/services/df-license-check.service'; import { TrialInfo } from 'src/app/shared/types/trial'; import { trialFromEnvironment } from 'src/app/shared/utilities/trial'; +import { DfUserDataService } from 'src/app/shared/services/df-user-data.service'; +import { ROUTES } from 'src/app/shared/types/routes'; +import { canManageServerSettings } from 'src/app/shared/utilities/server-settings-access'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -15,7 +19,7 @@ import { trialFromEnvironment } from 'src/app/shared/utilities/trial'; templateUrl: './df-system-info.component.html', styleUrls: ['./df-system-info.component.scss'], standalone: true, - imports: [AsyncPipe, DatePipe, NgFor, TranslocoPipe, NgIf], + imports: [AsyncPipe, DatePipe, NgFor, RouterLink, TranslocoPipe, NgIf], }) export class DfSystemInfoComponent implements OnInit { environment = this.systemConfigDataService.environment; @@ -24,9 +28,21 @@ export class DfSystemInfoComponent implements OnInit { constructor( public breakpointService: DfBreakpointService, private systemConfigDataService: DfSystemConfigDataService, - private licenseCheckService: DfLicenseCheckService + private licenseCheckService: DfLicenseCheckService, + private userDataService: DfUserDataService ) {} + serverSettingsLink = `/${ROUTES.SYSTEM_SETTINGS}/${ROUTES.CONFIG}/${ROUTES.SERVER_SETTINGS}`; + + private tabs: Array = []; + private tabsSub = this.userDataService.restrictedAccess$.subscribe( + tabs => (this.tabs = tabs) + ); + + get canEditServerSettings(): boolean { + return canManageServerSettings(this.userDataService.userData, this.tabs); + } + /** Docker trial block (platform.trial, or top-level pre-login); null otherwise. */ get trial(): TrialInfo | null { return trialFromEnvironment(this.environment); diff --git a/src/app/adf-config/df-system-info/system-info-link.spec.ts b/src/app/adf-config/df-system-info/system-info-link.spec.ts new file mode 100644 index 00000000..058b4907 --- /dev/null +++ b/src/app/adf-config/df-system-info/system-info-link.spec.ts @@ -0,0 +1,28 @@ +import { of } from 'rxjs'; +import { DfSystemInfoComponent } from './df-system-info.component'; + +// Pure-class spec (df-system-info.component.spec.ts is quarantined). +describe('DfSystemInfoComponent log level link', () => { + const make = (user: unknown, tabs: string[] = []) => + new DfSystemInfoComponent( + {} as never, + { environment: {} } as never, + {} as never, + { userData: user, restrictedAccess$: of(tabs) } as never + ); + + it('shows the Change link to full admins', () => { + expect(make({ isSysAdmin: true }).canEditServerSettings).toBe(true); + }); + + it('shows the Change link to a restricted admin with the config tab only', () => { + const user = { isSysAdmin: true, roleId: 3 }; + expect(make(user, ['config']).canEditServerSettings).toBe(true); + expect(make(user, ['apps']).canEditServerSettings).toBe(false); + }); + + it('hides the Change link from non-admins and a missing session', () => { + expect(make({ isSysAdmin: false }).canEditServerSettings).toBe(false); + expect(make(null).canEditServerSettings).toBe(false); + }); +}); diff --git a/src/app/adf-section-overviews/df-system-overview.component.ts b/src/app/adf-section-overviews/df-system-overview.component.ts index 006c6f5c..526523ee 100644 --- a/src/app/adf-section-overviews/df-system-overview.component.ts +++ b/src/app/adf-section-overviews/df-system-overview.component.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit, inject } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { catchError, map, of } from 'rxjs'; import { DfSectionLandingComponent, @@ -14,7 +15,9 @@ import { GenericListResponse } from '../shared/types/generic-http'; import { ROUTES } from '../shared/types/routes'; import { ServiceType } from '../shared/types/service'; import { DfSystemConfigDataService } from '../shared/services/df-system-config-data.service'; +import { DfUserDataService } from '../shared/services/df-user-data.service'; import { Environment } from '../shared/types/system'; +import { canManageServerSettings } from '../shared/utilities/server-settings-access'; @Component({ selector: 'df-system-overview', @@ -36,6 +39,9 @@ import { Environment } from '../shared/types/system'; export class DfSystemOverviewComponent implements OnInit { private serviceTypeService = inject(SERVICE_TYPE_SERVICE_TOKEN); private systemConfigDataService = inject(DfSystemConfigDataService); + private userDataService = inject(DfUserDataService); + private destroyRef = inject(DestroyRef); + private tabs: Array = []; private serviceTypes: ServiceType[] | null = null; environment = this.systemConfigDataService.environment; @@ -75,6 +81,12 @@ export class DfSystemOverviewComponent implements OnInit { this.systemConfigDataService.environment$.subscribe( environment => (this.environment = environment) ); + this.userDataService.restrictedAccess$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(tabs => { + this.tabs = tabs; + this.memoGroupsTypes = undefined; // rebuild the cards: Server Settings depends on the tabs + }); this.serviceTypeService .getAll>({ limit: 500 }) .pipe( @@ -160,6 +172,19 @@ export class DfSystemOverviewComponent implements OnInit { action: 'View system info', category: 'system', }, + // Same rule as the route guard and the nav entry. + ...(canManageServerSettings(this.userDataService.userData, this.tabs) + ? [ + { + icon: 'tune', + title: 'Server Settings', + text: 'Change the log level at runtime without editing .env or restarting.', + route: `/${ROUTES.SYSTEM_SETTINGS}/${ROUTES.CONFIG}/${ROUTES.SERVER_SETTINGS}`, + action: 'Edit server settings', + category: 'system' as const, + }, + ] + : []), { icon: 'cached', title: 'Cache', diff --git a/src/app/routes.ts b/src/app/routes.ts index c2b4341e..80e43329 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -57,7 +57,7 @@ import { globalLicenseGuard } from './shared/guards/global-license.guard'; import { trialGuard } from './shared/guards/trial.guard'; import { errorGuard } from './shared/guards/error.guard'; import { paywallGuard } from './shared/guards/paywall.guard'; -import { rootAdminGuard } from './shared/guards/admin.guard'; +import { rootAdminGuard, serverSettingsGuard } from './shared/guards/admin.guard'; import { SERVICE_GROUPS } from './shared/constants/serviceGroups'; export const routes: Routes = [ @@ -465,6 +465,15 @@ export const routes: Routes = [ data: checkStatusResolver, }, }, + { + path: ROUTES.SERVER_SETTINGS, + loadComponent: () => + import( + './adf-config/df-server-settings/df-server-settings.component' + ).then(m => m.DfServerSettingsComponent), + providers: [provideTranslocoScope('serverSettings')], + canActivate: [serverSettingsGuard], + }, { path: ROUTES.CORS, children: [ diff --git a/src/app/shared/constants/urls.ts b/src/app/shared/constants/urls.ts index 14cd5f44..625d0c8f 100644 --- a/src/app/shared/constants/urls.ts +++ b/src/app/shared/constants/urls.ts @@ -36,4 +36,5 @@ export enum URLS { LOGS = `${BASE_URL}/logs`, SCHEMA_CONTRACT = `${BASE_URL}/system/schema_contract`, ACCESS_USAGE = `${BASE_URL}/system/access_usage`, + SERVER_SETTING = `${BASE_URL}/system/server_setting`, } diff --git a/src/app/shared/guards/admin.guard.spec.ts b/src/app/shared/guards/admin.guard.spec.ts new file mode 100644 index 00000000..33169825 --- /dev/null +++ b/src/app/shared/guards/admin.guard.spec.ts @@ -0,0 +1,84 @@ +import { TestBed } from '@angular/core/testing'; +import { Router, UrlTree } from '@angular/router'; +import { BehaviorSubject, Observable, of, throwError } from 'rxjs'; +import { serverSettingsGuard } from './admin.guard'; +import { DfUserDataService } from '../services/df-user-data.service'; +import { ROUTES } from '../types/routes'; + +describe('serverSettingsGuard', () => { + const userData$ = new BehaviorSubject(null); + const accessibleTabs = jest.fn(); + const router = { + createUrlTree: jest.fn( + (commands: string[]) => commands as unknown as UrlTree + ), + }; + + const run = () => + TestBed.runInInjectionContext(() => + serverSettingsGuard() + ) as Observable; + + beforeEach(() => { + userData$.next(null); + router.createUrlTree.mockClear(); + accessibleTabs.mockReset().mockReturnValue(of([])); + TestBed.configureTestingModule({ + providers: [ + { provide: DfUserDataService, useValue: { userData$, accessibleTabs } }, + { provide: Router, useValue: router }, + ], + }); + }); + + it('allows a full admin', done => { + userData$.next({ isSysAdmin: true }); + run().subscribe(r => { + expect(r).toBe(true); + done(); + }); + }); + + it('sends a non-admin Home', done => { + userData$.next({ isSysAdmin: false }); + run().subscribe(r => { + expect(r).toEqual([ROUTES.HOME]); + done(); + }); + }); + + it('allows a restricted admin with the config tab', done => { + accessibleTabs.mockReturnValue(of(['config'])); + userData$.next({ isSysAdmin: true, roleId: 3 }); + run().subscribe(r => { + expect(r).toBe(true); + done(); + }); + }); + + it('sends a restricted admin without config Home', done => { + accessibleTabs.mockReturnValue(of(['apps'])); + userData$.next({ isSysAdmin: true, roleId: 3 }); + run().subscribe(r => { + expect(r).toEqual([ROUTES.HOME]); + done(); + }); + }); + + it('sends a restricted admin Home when the tabs cannot be loaded', done => { + accessibleTabs.mockReturnValue(throwError(() => new Error('403'))); + userData$.next({ isSysAdmin: true, roleId: 3 }); + run().subscribe(r => { + expect(r).toEqual([ROUTES.HOME]); + done(); + }); + }); + + it('waits for user data instead of deciding on the initial null', () => { + const seen: unknown[] = []; + run().subscribe(r => seen.push(r)); + expect(seen).toEqual([]); + userData$.next({ isSysAdmin: true }); + expect(seen).toEqual([true]); + }); +}); diff --git a/src/app/shared/guards/admin.guard.ts b/src/app/shared/guards/admin.guard.ts index 6df2b9ea..e9e536da 100644 --- a/src/app/shared/guards/admin.guard.ts +++ b/src/app/shared/guards/admin.guard.ts @@ -1,18 +1,11 @@ import { inject } from '@angular/core'; import { Router } from '@angular/router'; import { DfUserDataService } from '../services/df-user-data.service'; -import { filter, map, take } from 'rxjs'; +import { catchError, filter, map, of, switchMap, take } from 'rxjs'; import { UserSession } from '../types/user'; import { ROUTES } from '../types/routes'; +import { canManageServerSettings } from '../utilities/server-settings-access'; -// Root-admin gate for the Admin Settings > Admins subtree. userData$ is a -// BehaviorSubject seeded with null; on a hard navigation (e.g. the acceptance -// sweep deep-linking straight to the route) the guard used to read that initial -// null, evaluate `undefined`, and return a bare falsy value. Angular then -// cancels the navigation with no redirect, leaving a dead-blank main panel -// (no breadcrumb, title, or table) in every theme. Fix: wait for the session to -// actually resolve before deciding, and redirect a genuine non-root admin to -// Home instead of stranding them on a blank screen. export const rootAdminGuard = () => { const userDataService = inject(DfUserDataService); const router = inject(Router); @@ -22,3 +15,23 @@ export const rootAdminGuard = () => { map(user => (user.isRootAdmin ? true : router.createUrlTree([ROUTES.HOME]))) ); }; + +// Waits for the real session and, for a restricted admin, the real tabs (not the seeded []); refusals go Home. +export const serverSettingsGuard = () => { + const userDataService = inject(DfUserDataService); + const router = inject(Router); + return userDataService.userData$.pipe( + filter((user): user is UserSession => user !== null), + take(1), + switchMap(user => + userDataService.accessibleTabs(user).pipe( + catchError(() => of([] as Array)), + map(tabs => + canManageServerSettings(user, tabs) + ? true + : router.createUrlTree([ROUTES.HOME]) + ) + ) + ) + ); +}; diff --git a/src/app/shared/services/df-server-settings.service.spec.ts b/src/app/shared/services/df-server-settings.service.spec.ts new file mode 100644 index 00000000..54fe262c --- /dev/null +++ b/src/app/shared/services/df-server-settings.service.spec.ts @@ -0,0 +1,53 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { DfServerSettingsService } from './df-server-settings.service'; +import { DfSystemConfigDataService } from './df-system-config-data.service'; +import { URLS } from '../constants/urls'; +import { of } from 'rxjs'; + +describe('DfServerSettingsService', () => { + let service: DfServerSettingsService; + let http: HttpTestingController; + const config = { fetchEnvironmentData: jest.fn(() => of({})) }; + + beforeEach(() => { + config.fetchEnvironmentData.mockClear(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [{ provide: DfSystemConfigDataService, useValue: config }], + }); + service = TestBed.inject(DfServerSettingsService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + it('getAll reads the resource list', () => { + let rows: unknown; + service.getAll().subscribe(r => (rows = r)); + const req = http.expectOne(URLS.SERVER_SETTING); + expect(req.request.method).toBe('GET'); + req.flush({ resource: [{ name: 'log_level', source: 'env' }] }); + expect(rows).toEqual([{ name: 'log_level', source: 'env' }]); + }); + + it('update PATCHes the named setting and refreshes the environment', () => { + service.update('log_level', 'error').subscribe(); + const req = http.expectOne(`${URLS.SERVER_SETTING}/log_level`); + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual({ value: 'error' }); + req.flush({ name: 'log_level', value: 'error', source: 'override' }); + expect(config.fetchEnvironmentData).toHaveBeenCalled(); + }); + + it('reset DELETEs the named setting and refreshes the environment', () => { + service.reset('log_level').subscribe(); + const req = http.expectOne(`${URLS.SERVER_SETTING}/log_level`); + expect(req.request.method).toBe('DELETE'); + req.flush({ name: 'log_level', source: 'env' }); + expect(config.fetchEnvironmentData).toHaveBeenCalled(); + }); +}); diff --git a/src/app/shared/services/df-server-settings.service.ts b/src/app/shared/services/df-server-settings.service.ts new file mode 100644 index 00000000..5d2a8790 --- /dev/null +++ b/src/app/shared/services/df-server-settings.service.ts @@ -0,0 +1,48 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, map, switchMap } from 'rxjs'; +import { URLS } from '../constants/urls'; +import { ServerSetting } from '../types/server-setting'; +import { silent } from '../utilities/http-contexts'; +import { DfSystemConfigDataService } from './df-system-config-data.service'; + +/** + * system/server_setting (sys admin only). Writes are silent: the page shows + * its own inline error and keeps the previous value. + */ +@Injectable({ providedIn: 'root' }) +export class DfServerSettingsService { + private http = inject(HttpClient); + private systemConfig = inject(DfSystemConfigDataService); + + getAll(): Observable { + return this.http + .get<{ resource: ServerSetting[] }>(URLS.SERVER_SETTING, { + context: silent(), + }) + .pipe(map(body => body.resource)); + } + + update(name: string, value: string): Observable { + return this.http + .patch( + `${URLS.SERVER_SETTING}/${name}`, + { value }, + { context: silent() } + ) + .pipe(switchMap(row => this.refreshEnvironment(row))); + } + + reset(name: string): Observable { + return this.http + .delete(`${URLS.SERVER_SETTING}/${name}`, { + context: silent(), + }) + .pipe(switchMap(row => this.refreshEnvironment(row))); + } + + // System Info and the overview tile read the log level from system/environment. + private refreshEnvironment(row: ServerSetting): Observable { + return this.systemConfig.fetchEnvironmentData().pipe(map(() => row)); + } +} diff --git a/src/app/shared/services/df-user-data.service.ts b/src/app/shared/services/df-user-data.service.ts index dd013d38..8885b8df 100644 --- a/src/app/shared/services/df-user-data.service.ts +++ b/src/app/shared/services/df-user-data.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@angular/core'; -import { BehaviorSubject, map, of, switchMap } from 'rxjs'; +import { BehaviorSubject, Observable, map, of, switchMap } from 'rxjs'; import { UserSession } from '../types/user'; import { ROLE_SERVICE_TOKEN } from '../constants/tokens'; import { DfBaseCrudService } from './df-base-crud.service'; @@ -23,38 +23,39 @@ export class DfUserDataService { @Inject(ROLE_SERVICE_TOKEN) private roleService: DfBaseCrudService ) { this.userData$ - .pipe( - switchMap(userData => { - if ( - userData && - userData.isSysAdmin && - !userData.isRootAdmin && - userData.roleId - ) { - return this.roleService - .get(userData.roleId, { - related: 'role_service_access_by_role_id', - additionalParams: [ - { - key: 'accessible_tabs', - value: true, - }, - ], - additionalHeaders: [ - { - key: SESSION_TOKEN_HEADER, - value: userData.sessionToken, - }, - ], - }) - .pipe(map(role => role.accessibleTabs ?? [])); - } - return of([]); - }) - ) + .pipe(switchMap(userData => this.accessibleTabs(userData))) .subscribe(role => this.restrictedAccessSubject.next(role)); } + /** Tabs a restricted admin may use; empty for everyone else. */ + accessibleTabs(userData: UserSession | null): Observable> { + if ( + userData && + userData.isSysAdmin && + !userData.isRootAdmin && + userData.roleId + ) { + return this.roleService + .get(userData.roleId, { + related: 'role_service_access_by_role_id', + additionalParams: [ + { + key: 'accessible_tabs', + value: true, + }, + ], + additionalHeaders: [ + { + key: SESSION_TOKEN_HEADER, + value: userData.sessionToken, + }, + ], + }) + .pipe(map(role => role.accessibleTabs ?? [])); + } + return of([]); + } + clearToken() { document.cookie = `${this.TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`; this.isLoggedIn = false; diff --git a/src/app/shared/types/routes.ts b/src/app/shared/types/routes.ts index 3328b3a0..9c127db0 100644 --- a/src/app/shared/types/routes.ts +++ b/src/app/shared/types/routes.ts @@ -59,6 +59,7 @@ export enum ROUTES { DATA = 'data', PACKAGES = 'package-manager', SYSTEM_INFO = 'system-info', + SERVER_SETTINGS = 'server-settings', CORS = 'cors', CACHE = 'cache', CONFIG_PACKAGE = 'config-package', diff --git a/src/app/shared/types/server-setting.ts b/src/app/shared/types/server-setting.ts new file mode 100644 index 00000000..92082338 --- /dev/null +++ b/src/app/shared/types/server-setting.ts @@ -0,0 +1,8 @@ +export interface ServerSetting { + name: string; + label: string; + value: string; + envValue: string; + source: 'env' | 'override'; + allowedValues: string[]; +} diff --git a/src/app/shared/utilities/route.ts b/src/app/shared/utilities/route.ts index 20ee3515..575a4588 100644 --- a/src/app/shared/utilities/route.ts +++ b/src/app/shared/utilities/route.ts @@ -133,7 +133,8 @@ export function accessibleRoutes( ROUTES.CONFIG_PACKAGE, ROUTES.EMAIL_TEMPLATES, ROUTES.GLOBAL_LOOKUP_KEYS, - ROUTES.INTERCOM + ROUTES.INTERCOM, + ROUTES.SERVER_SETTINGS ); break; case 'limits': diff --git a/src/app/shared/utilities/server-settings-access.spec.ts b/src/app/shared/utilities/server-settings-access.spec.ts new file mode 100644 index 00000000..d36e8f47 --- /dev/null +++ b/src/app/shared/utilities/server-settings-access.spec.ts @@ -0,0 +1,63 @@ +import { UserSession } from '../types/user'; +import { Nav } from '../types/nav'; +import { ROUTES } from '../types/routes'; +import { accessibleRoutes } from './route'; +import { canManageServerSettings } from './server-settings-access'; + +const user = (u: Partial) => u as UserSession; + +describe('canManageServerSettings', () => { + it('allows a full admin', () => { + expect(canManageServerSettings(user({ isSysAdmin: true }), [])).toBe(true); + }); + + it('allows a root admin even when a role is attached', () => { + expect( + canManageServerSettings( + user({ isSysAdmin: true, isRootAdmin: true, roleId: 3 }), + [] + ) + ).toBe(true); + }); + + it('allows a restricted admin whose tabs include config', () => { + expect( + canManageServerSettings(user({ isSysAdmin: true, roleId: 3 }), [ + 'apps', + 'config', + ]) + ).toBe(true); + }); + + it('denies a restricted admin without the config tab', () => { + expect( + canManageServerSettings(user({ isSysAdmin: true, roleId: 3 }), ['apps']) + ).toBe(false); + }); + + it('denies a non-admin and a missing session', () => { + expect( + canManageServerSettings(user({ isSysAdmin: false, roleId: 3 }), ['config']) + ).toBe(false); + expect(canManageServerSettings(null, ['config'])).toBe(false); + }); +}); + +describe('accessibleRoutes server settings', () => { + const navs = (): Nav[] => [ + { path: '/system-info', route: ROUTES.SYSTEM_INFO }, + { path: '/server-settings', route: ROUTES.SERVER_SETTINGS }, + ]; + + it('keeps server-settings for the config tab', () => { + expect(accessibleRoutes(navs(), ['config']).map(n => n.route)).toContain( + ROUTES.SERVER_SETTINGS + ); + }); + + it('drops server-settings without the config tab', () => { + expect(accessibleRoutes(navs(), ['apps']).map(n => n.route)).not.toContain( + ROUTES.SERVER_SETTINGS + ); + }); +}); diff --git a/src/app/shared/utilities/server-settings-access.ts b/src/app/shared/utilities/server-settings-access.ts new file mode 100644 index 00000000..75bd9f99 --- /dev/null +++ b/src/app/shared/utilities/server-settings-access.ts @@ -0,0 +1,16 @@ +import { UserSession } from '../types/user'; + +/** + * Who may open Server Settings: a full admin, or a restricted admin whose role + * has the Config tab. A restricted admin is a sys admin who is not root and has + * a role, the same definition DfUserDataService uses to load accessible tabs. + */ +export function canManageServerSettings( + user: UserSession | null | undefined, + tabs: Array +): boolean { + return ( + !!user?.isSysAdmin && + (!!user.isRootAdmin || !user.roleId || tabs.includes('config')) + ); +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 643be85f..fb0d6a49 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -631,6 +631,10 @@ "nav": "System Info", "header": "{{nav.system-settings.config.system-info.nav}}" }, + "server-settings": { + "nav": "Server Settings", + "header": "{{nav.system-settings.config.server-settings.nav}}" + }, "cors": { "header": "Cors Configuration", "nav": "CORS", diff --git a/src/assets/i18n/serverSettings/en.json b/src/assets/i18n/serverSettings/en.json new file mode 100644 index 00000000..82ea00ba --- /dev/null +++ b/src/assets/i18n/serverSettings/en.json @@ -0,0 +1,17 @@ +{ + "description": "Change server settings at runtime without editing .env or restarting. Only a short list of safe settings can be changed here.", + "hint": "Applies on the next request; long-running queue workers pick it up on restart.", + "loading": "Loading server settings…", + "empty": "No server settings are available to change.", + "loadError": "Could not load server settings.", + "saveError": "Could not save the change. The previous value is unchanged.", + "saved": "Saved.", + "viewSystemInfo": "View System Info", + "save": "Save", + "reset": "Reset to .env value", + "resetTitle": "Reset to .env value?", + "resetMessage": "The override will be removed and the value from .env will apply again.", + "sourceEnv": ".env", + "sourceOverride": "override", + "envValue": ".env value: {{ value }}" +} diff --git a/src/assets/i18n/systemInfo/en.json b/src/assets/i18n/systemInfo/en.json index ecbfea3c..efe4e66d 100644 --- a/src/assets/i18n/systemInfo/en.json +++ b/src/assets/i18n/systemInfo/en.json @@ -12,6 +12,7 @@ "logPath": "Log Path", "logMode": "Log Mode", "logLevel": "Log Level", + "logLevelChange": "Change", "cacheDriver": "Cache Driver", "demo": "Demo", "instanceId": "Instance ID",