feat(ldap): allow testing connection before saving a config
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 42s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m35s

"Verbindung testen" only appeared after a config was already saved,
so a fresh/never-configured tenant had no way to validate connection
details at all. Now the button is always visible; POST /ldap/test-connection
accepts optional ad-hoc serverUrl/bindDn/bindPassword and falls back
field-by-field to the saved config for anything omitted (bindPassword
in particular, since the form never re-sends the masked saved password).

Verified locally: tested with typed-but-unsaved values (base DN left
blank, nothing saved) and got a real connection attempt/error back,
not a 404 "no config" response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 10:55:46 +02:00
parent 8e8305ce18
commit 39aa4bff2a
3 changed files with 63 additions and 20 deletions
+19
View File
@@ -53,6 +53,25 @@ export class CreateLdapConfigDto {
*/ */
export class UpdateLdapConfigDto extends PartialType(CreateLdapConfigDto) {} export class UpdateLdapConfigDto extends PartialType(CreateLdapConfigDto) {}
/**
* DTO for testing a connection with ad-hoc (not-yet-saved) values, so an
* admin can validate server/bindDn/bindPassword before persisting a config.
* When omitted, the controller falls back to the tenant's saved config.
*/
export class TestConnectionDto {
@IsUrl({ protocols: ['ldap', 'ldaps'], require_tld: false })
@IsOptional()
serverUrl?: string;
@IsString()
@IsOptional()
bindDn?: string;
@IsString()
@IsOptional()
bindPassword?: string;
}
/** /**
* DTO for creating a field mapping entry (D-17). * DTO for creating a field mapping entry (D-17).
*/ */
+19 -10
View File
@@ -15,6 +15,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
import { import {
CreateFieldMappingDto, CreateFieldMappingDto,
CreateLdapConfigDto, CreateLdapConfigDto,
TestConnectionDto,
UpdateLdapConfigDto, UpdateLdapConfigDto,
} from './dto/ldap-config.dto'; } from './dto/ldap-config.dto';
import { LdapConfigService } from './ldap-config.service'; import { LdapConfigService } from './ldap-config.service';
@@ -108,27 +109,35 @@ export class LdapController {
} }
/** /**
* POST /ldap/test-connection - Test LDAP connection with current config. * POST /ldap/test-connection - Test an LDAP connection.
* Returns success/failure with error message. *
* Accepts optional ad-hoc serverUrl/bindDn/bindPassword so an admin can
* validate connection details before ever saving a config. Any field left
* out (e.g. bindPassword, which the form never re-sends once masked)
* falls back to the tenant's saved config. If no config is saved yet and
* the body doesn't supply all three fields, there is nothing to test.
*/ */
@Post('test-connection') @Post('test-connection')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testConnection(@Req() req: any) { async testConnection(@Req() req: any, @Body() dto: TestConnectionDto) {
const tenantId = req.tenantId; const tenantId = req.tenantId;
if (!tenantId) { if (!tenantId) {
throw new BadRequestException('No tenant context'); throw new BadRequestException('No tenant context');
} }
const config = await this.ldapConfigService.getConfig(tenantId); const config = await this.ldapConfigService.getConfig(tenantId);
if (!config) {
throw new NotFoundException('No LDAP config found for this tenant'); const serverUrl = dto.serverUrl || config?.serverUrl;
const bindDn = dto.bindDn || config?.bindDn;
const bindPassword = dto.bindPassword || config?.bindPassword;
if (!serverUrl || !bindDn || !bindPassword) {
throw new BadRequestException(
'serverUrl, bindDn, and bindPassword are required (either provided directly or from a saved config)',
);
} }
return this.ldapService.testConnection({ return this.ldapService.testConnection({ serverUrl, bindDn, bindPassword });
serverUrl: config.serverUrl,
bindDn: config.bindDn,
bindPassword: config.bindPassword,
});
} }
/** /**
+25 -10
View File
@@ -155,13 +155,26 @@ export default function AdminLdapPage() {
const handleTestConnection = async () => { const handleTestConnection = async () => {
setTestResult(null); setTestResult(null);
try { try {
// Send the currently entered values so a connection can be validated
// before ever saving a config. bindPassword is omitted when blank so
// the backend falls back to the saved config's password (masked
// fields never get re-sent once a config already exists).
const body: Record<string, string> = {};
if (formData.serverUrl) body.serverUrl = formData.serverUrl;
if (formData.bindDn) body.bindDn = formData.bindDn;
if (formData.bindPassword) body.bindPassword = formData.bindPassword;
const res = await fetch(`${API_URL}/ldap/test-connection`, { const res = await fetch(`${API_URL}/ldap/test-connection`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify(body),
}); });
const data = await res.json();
if (res.ok) { if (res.ok) {
const data = await res.json();
setTestResult(data); setTestResult(data);
} else {
setTestResult({ success: false, error: data.message || 'Test failed' });
} }
} catch { } catch {
setTestResult({ success: false, error: 'Network error' }); setTestResult({ success: false, error: 'Network error' });
@@ -376,15 +389,17 @@ export default function AdminLdapPage() {
> >
{saving ? tCommon('loading') : t('save')} {saving ? tCommon('loading') : t('save')}
</button> </button>
{config && ( <button
<button type="button"
type="button" onClick={handleTestConnection}
onClick={handleTestConnection} disabled={
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors" !config &&
> !(formData.serverUrl && formData.bindDn && formData.bindPassword)
{t('testConnection')} }
</button> className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
)} >
{t('testConnection')}
</button>
</div> </div>
{testResult && ( {testResult && (