#245: mode hidden — hide profile-violating options, mark the hiding #294
@ -196,6 +196,12 @@ jobs:
|
||||
# cover the marked state (issue #244); mode off is covered by
|
||||
# local full runs and the marking pack's off-assertions there.
|
||||
(cd apps/api && PORT=3001 VS_NFD_MODE=marked node dist/main.js > /tmp/api.log 2>&1 &)
|
||||
# Second api on the SAME database with VS_NFD_MODE=hidden: the
|
||||
# marking pack's hidden half runs against it via its own static
|
||||
# server (issue #245); the mode is env-only, so sharing the db is
|
||||
# exactly the deploy semantics.
|
||||
(cd apps/api && PORT=3006 VS_NFD_MODE=hidden MIGRATE_ON_START=false node dist/main.js > /tmp/api-hidden.log 2>&1 &)
|
||||
(PORT=5176 API_TARGET=http://127.0.0.1:3006 node scripts/e2e-static-server.mjs > /tmp/web-hidden.log 2>&1 &)
|
||||
(cd apps/collab && PORT=3002 node dist/index.js > /tmp/collab.log 2>&1 &)
|
||||
(PORT=5173 COLLAB_TARGET=http://127.0.0.1:3002 node scripts/e2e-static-server.mjs > /tmp/web.log 2>&1 &)
|
||||
for i in $(seq 1 30); do
|
||||
@ -620,6 +626,16 @@ jobs:
|
||||
E2E_BASE_URL=http://localhost:5173 E2E_VS_NFD_MODE=marked \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts
|
||||
|
||||
# Ausblendung + Policy-Hinweis im Modus `hidden` (issue #245).
|
||||
- name: Run VS-NfD hidden pack
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf http://localhost:3006/api/v1/readyz >/dev/null && break
|
||||
sleep 2
|
||||
done
|
||||
E2E_BASE_URL=http://localhost:5176 E2E_VS_NFD_MODE=hidden \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts
|
||||
|
||||
# The marking pack's extra login on top of the six a11y logins pushes
|
||||
# the theme pack over the 10/min login limit — reset again (#244).
|
||||
- name: Reset login rate limit before theme pack
|
||||
|
||||
@ -36,10 +36,11 @@ E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://localhost:8025 pnpm --
|
||||
`auth.spec.ts` skips itself when `E2E_MAILPIT_URL` is unset, so the CD
|
||||
smoke run never trips over it.
|
||||
|
||||
`vs-nfd-marking.spec.ts` (issue #244) has two halves selected by
|
||||
`E2E_VS_NFD_MODE`: set it to `marked` **and** start the api with
|
||||
`VS_NFD_MODE=marked` for the marking assertions (CI does this in the
|
||||
auth-e2e job); leave both unset for the no-marking-in-`off` assertions.
|
||||
`vs-nfd-marking.spec.ts` (issues #244/#245) has three parts selected by
|
||||
`E2E_VS_NFD_MODE` (`marked`, `hidden`, unset = `off`); each needs an api
|
||||
started with the matching `VS_NFD_MODE`. CI runs the marked half against
|
||||
the main e2e stack and the hidden half against a second api (port 3006)
|
||||
on the same database; the off assertions run in local default stacks.
|
||||
|
||||
## Fixture matrix
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { contextForUser } from './helpers';
|
||||
@ -46,6 +47,45 @@ test('mode marked: card, checkbox marking, and point-of-choice marking', async (
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('mode hidden: rows disappear, notes mark the hiding, a11y clean', async ({ browser }) => {
|
||||
test.skip(MODE !== 'hidden', 'needs an api started with VS_NFD_MODE=hidden');
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||
const page = await context.newPage();
|
||||
await page.goto('/admin');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// feeds.enabled defaults to true = ALREADY violating: never silently
|
||||
// hidden — the row stays visible with its marking.
|
||||
await expect(page.locator('#vs-nfd-mark-feeds\\.enabled')).toBeVisible();
|
||||
|
||||
// Master switches: compliant (false) rows disappear, violating (true)
|
||||
// rows stay — derive the expectation from the actual instance settings,
|
||||
// the seed state differs between local and CI databases.
|
||||
const settings = (await (
|
||||
await context.request.get(`${BASE_URL}/api/v1/admin/settings`)
|
||||
).json()) as Record<string, boolean>;
|
||||
const masterKeys = ['api.enabled', 'mcp.enabled', 'feeds.enabled', 'plugins.enabled'] as const;
|
||||
const visible = masterKeys.filter((key) => settings[key] === true).length;
|
||||
expect(visible).toBeLessThan(masterKeys.length); // at least one row is hidden
|
||||
await expect(page.locator('.api-opt-in__label')).toHaveCount(visible);
|
||||
await expect(page.locator('.vs-nfd-hidden-note').first()).toBeVisible();
|
||||
|
||||
// Value-listed control: the compliant registration mode keeps only its
|
||||
// compliant choice (seed leaves it open = violating? then all options).
|
||||
const regSelect = page.locator('select[name="auth.registrationMode"]');
|
||||
const regField = page.locator('label.field', { has: regSelect });
|
||||
const optionCount = await regSelect.locator('option').count();
|
||||
const marked = await regField.locator('.vs-nfd-mark').count();
|
||||
// Exactly one of the two treatments applies, never a silent third state.
|
||||
expect(optionCount === 1 || marked === 1).toBe(true);
|
||||
|
||||
// The hidden state and the policy note pass the axe WCAG A/AA scan.
|
||||
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('mode off: no card, no markings', async ({ browser }) => {
|
||||
test.skip(MODE !== 'off', 'covers the default stack only');
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||
|
||||
@ -22,6 +22,9 @@ export function useVsNfdMarking(): {
|
||||
view: VsNfdProfileView | undefined;
|
||||
/** Translated marking text for a violating value, else undefined. */
|
||||
markingFor: (key: string, value: unknown) => string | undefined;
|
||||
/** True when the control must not be rendered (#245): mode hidden/
|
||||
* enforced and the SAVED value is compliant. */
|
||||
hides: (key: string, savedValue: unknown) => boolean;
|
||||
} {
|
||||
const { t } = useTranslation('settings');
|
||||
const query = useQuery({
|
||||
@ -34,16 +37,41 @@ export function useVsNfdMarking(): {
|
||||
mode,
|
||||
view: query.data,
|
||||
markingFor(key, value) {
|
||||
// Marking is the `marked` treatment; `hidden`/`enforced` get their
|
||||
// own behaviour with #245/#246.
|
||||
if (mode !== 'marked') return undefined;
|
||||
// Marking applies in `marked`, and in `hidden`/`enforced` to values
|
||||
// that are ALREADY violating — an existing violation is never
|
||||
// silently hidden (#245).
|
||||
if (mode === 'off') return undefined;
|
||||
const entry = VS_NFD_PROFILE.find((e) => e.scope === 'instance' && e.key === key);
|
||||
if (!entry || isVsNfdCompliant(entry, value)) return undefined;
|
||||
return t('admin.vsNfd.marking', { value: describeCompliance(entry.compliance) });
|
||||
},
|
||||
hides(key, savedValue) {
|
||||
// The `hidden` treatment (#245, `enforced` renders the same, #246):
|
||||
// a compliant control whose only purpose would be enabling a
|
||||
// violation disappears; a violating one stays visible WITH its
|
||||
// marking so the deviation is surfaced, never buried.
|
||||
if (mode !== 'hidden' && mode !== 'enforced') return false;
|
||||
const entry = VS_NFD_PROFILE.find((e) => e.scope === 'instance' && e.key === key);
|
||||
return entry !== undefined && isVsNfdCompliant(entry, savedValue);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The one accessible per-section note that options are hidden by
|
||||
* deployment policy (#245) — policy must be distinguishable from missing
|
||||
* features for anyone reading the settings page.
|
||||
*/
|
||||
export function VsNfdHiddenNote(): React.JSX.Element {
|
||||
const { t } = useTranslation('settings');
|
||||
return (
|
||||
<p className="field__hint vs-nfd-hidden-note">
|
||||
<span aria-hidden="true">🛈 </span>
|
||||
{t('admin.vsNfd.hiddenNote')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The marking element for controls not wrapped in <Field> (checkbox rows):
|
||||
* icon + text, colour never alone; wire `id` into the control's
|
||||
|
||||
@ -10,7 +10,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
|
||||
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
|
||||
import { formatBytes } from '../files/file-format';
|
||||
import { ApiError, apiGet, apiPost, apiPut } from '../lib/api';
|
||||
|
||||
@ -216,6 +216,7 @@ function BackupSettingsForm(): React.JSX.Element {
|
||||
if (!view) return <></>;
|
||||
const form = draft ?? toDraft(view);
|
||||
const nextcloudMarking = vsNfd.markingFor('backup.nextcloud.enabled', form.enabled);
|
||||
const nextcloudHidden = vsNfd.hides('backup.nextcloud.enabled', view.nextcloud.enabled);
|
||||
const update = (patch: Partial<SettingsDraft>): void => setDraft({ ...form, ...patch });
|
||||
|
||||
const describeError = (error: unknown): string => {
|
||||
@ -307,92 +308,97 @@ function BackupSettingsForm(): React.JSX.Element {
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<label className="system-backup__checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
disabled={!view.remoteTargets.allowed}
|
||||
aria-describedby={nextcloudMarking ? 'vs-nfd-mark-backup-nextcloud' : undefined}
|
||||
onChange={(e) => update({ enabled: e.target.checked })}
|
||||
/>
|
||||
{t('backup.settings.enabled')}
|
||||
</label>
|
||||
{nextcloudHidden && <VsNfdHiddenNote />}
|
||||
{!nextcloudHidden && (
|
||||
<label className="system-backup__checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
disabled={!view.remoteTargets.allowed}
|
||||
aria-describedby={nextcloudMarking ? 'vs-nfd-mark-backup-nextcloud' : undefined}
|
||||
onChange={(e) => update({ enabled: e.target.checked })}
|
||||
/>
|
||||
{t('backup.settings.enabled')}
|
||||
</label>
|
||||
)}
|
||||
{nextcloudMarking && <VsNfdMark id="vs-nfd-mark-backup-nextcloud" text={nextcloudMarking} />}
|
||||
<fieldset
|
||||
disabled={!form.enabled || !view.remoteTargets.allowed}
|
||||
className="system-backup__nextcloud"
|
||||
>
|
||||
<label>
|
||||
{t('backup.settings.baseUrl')}
|
||||
<input
|
||||
type="url"
|
||||
value={form.baseUrl}
|
||||
placeholder="https://cloud.example.com"
|
||||
onChange={(e) => update({ baseUrl: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<p className="system-backup__hint">{t('backup.settings.baseUrlHint')}</p>
|
||||
<label>
|
||||
{t('backup.settings.username')}
|
||||
<input
|
||||
type="text"
|
||||
value={form.username}
|
||||
autoComplete="off"
|
||||
onChange={(e) => update({ username: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('backup.settings.password')}
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
autoComplete="new-password"
|
||||
placeholder={view.nextcloud.passwordSet ? t('backup.settings.passwordStored') : ''}
|
||||
onChange={(e) => update({ password: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<p className="system-backup__hint">{t('backup.settings.passwordHint')}</p>
|
||||
<label>
|
||||
{t('backup.settings.folder')}
|
||||
<input
|
||||
type="text"
|
||||
value={form.folder}
|
||||
onChange={(e) => update({ folder: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('backup.settings.schedule')}
|
||||
<select
|
||||
value={form.uploadSchedule}
|
||||
onChange={(e) =>
|
||||
update({ uploadSchedule: e.target.value as SettingsDraft['uploadSchedule'] })
|
||||
}
|
||||
>
|
||||
{(['off', 'daily', 'weekly'] as const).map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`backup.settings.scheduleOptions.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('backup.settings.remoteRetention')}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.remoteRetentionDays}
|
||||
onChange={(e) => update({ remoteRetentionDays: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="button system-backup__test"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void test()}
|
||||
{!nextcloudHidden && (
|
||||
<fieldset
|
||||
disabled={!form.enabled || !view.remoteTargets.allowed}
|
||||
className="system-backup__nextcloud"
|
||||
>
|
||||
{busy === 'test' ? t('backup.settings.testPending') : t('backup.settings.test')}
|
||||
</button>
|
||||
</fieldset>
|
||||
<label>
|
||||
{t('backup.settings.baseUrl')}
|
||||
<input
|
||||
type="url"
|
||||
value={form.baseUrl}
|
||||
placeholder="https://cloud.example.com"
|
||||
onChange={(e) => update({ baseUrl: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<p className="system-backup__hint">{t('backup.settings.baseUrlHint')}</p>
|
||||
<label>
|
||||
{t('backup.settings.username')}
|
||||
<input
|
||||
type="text"
|
||||
value={form.username}
|
||||
autoComplete="off"
|
||||
onChange={(e) => update({ username: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('backup.settings.password')}
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
autoComplete="new-password"
|
||||
placeholder={view.nextcloud.passwordSet ? t('backup.settings.passwordStored') : ''}
|
||||
onChange={(e) => update({ password: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<p className="system-backup__hint">{t('backup.settings.passwordHint')}</p>
|
||||
<label>
|
||||
{t('backup.settings.folder')}
|
||||
<input
|
||||
type="text"
|
||||
value={form.folder}
|
||||
onChange={(e) => update({ folder: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('backup.settings.schedule')}
|
||||
<select
|
||||
value={form.uploadSchedule}
|
||||
onChange={(e) =>
|
||||
update({ uploadSchedule: e.target.value as SettingsDraft['uploadSchedule'] })
|
||||
}
|
||||
>
|
||||
{(['off', 'daily', 'weekly'] as const).map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`backup.settings.scheduleOptions.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t('backup.settings.remoteRetention')}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.remoteRetentionDays}
|
||||
onChange={(e) => update({ remoteRetentionDays: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="button system-backup__test"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void test()}
|
||||
>
|
||||
{busy === 'test' ? t('backup.settings.testPending') : t('backup.settings.test')}
|
||||
</button>
|
||||
</fieldset>
|
||||
)}
|
||||
<button type="submit" className="button system-backup__save" disabled={busy !== null}>
|
||||
{busy === 'save' ? t('backup.settings.savePending') : t('backup.settings.save')}
|
||||
</button>
|
||||
|
||||
@ -7,7 +7,7 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import { Field, FormError, FormSuccess } from '../components/forms';
|
||||
import { SettingsLayout } from '../components/SettingsLayout';
|
||||
import { VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
|
||||
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
|
||||
import { apiGet, apiPatch } from '../lib/api';
|
||||
import { PluginManager } from './PluginManager';
|
||||
import { QuotaManager } from './QuotaManager';
|
||||
@ -76,6 +76,15 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
<VsNfdProfileSection />
|
||||
<section className="settings-section">
|
||||
<h2>{t('settings:admin.general')}</h2>
|
||||
{(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) ||
|
||||
vsNfd.hides(
|
||||
'classification.newPageDefault',
|
||||
settings.data['classification.newPageDefault'],
|
||||
) ||
|
||||
vsNfd.hides(
|
||||
'classification.uploadPolicy',
|
||||
settings.data['classification.uploadPolicy'],
|
||||
)) && <VsNfdHiddenNote />}
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<FormError error={error} />
|
||||
<FormSuccess message={saved ? t('settings:admin.saved') : null} />
|
||||
@ -96,7 +105,9 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
)}
|
||||
>
|
||||
<select {...form.register('auth.registrationMode')}>
|
||||
<option value="open">{t('settings:admin.registrationOpen')}</option>
|
||||
{!vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) && (
|
||||
<option value="open">{t('settings:admin.registrationOpen')}</option>
|
||||
)}
|
||||
<option value="closed">{t('settings:admin.registrationClosed')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
@ -109,9 +120,14 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
)}
|
||||
>
|
||||
<select {...form.register('classification.newPageDefault')}>
|
||||
<option value="unclassified">
|
||||
{t('settings:admin.classificationUnclassified')}
|
||||
</option>
|
||||
{!vsNfd.hides(
|
||||
'classification.newPageDefault',
|
||||
settings.data['classification.newPageDefault'],
|
||||
) && (
|
||||
<option value="unclassified">
|
||||
{t('settings:admin.classificationUnclassified')}
|
||||
</option>
|
||||
)}
|
||||
<option value="vs_nfd">{t('settings:admin.classificationVsNfd')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
@ -124,7 +140,10 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
)}
|
||||
>
|
||||
<select {...form.register('classification.uploadPolicy')}>
|
||||
<option value="warn">{t('settings:admin.uploadPolicyWarn')}</option>
|
||||
{!vsNfd.hides(
|
||||
'classification.uploadPolicy',
|
||||
settings.data['classification.uploadPolicy'],
|
||||
) && <option value="warn">{t('settings:admin.uploadPolicyWarn')}</option>}
|
||||
<option value="block">{t('settings:admin.uploadPolicyBlock')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
@ -266,6 +285,7 @@ function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React
|
||||
onChange={(event) => setExtensions(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{vsNfd.hides('upload.svgPolicy', settings['upload.svgPolicy']) && <VsNfdHiddenNote />}
|
||||
<Field
|
||||
label={t('settings.svgPolicy')}
|
||||
marking={vsNfd.markingFor('upload.svgPolicy', svgPolicy)}
|
||||
@ -274,7 +294,9 @@ function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React
|
||||
value={svgPolicy}
|
||||
onChange={(event) => setSvgPolicy(event.target.value as 'reject' | 'sanitize')}
|
||||
>
|
||||
<option value="sanitize">{t('settings.svgSanitize')}</option>
|
||||
{!vsNfd.hides('upload.svgPolicy', settings['upload.svgPolicy']) && (
|
||||
<option value="sanitize">{t('settings.svgSanitize')}</option>
|
||||
)}
|
||||
<option value="reject">{t('settings.svgReject')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
@ -314,6 +336,9 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re
|
||||
<h2>{t('admin.title')}</h2>
|
||||
<FormError error={error} />
|
||||
<FormSuccess message={saved ? t('admin.saved') : null} />
|
||||
{(['api.enabled', 'mcp.enabled', 'feeds.enabled', 'plugins.enabled'] as const).some((key) =>
|
||||
vsNfd.hides(key, settings[key]),
|
||||
) && <VsNfdHiddenNote />}
|
||||
{(
|
||||
[
|
||||
{ key: 'api.enabled', label: t('admin.label'), hint: t('admin.hint') },
|
||||
@ -322,6 +347,7 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re
|
||||
{ key: 'plugins.enabled', label: t('admin.pluginsLabel'), hint: t('admin.pluginsHint') },
|
||||
] as const
|
||||
).map((row) => {
|
||||
if (vsNfd.hides(row.key, settings[row.key])) return null;
|
||||
const marking = vsNfd.markingFor(row.key, settings[row.key]);
|
||||
return (
|
||||
<div key={row.key}>
|
||||
|
||||
@ -72,7 +72,8 @@
|
||||
"referenceValue": "Referenzwert: {{value}}",
|
||||
"guideRef": "Härtungsleitfaden §{{section}}",
|
||||
"marking": "Weicht von der VS-NfD-Referenzkonfiguration ab (Referenzwert: {{value}})",
|
||||
"guideNote": "Referenzkonfiguration und Begründungen:"
|
||||
"guideNote": "Referenzkonfiguration und Begründungen:",
|
||||
"hiddenNote": "Einzelne Optionen sind durch die Deployment-Policy (VS-NfD-Modus) ausgeblendet — das ist Absicht, kein fehlendes Feature."
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
|
||||
@ -72,7 +72,8 @@
|
||||
"referenceValue": "Reference value: {{value}}",
|
||||
"guideRef": "Hardening guide §{{section}}",
|
||||
"marking": "Deviates from the VS-NfD reference configuration (reference value: {{value}})",
|
||||
"guideNote": "Reference configuration and rationale:"
|
||||
"guideNote": "Reference configuration and rationale:",
|
||||
"hiddenNote": "Some options are hidden by deployment policy (VS-NfD mode) — that is intentional, not a missing feature."
|
||||
}
|
||||
},
|
||||
"landing": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user