Files
cloudron-box/dashboard/src/components/ExternalLdap.vue
2025-02-17 16:38:30 +01:00

389 lines
16 KiB
Vue

<script setup>
import { ref, useTemplateRef, onMounted, computed } from 'vue';
import { Dialog, Button, Icon, FormGroup, Dropdown, Checkbox, TextInput, ProgressBar } from 'pankow';
import { prettyLongDate } from 'pankow/utils';
import Section from './Section.vue';
import UserDirectoryModel from '../models/UserDirectoryModel.js';
import TasksModel from '../models/TasksModel.js';
import { TASK_TYPES } from '../constants.js';
const userDirectoryModel = UserDirectoryModel.create();
const tasksModel = TasksModel.create();
const availableProviders = [
{ name: 'Active Directory', value: 'ad' },
{ name: 'Cloudron', value: 'cloudron' },
{ name: 'Jumpcloud', value: 'jumpcloud' },
{ name: 'Okta', value: 'okta' },
{ name: 'Univention Corporate Server (UCS)', value: 'univention' },
{ name: 'Other', value: 'other' },
{ name: 'Disabled', value: 'noop' }
];
const dialog = useTemplateRef('dialog');
const taskLogsMenu = ref([]);
const tasks = ref([]);
const config = ref({});
// for edit dialog
const editBusy = ref(false);
const editError = ref({});
const provider = ref('');
const url = ref('');
const acceptSelfSignedCerts = ref(false);
const baseDn = ref('');
const filter = ref('');
const usernameField = ref('');
const syncGroups = ref(false);
const groupBaseDn = ref('');
const groupFilter = ref('');
const groupnameField = ref('');
const bindDn = ref('');
const bindPassword = ref('');
const autoCreate = ref(false);
const isValid = computed(() => {
if (!provider.value) return false;
if (!url.value) return false;
if (!baseDn.value) return false;
if (!filter.value) return false;
// TODO check all
return true;
});
async function onConfigure() {
editBusy.value = false;
editError.value = {};
provider.value = config.value.provider;
url.value = config.value.url;
acceptSelfSignedCerts.value = config.value.acceptSelfSignedCerts;
baseDn.value = config.value.baseDn;
filter.value = config.value.filter;
usernameField.value = config.value.usernameField;
syncGroups.value = config.value.syncGroups;
groupBaseDn.value = config.value.groupBaseDn;
groupFilter.value = config.value.groupFilter;
groupnameField.value = config.value.groupnameField;
bindDn.value = config.value.bindDn;
bindPassword.value = config.value.bindPassword;
autoCreate.value = config.value.autoCreate;
dialog.value.open();
}
async function onSubmit() {
if (!isValid.value) return;
editBusy.value = true;
editError.value = {};
const config = { provider: provider.value };
if (provider.value === 'cloudron') {
config.url = url.value;
config.acceptSelfSignedCerts = acceptSelfSignedCerts.value;
config.autoCreate = autoCreate.value;
config.syncGroups = syncGroups.value;
config.bindPassword = bindPassword.value;
// those values are known and thus overwritten
config.baseDn = 'ou=users,dc=cloudron';
config.filter = '(objectClass=inetOrgPerson)';
config.usernameField = 'username';
config.groupBaseDn = 'ou=groups,dc=cloudron';
config.groupFilter = '(objectClass=group)';
config.groupnameField = 'cn';
config.bindDn = 'cn=admin,ou=system,dc=cloudron';
} else if (provider.value !== 'noop') {
config.url = url.value;
config.acceptSelfSignedCerts = acceptSelfSignedCerts.value;
config.baseDn = baseDn.value;
config.filter = filter.value;
config.usernameField = usernameField.value;
config.syncGroups = syncGroups.value;
config.groupBaseDn = groupBaseDn.value;
config.groupFilter = groupFilter.value;
config.groupnameField = groupnameField.value;
config.autoCreate = autoCreate.value;
if (bindDn.value) {
config.bindDn = bindDn.value;
config.bindPassword = bindPassword.value;
}
}
const [error] = await userDirectoryModel.setExternalLdapConfig(config);
if (error) {
editBusy.value = false;
if (error.status === 424) {
if (error.code === 'SELF_SIGNED_CERT_IN_CHAIN') editError.value.acceptSelfSignedCerts = true;
else editError.value.url = true;
editError.value.generic = error.body.message;
} else if (error.status === 400 && error.body.message === 'invalid baseDn') {
editError.value.baseDn = true;
} else if (error.status === 400 && error.body.message === 'invalid filter') {
editError.value.filter = true;
} else if (error.status === 400 && error.body.message === 'invalid groupBaseDn') {
editError.value.groupBaseDn = true;
} else if (error.status === 400 && error.body.message === 'invalid groupFilter') {
editError.value.groupFilter = true;
} else if (error.status === 400 && error.body.message === 'invalid groupnameField') {
editError.value.groupnameField = true;
} else if (error.status === 400 && error.body.message === 'invalid bind credentials') {
editError.value.credentials = true;
} else if (error.status === 400 && error.body.message === 'invalid usernameField') {
editError.value.usernameField = true;
} else {
console.error('Failed to set external LDAP config:', error);
editError.value.generic = error.body.message;
}
return;
}
await refresh();
editBusy.value = false;
dialog.value.close();
}
const syncBusy = ref(false);
const syncPercent = ref(0);
const syncMessage = ref('');
const syncError = ref('');
async function onSync() {
syncBusy.value = true;
syncPercent.value = 0;
syncMessage.value = '';
syncError.value = '';
const [error] = await userDirectoryModel.startExternalLdapSync();
if (error && error.body) syncMessage.value = error.body.message;
else if (error) console.error(error);
else await refreshTasks();
}
async function updateTaskStatus(id) {
const [error, result] = await tasksModel.get(id);
if (error) return setTimeout(updateTaskStatus.bind(null, id), 5000);
if (!result.active) {
syncBusy.value = false;
syncMessage.value = '';
syncPercent.value = 100; // indicates that 'result' is valid
syncError.value = result.success ? '' : result.error.message;
await refreshTasks(); // update the tasks list dropdown
return;
}
syncBusy.value = true;
syncPercent.value = result.percent;
syncMessage.value = result.message;
setTimeout(updateTaskStatus.bind(null, id), 2000);
}
async function refreshTasks() {
const [error, result] = await tasksModel.getByType(TASK_TYPES.TASK_SYNC_EXTERNAL_LDAP);
if (error) return console.error(error);
// limit to last 10
tasks.value = result.slice(0, 10);
if (tasks.value.length && tasks.value[0].active) updateTaskStatus(tasks.value[0].id);
taskLogsMenu.value = tasks.value.map(t => {
return {
icon: 'fa-solid ' + ((!t.active && t.success) ? 'status-active fa-check-circle' : (t.active ? 'fa-circle-notch fa-spin' : 'status-error fa-times-circle')),
label: prettyLongDate(t.ts),
action: () => { window.open(`/logs.html?taskId=${t.id}`); }
};
});
}
async function refresh() {
const [error, result] = await userDirectoryModel.getExternalLdapConfig();
if (error) return console.error(error);
config.value = result;
}
onMounted(async () => {
await refresh();
await refreshTasks();
});
</script>
<template>
<div>
<Dialog ref="dialog"
:title="$t('users.externalLdapDialog.title')"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="editBusy"
:confirm-active="!editBusy && isValid"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmit()"
>
<p class="has-error text-center" v-show="editError.generic">{{ editError.generic }}</p>
<FormGroup>
<label for="ldapProvider">{{ $t('users.externalLdap.provider') }} <sup><a href="https://docs.cloudron.io/user-directory/#external-directory" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<Dropdown id="ldapProvider" v-model="provider" :options="availableProviders" option-key="value" option-label="name" />
</FormGroup>
<p class="text-small text-warning" v-show="provider === 'noop' && config.provider !== 'noop'">{{ $t('users.externalLdap.disableWarning') }}</p>
<div v-show="provider !== 'noop'">
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
<fieldset :disabled="editBusy">
<input style="display: none" type="submit" :disabled="editBusy || !isValid" />
<FormGroup :class="{ 'has-error': editError.url }">
<label class="control-label" for="configUrlInput">{{ $t('users.externalLdap.server') }}</label>
<TextInput id="configUrlInput" v-model="url" placeholder="ldaps://example.com:636" />
</FormGroup>
<Checkbox v-model="acceptSelfSignedCerts" :label="$t('users.externalLdap.acceptSelfSignedCert')" />
<p class="has-error" v-show="editError.acceptSelfSignedCerts">{{ $t('users.externalLdap.errorSelfSignedCert') }}</p>
<FormGroup :class="{ 'has-error': editError.baseDn }" v-show="provider !== 'cloudron'">
<label for="baseDnInput">{{ $t('users.externalLdap.baseDn') }}</label>
<TextInput v-model="baseDn" id="baseDnInput" placeholder="ou=users,dc=example,dc=com" :required="provider !== 'cloudron'" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.filter }" v-show="provider !== 'cloudron'">
<label for="filterInput">{{ $t('users.externalLdap.filter') }}</label>
<TextInput v-model="filter" id="filterInput" placeholder="(objectClass=inetOrgPerson)" :required="provider !== 'cloudron'" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.usernameField }" v-show="provider !== 'cloudron'">
<label for="usernameFieldInput">{{ $t('users.externalLdap.usernameField') }}</label>
<TextInput v-model="usernameField" id="usernameFieldInput" placeholder="uid or sAMAcountName" />
</FormGroup>
<Checkbox v-model="syncGroups" :label="$t('users.externalLdap.syncGroups')" />
<FormGroup :class="{ 'has-error': editError.groupBaseDn }" v-show="syncGroups && provider !== 'cloudron'">
<label for="groupBaseDnInput">{{ $t('users.externalLdap.groupBaseDn') }}</label>
<TextInput v-model="groupBaseDn" id="groupBaseDnInput" placeholder="ou=groups,dc=example,dc=com" :required="syncGroups && provider !== 'cloudron'" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.groupFilter }" v-show="syncGroups && provider !== 'cloudron'">
<label for="groupFilterInput">{{ $t('users.externalLdap.groupFilter') }}</label>
<TextInput v-model="groupFilter" id="groupFilterInput" placeholder="(objectClass=groupOfNames)" :required="syncGroups && provider !== 'cloudron'" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.groupnameField }" v-show="syncGroups && provider !== 'cloudron'">
<label for="groupnameFieldInput">{{ $t('users.externalLdap.groupnameField') }}</label>
<TextInput v-model="groupnameField" id="groupnameFieldInput" placeholder="cn" v-required="syncGroups && provider !== 'cloudron'" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.credentials }" v-show="provider !== 'cloudron'">
<label for="bindDnInput">{{ $t('users.externalLdap.bindUsername') }}</label>
<TextInput v-model="bindDn" id="bindDnInput" placeholder="uid=admin,ou=Users,dc=example,dc=com" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.credentials }">
<label for="bindPasswordInput">{{ $t('users.externalLdap.bindPassword') }}</label>
<PasswordInput v-model="bindPassword" id="bindPasswordInput" />
</FormGroup>
<Checkbox v-model="autoCreate" :label="$t('users.externalLdap.autocreateUsersOnLogin')" />
</fieldset>
</form>
</div>
</Dialog>
<Section :title="$t('users.externalLdap.title')">
<template #header-buttons>
<Button tool icon="fas fa-align-left" v-tooltip="$t('domains.renewCerts.showLogsAction')" :menu="taskLogsMenu" :disabled="!taskLogsMenu.length"/>
</template>
<p>{{ $t('users.externalLdap.description') }}</p>
<div>
<div v-show="config.provider === 'noop'">
{{ $t('users.externalLdap.noopInfo') }}
</div>
<div class="info-row" v-show="config.provider !== 'noop'">
<div class="info-label">{{ $t('users.externalLdap.provider') }}</div>
<div class="info-value">{{ config.provider }}</div>
</div>
<div class="info-row" v-show="config.provider !== 'noop'">
<div class="info-label">{{ $t('users.externalLdap.server') }}</div>
<div class="info-value">{{ config.url }}</div>
</div>
<div class="info-row" v-show="config.provider !== 'noop'">
<div class="info-label">{{ $t('users.externalLdap.acceptSelfSignedCert') }}</div>
<div class="info-value"><Icon :icon="config.acceptSelfSignedCerts ? 'fa-solid fa-check' : 'fa-solid fa-xmark'" /></div>
</div>
<div class="info-row" v-show="config.provider !== 'noop' && config.provider !== 'cloudron'">
<div class="info-label">{{ $t('users.externalLdap.baseDn') }}</div>
<div class="info-value">{{ config.baseDn }}</div>
</div>
<div class="info-row" v-show="config.provider !== 'noop' && config.provider !== 'cloudron'">
<div class="info-label">{{ $t('users.externalLdap.filter') }}</div>
<div class="info-value">{{ config.filter }}</div>
</div>
<div class="info-row" v-show="config.provider !== 'noop' && config.provider !== 'cloudron'">
<div class="info-label">{{ $t('users.externalLdap.usernameField') }}</div>
<div class="info-value">{{ config.usernameField || 'uid' }}</div>
</div>
<div class="info-row" v-show="config.provider !== 'noop'">
<div class="info-label">{{ $t('users.externalLdap.syncGroups') }}</div>
<div class="info-value"><Icon :icon="config.syncGroups ? 'fa-solid fa-check' : 'fa-solid fa-xmark'" /></div>
</div>
<div class="info-row" v-show="config.provider !== 'noop' && config.provider !== 'cloudron' && config.syncGroups">
<div class="info-label">{{ $t('users.externalLdap.groupBaseDn') }}</div>
<div class="info-value">{{ config.groupBaseDn }}</div>
</div>
<div class="info-row" v-show="config.provider !== 'noop' && config.provider !== 'cloudron' && config.syncGroups">
<div class="info-label">{{ $t('users.externalLdap.groupFilter') }}</div>
<div class="info-value">{{ config.groupFilter }}</div>
</div>
<div class="info-row" v-show="config.provider !== 'noop' && config.provider !== 'cloudron' && config.syncGroups">
<div class="info-label">{{ $t('users.externalLdap.groupnameField') }}</div>
<div class="info-value">{{ config.groupnameField }}</div>
</div>
<div class="info-row" v-show="config.provider !== 'noop' && config.provider !== 'cloudron'">
<div class="info-label">{{ $t('users.externalLdap.auth') }}</div>
<div class="info-value"><Icon :icon="config.bindDn ? 'fa-solid fa-check' : 'fa-solid fa-xmark'" /></div>
</div>
<div class="info-row" v-show="config.provider !== 'noop'">
<div class="info-label">{{ $t('users.externalLdap.autocreateUsersOnLogin') }}</div>
<div class="info-value"><Icon :icon="config.autoCreate ? 'fa-solid fa-check' : 'fa-solid fa-xmark'" /></div>
</div>
</div>
<br/>
<ProgressBar :value="syncPercent" v-show="syncBusy"/>
<p v-show="syncBusy">{{ syncMessage || 'Queued' }}</p>
<p v-show="!syncBusy && syncError" class="has-error">{{ syncError }}</p>
<Button @click="onSync()" :loading="syncBusy" :disabled="syncBusy || config.provider === 'noop'">{{ $t('users.externalLdap.syncAction') }}</Button>
<Button @click="onConfigure()">{{ $t('users.externalLdap.configureAction') }}</Button>
</Section>
</div>
</template>