feat(ldap): opt-in skip TLS verification for ldaps (internal CA)
Add a per-tenant "Skip TLS certificate verification" toggle to the LDAP admin page so admins can connect to an AD whose ldaps:// certificate is signed by an internal/self-signed CA (Node error: "unable to verify the first certificate"). When enabled, ldapts is given tlsOptions.rejectUnauthorized=false; the flag is ignored for plain ldap:// (no TLS). Defaults to full verification. New Boolean column LdapConfig.tlsRejectUnauthorized (@default(true)) + migration; wired through DTOs, config service, all Client creations (test/groups/user-search/import/sync) and the test-connection endpoint. UI checkbox with an insecure-network warning (de/en). 3 new service specs; API 218 green, web 131 green, both apps tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add opt-in TLS verification skip for ldaps:// (internal/private CA AD certs).
|
||||||
|
ALTER TABLE "LdapConfig" ADD COLUMN IF NOT EXISTS "tlsRejectUnauthorized" BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -69,6 +69,7 @@ model LdapConfig {
|
|||||||
searchFilter String @default("(objectClass=person)")
|
searchFilter String @default("(objectClass=person)")
|
||||||
syncIntervalMin Int @default(60)
|
syncIntervalMin Int @default(60)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
tlsRejectUnauthorized Boolean @default(true)
|
||||||
groupFilterDns String[] @default([])
|
groupFilterDns String[] @default([])
|
||||||
userExcludeList String[] @default([])
|
userExcludeList String[] @default([])
|
||||||
lastSyncAt DateTime?
|
lastSyncAt DateTime?
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export class CreateLdapConfigDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
tlsRejectUnauthorized?: boolean;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -76,6 +80,10 @@ export class TestConnectionDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
bindPassword?: string;
|
bindPassword?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
tlsRejectUnauthorized?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export class LdapConfigService {
|
|||||||
searchFilter: dto.searchFilter ?? '(objectClass=person)',
|
searchFilter: dto.searchFilter ?? '(objectClass=person)',
|
||||||
syncIntervalMin: dto.syncIntervalMin ?? 60,
|
syncIntervalMin: dto.syncIntervalMin ?? 60,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
|
tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? true,
|
||||||
groupFilterDns: dto.groupFilterDns ?? [],
|
groupFilterDns: dto.groupFilterDns ?? [],
|
||||||
userExcludeList: dto.userExcludeList ?? [],
|
userExcludeList: dto.userExcludeList ?? [],
|
||||||
fieldMappings: {
|
fieldMappings: {
|
||||||
@@ -81,6 +82,9 @@ export class LdapConfigService {
|
|||||||
syncIntervalMin: dto.syncIntervalMin,
|
syncIntervalMin: dto.syncIntervalMin,
|
||||||
}),
|
}),
|
||||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||||
|
...(dto.tlsRejectUnauthorized !== undefined && {
|
||||||
|
tlsRejectUnauthorized: dto.tlsRejectUnauthorized,
|
||||||
|
}),
|
||||||
...(dto.groupFilterDns !== undefined && {
|
...(dto.groupFilterDns !== undefined && {
|
||||||
groupFilterDns: dto.groupFilterDns,
|
groupFilterDns: dto.groupFilterDns,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ export class LdapController {
|
|||||||
const serverUrl = dto.serverUrl || config?.serverUrl;
|
const serverUrl = dto.serverUrl || config?.serverUrl;
|
||||||
const bindDn = dto.bindDn || config?.bindDn;
|
const bindDn = dto.bindDn || config?.bindDn;
|
||||||
const bindPassword = dto.bindPassword || config?.bindPassword;
|
const bindPassword = dto.bindPassword || config?.bindPassword;
|
||||||
|
// Explicit form value wins; otherwise fall back to the saved config.
|
||||||
|
const tlsRejectUnauthorized =
|
||||||
|
dto.tlsRejectUnauthorized ?? config?.tlsRejectUnauthorized;
|
||||||
|
|
||||||
if (!serverUrl) {
|
if (!serverUrl) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -140,7 +143,12 @@ export class LdapController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.ldapService.testConnection({ serverUrl, bindDn, bindPassword });
|
return this.ldapService.testConnection({
|
||||||
|
serverUrl,
|
||||||
|
bindDn,
|
||||||
|
bindPassword,
|
||||||
|
tlsRejectUnauthorized,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,6 +173,7 @@ export class LdapController {
|
|||||||
baseDn: config.baseDn,
|
baseDn: config.baseDn,
|
||||||
bindDn: config.bindDn,
|
bindDn: config.bindDn,
|
||||||
bindPassword: config.bindPassword,
|
bindPassword: config.bindPassword,
|
||||||
|
tlsRejectUnauthorized: config.tlsRejectUnauthorized,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +200,7 @@ export class LdapController {
|
|||||||
baseDn: config.baseDn,
|
baseDn: config.baseDn,
|
||||||
bindDn: config.bindDn,
|
bindDn: config.bindDn,
|
||||||
bindPassword: config.bindPassword,
|
bindPassword: config.bindPassword,
|
||||||
|
tlsRejectUnauthorized: config.tlsRejectUnauthorized,
|
||||||
},
|
},
|
||||||
tenantId,
|
tenantId,
|
||||||
q ?? '',
|
q ?? '',
|
||||||
@@ -223,6 +233,7 @@ export class LdapController {
|
|||||||
baseDn: config.baseDn,
|
baseDn: config.baseDn,
|
||||||
bindDn: config.bindDn,
|
bindDn: config.bindDn,
|
||||||
bindPassword: config.bindPassword,
|
bindPassword: config.bindPassword,
|
||||||
|
tlsRejectUnauthorized: config.tlsRejectUnauthorized,
|
||||||
searchFilter: config.searchFilter,
|
searchFilter: config.searchFilter,
|
||||||
groupFilterDns: config.groupFilterDns,
|
groupFilterDns: config.groupFilterDns,
|
||||||
userExcludeList: config.userExcludeList,
|
userExcludeList: config.userExcludeList,
|
||||||
@@ -258,6 +269,7 @@ export class LdapController {
|
|||||||
baseDn: config.baseDn,
|
baseDn: config.baseDn,
|
||||||
bindDn: config.bindDn,
|
bindDn: config.bindDn,
|
||||||
bindPassword: config.bindPassword,
|
bindPassword: config.bindPassword,
|
||||||
|
tlsRejectUnauthorized: config.tlsRejectUnauthorized,
|
||||||
searchFilter: config.searchFilter,
|
searchFilter: config.searchFilter,
|
||||||
groupFilterDns: config.groupFilterDns,
|
groupFilterDns: config.groupFilterDns,
|
||||||
userExcludeList: config.userExcludeList,
|
userExcludeList: config.userExcludeList,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|||||||
forTenant: vi.fn((p: unknown) => p),
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { Client } from 'ldapts';
|
||||||
import { LdapService } from './ldap.service';
|
import { LdapService } from './ldap.service';
|
||||||
|
|
||||||
describe('LdapService.syncUsersForTenant — per-user exclude list', () => {
|
describe('LdapService.syncUsersForTenant — per-user exclude list', () => {
|
||||||
@@ -265,3 +266,45 @@ describe('LdapService — individual user search & import (dedup)', () => {
|
|||||||
expect(userService.create).not.toHaveBeenCalled();
|
expect(userService.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('LdapService.testConnection — TLS verification opt-out (ldaps)', () => {
|
||||||
|
let service: LdapService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockBind.mockResolvedValue(undefined);
|
||||||
|
mockUnbind.mockResolvedValue(undefined);
|
||||||
|
service = new LdapService({} as any, {} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes tlsOptions.rejectUnauthorized=false for ldaps when opted out', async () => {
|
||||||
|
await service.testConnection({
|
||||||
|
serverUrl: 'ldaps://ad:636',
|
||||||
|
tlsRejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
expect(Client).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: 'ldaps://ad:636',
|
||||||
|
tlsOptions: { rejectUnauthorized: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps verification on for ldaps when tlsRejectUnauthorized is true', async () => {
|
||||||
|
await service.testConnection({
|
||||||
|
serverUrl: 'ldaps://ad:636',
|
||||||
|
tlsRejectUnauthorized: true,
|
||||||
|
});
|
||||||
|
const opts = (Client as any).mock.calls.at(-1)[0];
|
||||||
|
expect(opts.tlsOptions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the flag for plain ldap:// (no TLS)', async () => {
|
||||||
|
await service.testConnection({
|
||||||
|
serverUrl: 'ldap://ad:389',
|
||||||
|
tlsRejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
const opts = (Client as any).mock.calls.at(-1)[0];
|
||||||
|
expect(opts.tlsOptions).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ interface LdapConfigData {
|
|||||||
bindDn?: string | null;
|
bindDn?: string | null;
|
||||||
bindPassword?: string | null;
|
bindPassword?: string | null;
|
||||||
searchFilter: string;
|
searchFilter: string;
|
||||||
|
// When false, skip TLS certificate verification for ldaps:// (internal CA
|
||||||
|
// / self-signed AD certs). Ignored for plain ldap://. Default true.
|
||||||
|
tlsRejectUnauthorized?: boolean | null;
|
||||||
groupFilterDns: string[];
|
groupFilterDns: string[];
|
||||||
userExcludeList: string[];
|
userExcludeList: string[];
|
||||||
fieldMappings: Array<{
|
fieldMappings: Array<{
|
||||||
@@ -97,6 +100,27 @@ export class LdapService {
|
|||||||
await client.bind(bindDn || '', bindPassword || '');
|
await client.bind(bindDn || '', bindPassword || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build ldapts Client options. For ldaps:// connections, honor an opt-in
|
||||||
|
* "skip TLS verification" flag (tlsRejectUnauthorized === false) so admins
|
||||||
|
* can connect to an AD whose certificate is signed by an internal/private
|
||||||
|
* CA that Node doesn't trust ("unable to verify the first certificate").
|
||||||
|
* Ignored for plain ldap:// (no TLS). Default is full verification.
|
||||||
|
*/
|
||||||
|
private buildClientOptions(
|
||||||
|
serverUrl: string,
|
||||||
|
tlsRejectUnauthorized?: boolean | null,
|
||||||
|
): ConstructorParameters<typeof Client>[0] {
|
||||||
|
const options: ConstructorParameters<typeof Client>[0] = { url: serverUrl };
|
||||||
|
if (
|
||||||
|
serverUrl.toLowerCase().startsWith('ldaps') &&
|
||||||
|
tlsRejectUnauthorized === false
|
||||||
|
) {
|
||||||
|
options.tlsOptions = { rejectUnauthorized: false };
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test LDAP connection with given configuration.
|
* Test LDAP connection with given configuration.
|
||||||
* Returns success/failure with optional error message.
|
* Returns success/failure with optional error message.
|
||||||
@@ -105,8 +129,11 @@ export class LdapService {
|
|||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
bindDn?: string | null;
|
bindDn?: string | null;
|
||||||
bindPassword?: string | null;
|
bindPassword?: string | null;
|
||||||
|
tlsRejectUnauthorized?: boolean | null;
|
||||||
}): Promise<{ success: boolean; error?: string }> {
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
const client = new Client({ url: config.serverUrl });
|
const client = new Client(
|
||||||
|
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.bind(client, config.bindDn, config.bindPassword);
|
await this.bind(client, config.bindDn, config.bindPassword);
|
||||||
@@ -135,8 +162,11 @@ export class LdapService {
|
|||||||
baseDn: string;
|
baseDn: string;
|
||||||
bindDn?: string | null;
|
bindDn?: string | null;
|
||||||
bindPassword?: string | null;
|
bindPassword?: string | null;
|
||||||
|
tlsRejectUnauthorized?: boolean | null;
|
||||||
}): Promise<LdapDirectoryEntry[]> {
|
}): Promise<LdapDirectoryEntry[]> {
|
||||||
const client = new Client({ url: config.serverUrl });
|
const client = new Client(
|
||||||
|
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.bind(client, config.bindDn, config.bindPassword);
|
await this.bind(client, config.bindDn, config.bindPassword);
|
||||||
@@ -263,6 +293,7 @@ export class LdapService {
|
|||||||
baseDn: string;
|
baseDn: string;
|
||||||
bindDn?: string | null;
|
bindDn?: string | null;
|
||||||
bindPassword?: string | null;
|
bindPassword?: string | null;
|
||||||
|
tlsRejectUnauthorized?: boolean | null;
|
||||||
},
|
},
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
query: string,
|
query: string,
|
||||||
@@ -272,7 +303,9 @@ export class LdapService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new Client({ url: config.serverUrl });
|
const client = new Client(
|
||||||
|
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
|
||||||
|
);
|
||||||
const first = (v: unknown): string =>
|
const first = (v: unknown): string =>
|
||||||
Array.isArray(v) ? String(v[0] ?? '') : v != null ? String(v) : '';
|
Array.isArray(v) ? String(v[0] ?? '') : v != null ? String(v) : '';
|
||||||
|
|
||||||
@@ -350,7 +383,9 @@ export class LdapService {
|
|||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = new Client({ url: config.serverUrl });
|
const client = new Client(
|
||||||
|
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
|
||||||
|
);
|
||||||
const excludeSet = new Set(
|
const excludeSet = new Set(
|
||||||
(config.userExcludeList ?? [])
|
(config.userExcludeList ?? [])
|
||||||
.map((u) => u.trim().toLowerCase())
|
.map((u) => u.trim().toLowerCase())
|
||||||
@@ -464,7 +499,9 @@ export class LdapService {
|
|||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = new Client({ url: config.serverUrl });
|
const client = new Client(
|
||||||
|
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
|
||||||
|
);
|
||||||
|
|
||||||
// Create tenant-scoped Prisma client per Pitfall 2
|
// Create tenant-scoped Prisma client per Pitfall 2
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface LdapConfig {
|
|||||||
searchFilter: string;
|
searchFilter: string;
|
||||||
syncIntervalMin: number;
|
syncIntervalMin: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
tlsRejectUnauthorized: boolean;
|
||||||
groupFilterDns: string[];
|
groupFilterDns: string[];
|
||||||
userExcludeList: string[];
|
userExcludeList: string[];
|
||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
@@ -85,6 +86,7 @@ export default function AdminLdapPage() {
|
|||||||
searchFilter: '(objectClass=person)',
|
searchFilter: '(objectClass=person)',
|
||||||
syncIntervalMin: 60,
|
syncIntervalMin: 60,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
tlsRejectUnauthorized: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// New mapping form
|
// New mapping form
|
||||||
@@ -143,6 +145,7 @@ export default function AdminLdapPage() {
|
|||||||
searchFilter: data.searchFilter || '(objectClass=person)',
|
searchFilter: data.searchFilter || '(objectClass=person)',
|
||||||
syncIntervalMin: data.syncIntervalMin ?? 60,
|
syncIntervalMin: data.syncIntervalMin ?? 60,
|
||||||
isActive: data.isActive ?? true,
|
isActive: data.isActive ?? true,
|
||||||
|
tlsRejectUnauthorized: data.tlsRejectUnauthorized ?? true,
|
||||||
});
|
});
|
||||||
setGroupFilterDns(data.groupFilterDns ?? []);
|
setGroupFilterDns(data.groupFilterDns ?? []);
|
||||||
setUserExcludeList(data.userExcludeList ?? []);
|
setUserExcludeList(data.userExcludeList ?? []);
|
||||||
@@ -201,10 +204,12 @@ export default function AdminLdapPage() {
|
|||||||
// before ever saving a config. bindPassword is omitted when blank so
|
// before ever saving a config. bindPassword is omitted when blank so
|
||||||
// the backend falls back to the saved config's password (masked
|
// the backend falls back to the saved config's password (masked
|
||||||
// fields never get re-sent once a config already exists).
|
// fields never get re-sent once a config already exists).
|
||||||
const body: Record<string, string> = {};
|
const body: Record<string, unknown> = {};
|
||||||
if (formData.serverUrl) body.serverUrl = formData.serverUrl;
|
if (formData.serverUrl) body.serverUrl = formData.serverUrl;
|
||||||
if (formData.bindDn) body.bindDn = formData.bindDn;
|
if (formData.bindDn) body.bindDn = formData.bindDn;
|
||||||
if (formData.bindPassword) body.bindPassword = formData.bindPassword;
|
if (formData.bindPassword) body.bindPassword = formData.bindPassword;
|
||||||
|
// Always send the current TLS-verification choice so the test reflects it.
|
||||||
|
body.tlsRejectUnauthorized = formData.tlsRejectUnauthorized;
|
||||||
|
|
||||||
const res = await fetch(`${API_URL}/ldap/test-connection`, {
|
const res = await fetch(`${API_URL}/ldap/test-connection`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -504,6 +509,23 @@ export default function AdminLdapPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!formData.tlsRejectUnauthorized}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
tlsRejectUnauthorized: !e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t('tlsSkip.label')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('tlsSkip.hint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 pt-2">
|
<div className="flex items-center gap-4 pt-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -287,6 +287,10 @@
|
|||||||
"bindDn": "Bind-DN",
|
"bindDn": "Bind-DN",
|
||||||
"bindPassword": "Bind-Passwort",
|
"bindPassword": "Bind-Passwort",
|
||||||
"searchFilter": "Suchfilter",
|
"searchFilter": "Suchfilter",
|
||||||
|
"tlsSkip": {
|
||||||
|
"label": "TLS-Zertifikat nicht pruefen (unsicher)",
|
||||||
|
"hint": "Nur fuer ldaps:// noetig, wenn das AD-Zertifikat von einer internen/selbstsignierten CA stammt (Fehler: 'unable to verify the first certificate'). Deaktiviert die Zertifikatspruefung - nur in vertrauenswuerdigen Netzen verwenden."
|
||||||
|
},
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"testConnection": "Verbindung testen",
|
"testConnection": "Verbindung testen",
|
||||||
"testSuccess": "Verbindung erfolgreich",
|
"testSuccess": "Verbindung erfolgreich",
|
||||||
|
|||||||
@@ -287,6 +287,10 @@
|
|||||||
"bindDn": "Bind DN",
|
"bindDn": "Bind DN",
|
||||||
"bindPassword": "Bind password",
|
"bindPassword": "Bind password",
|
||||||
"searchFilter": "Search filter",
|
"searchFilter": "Search filter",
|
||||||
|
"tlsSkip": {
|
||||||
|
"label": "Skip TLS certificate verification (insecure)",
|
||||||
|
"hint": "Only needed for ldaps:// when the AD certificate is signed by an internal/self-signed CA (error: 'unable to verify the first certificate'). Disables certificate verification - use only on trusted networks."
|
||||||
|
},
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"testConnection": "Test connection",
|
"testConnection": "Test connection",
|
||||||
"testSuccess": "Connection successful",
|
"testSuccess": "Connection successful",
|
||||||
|
|||||||
Reference in New Issue
Block a user