Commit d715defc authored by jan.koester's avatar jan.koester
Browse files

test

parent 7a4df7df
Loading
Loading
Loading
Loading
+47 −0
Changes for modules/shares/shares.cpp: 47 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -33,6 +33,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 * shadow_clear_nthash() ends SMB logins). root never uses SMB.
 *
 *   info                     -> {servers, settings, shares, users, groups}
 *   principal {name}         -> {name, kind}: a user or @group of any source
 *   create  {share}          a share: name, path, comment, readOnly, smb,
 *   update  {name, share}      nfs, guest, users[], clients[], squash, async
 *   delete  {name}             (update: share.name renames)
@@ -237,6 +238,51 @@ json_object *settings(json_object *a) {
    return webctl::ok();
}

// {name}: a user, or a group as @name, the libc knows -- of any source
// (Active Directory's too, which "info" only lists when it enumerates)
json_object *principal(json_object *a) {
    std::string name = webctl::str(a, "name");
    while (!name.empty() && name.back() == ' ') name.pop_back();
    while (!name.empty() && name[0] == ' ') name.erase(0, 1);
    if (name.empty() || name.size() > 257) return error(tr("no name", "kein Name"));
    for (unsigned char c : name)
        if (c < 0x20 || c == 0x7f) return error(tr("invalid name", "ungültiger Name"));
    json_object *out = json_object_new_object();
    if (name[0] == '@') {
        struct group *gr = getgrnam(name.c_str() + 1);
#if __has_include(<sys/uid_r.h>)
        bool found = gr || getgrnam_id_r(name.c_str() + 1);
#else
        bool found = gr != nullptr;
#endif
        if (!found) {
            json_object_put(out);
            return error(tr("no group %1", "keine Gruppe %1", {name.substr(1)}), 404);
        }
        json_object_object_add(out, "name", jstr(name));
        json_object_object_add(out, "kind", jstr("group"));
        return out;
    }
#if __has_include(<sys/uid_r.h>)
    struct passwd_id_r *pw = getpwnam_id_r(name.c_str());
#else
    struct passwd *pw = getpwnam(name.c_str());
#endif
    if (!pw) {
        // maybe a group, entered without the @
        if (getgrnam(name.c_str())) {
            json_object_object_add(out, "name", jstr("@" + name));
            json_object_object_add(out, "kind", jstr("group"));
            return out;
        }
        json_object_put(out);
        return error(tr("no user or group %1", "kein Benutzer und keine Gruppe %1", {name}), 404);
    }
    json_object_object_add(out, "name", jstr(pw->pw_name));
    json_object_object_add(out, "kind", jstr("user"));
    return out;
}

json_object *smbPassword(json_object *a) {
    std::string name = webctl::str(a, "name"), password = webctl::str(a, "password");
#if __has_include(<sys/uid_r.h>)
@@ -331,6 +377,7 @@ json_object *fileAction(const std::string &action, json_object *a) {

json_object *call(const webctl_request *req) {
    webctl::LanguageScope lang(req->lang);
    if (std::string(req->action) == "principal") return principal(req->args);
    std::string action = req->action;
    json_object *a = req->args;
    sysbus::Message r;
+28 −1
Changes for www/modules/shares.js: 28 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -42,6 +42,10 @@
    'File server settings': 'Einstellungen der Dateiserver', 'SMB server': 'SMB-Server',
    'Workgroup': 'Arbeitsgruppe', 'NetBIOS name (empty: host name)': 'NetBIOS-Name (leer: Hostname)',
    'Description': 'Beschreibung', 'Lowest protocol': 'Niedrigstes Protokoll', 'Highest protocol': 'Höchstes Protokoll',
    'jan@example.com or @domain users@example.com': 'jan@example.com oder @domain users@example.com',
    'Users and groups of other sources (Active Directory) by name; groups with @.':
      'Benutzer und Gruppen anderer Quellen (Active Directory) per Name; Gruppen mit @.',
    'Add': 'Hinzufügen',
    'Signing': 'Signierung', 'Unknown users': 'Unbekannte Benutzer', 'refuse': 'abweisen',
    'log in as guest': 'als Gast anmelden', 'Guest account': 'Gastkonto', 'NFS server (NFSv4)': 'NFS-Server (NFSv4)',
    'Threads': 'Threads', 'NFSv4 root (clients see it as /)': 'NFSv4-Wurzel (Clients sehen sie als /)',
@@ -126,14 +130,37 @@
                        users: [], clients: [], squash: 'root', async: false};
    const principals = [...data.users.map(u => u.name), ...data.groups.map(g => '@' + g)];
    for (const u of s.users) if (!principals.includes(u)) principals.push(u);
    let checklist, addInput;
    // a name the libc knows (AD's too), ticked
    const addPrincipal = async () => {
      const name = addInput.value.trim();
      if (!name) return;
      let r;
      try {
        r = await call('shares', 'principal', {name});
      } catch (e) {
        toast(e.message, true);
        return;
      }
      const existing = [...checklist.querySelectorAll('input[name=users]')].find(i => i.value === r.name);
      if (existing) existing.checked = true;
      else checklist.append(h('label', {class: 'check'},
        h('input', {type: 'checkbox', name: 'users', value: r.name, checked: true}), r.name));
      addInput.value = '';
    };
    dialog({
      title: share ? t('Share {0}', share.name) : t('New share'), submit: share ? undefined : t('Create'),
      fields: form => {
        const smbBox = h('div', {},
          h('label', {}, t('Users and groups (SMB)'),
            h('div', {class: 'checklist'}, principals.map(p =>
            checklist = h('div', {class: 'checklist'}, principals.map(p =>
              h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'users', value: p,
                                                        checked: s.users.includes(p)}), p)))),
          h('div', {class: 'row'},
            addInput = h('input', {placeholder: t('jan@example.com or @domain users@example.com'), autocomplete: 'off',
              onkeydown: ev => { if (ev.key === 'Enter') { ev.preventDefault(); addPrincipal(); } }}),
            h('button', {type: 'button', onclick: () => addPrincipal()}, t('Add'))),
          h('p', {class: 'hint'}, t('Users and groups of other sources (Active Directory) by name; groups with @.')),
          h('p', {class: 'hint'}, t('None ticked: every user with an SMB password.')),
          h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'guest', checked: s.guest}),
            t('Guests without login')));
+118 −56
Changes for www/modules/users.js: 118 added lines, 56 removed lines.
Original line number Diff line number Diff line
@@ -102,7 +102,20 @@
    'With Kerberos the connection is protected without TLS (encryption "none" is fine) and needs no DC certificate; the clock must be within 5 minutes of the DCs\' and the domain controllers must be given by DNS name.':
      'Mit Kerberos ist die Verbindung ohne TLS geschützt (Verschlüsselung „keine“ genügt) und braucht kein DC-Zertifikat; die Uhr darf höchstens 5 Minuten von der der DCs abweichen, und die Domänencontroller müssen als DNS-Namen angegeben sein.',
    'Kerberos': 'Kerberos', 'simple bind': 'einfacher Bind',
    'none (sealed by Kerberos)': 'keine (von Kerberos verschlüsselt)', 'none (port 389; for Kerberos)': 'keine (Port 389; für Kerberos)',
    'none (sealed by Kerberos)': 'keine (von Kerberos verschlüsselt)',
    'Add domain …': 'Domäne hinzufügen …', 'Add domain': 'Domäne hinzufügen', 'Join': 'Beitreten',
    'Administrator': 'Administrator', 'Cache …': 'Cache …', 'No domain yet.': 'Noch keine Domäne.',
    'Add a domain with an administrator account: this system gets a computer account there, everything else is found automatically.':
      'Eine Domäne mit einem Administratorkonto hinzufügen: Dieses System bekommt dort ein Computerkonto, alles andere wird automatisch ermittelt.',
    'This system gets a computer account in the domain and logs in with it (Kerberos); the administrator\'s password is used for this only and not stored. The domain controllers are found in DNS.':
      'Dieses System bekommt ein Computerkonto in der Domäne und meldet sich damit an (Kerberos); das Passwort des Administrators wird nur dafür verwendet und nicht gespeichert. Die Domänencontroller werden über DNS gefunden.',
    '{0} joined as computer {1}.': '{0} beigetreten als Computer {1}.', 'Remove domain {0}': 'Domäne {0} entfernen',
    'Its users and groups are no longer known here. The computer account stays in the domain.':
      'Ihre Benutzer und Gruppen sind hier nicht mehr bekannt. Das Computerkonto bleibt in der Domäne.',
    'Domain {0} removed.': 'Domäne {0} entfernt.', 'Domain {0}': 'Domäne {0}', 'Names': 'Namen', 'plain': 'ohne Domäne',
    'user@{0}': 'benutzer@{0}', 'Names with the domain (user@domain)': 'Namen mit Domäne (benutzer@domäne)',
    'Home directory (%d domain, %n user, %u full name)': 'Home-Verzeichnis (%d Domäne, %n Benutzer, %u voller Name)',
    'automatic': 'automatisch', 'Settings saved.': 'Einstellungen gespeichert.', 'none (port 389; for Kerberos)': 'keine (Port 389; für Kerberos)',
    'Add': 'Hinzufügen', 'error': 'Fehler',
  }});

@@ -398,40 +411,68 @@

  const stateBadge = {running: 'ok', starting: 'busy', stopping: 'busy', failed: 'danger', missing: 'warn', unknown: 'warn'};

  function adSummary(a) {
    const c = a.config, row = (label, value) => h('tr', {}, h('th', {}, label), h('td', {}, value));
  function adStatus(a) {
    const svc = a.service || {};
    const inUse = a.listed && svc.enabled === 'enabled';
    const tls = {ldaps: 'LDAPS', starttls: 'StartTLS',
                 none: c.auth === 'kerberos' ? t('none (sealed by Kerberos)') : t('none (password in the clear!)')}[c.tls || 'ldaps'];
    const mapping = {rid: 'RID', rfc2307: 'RFC 2307', guid: 'GUID'}[c.id_mapping || 'rid'];
    const offline = c.offline_ttl === undefined ? 259200 : Number(c.offline_ttl);
    return h('div', {class: 'table-wrap'}, h('table', {class: 'facts'}, h('tbody', {},
      row(t('Status'), [h('span', {class: 'badge ' + (inUse ? 'ok' : '')}, inUse ? t('In use') : t('Not in use')), ' ',
    const inUse = a.listed && a.domains.length && svc.enabled === 'enabled';
    return h('p', {},
      h('span', {class: 'badge ' + (inUse ? 'ok' : '')}, inUse ? t('In use') : t('Not in use')), ' ',
      h('span', {class: 'muted'}, t('Service') + ': '),
      h('span', {class: 'badge ' + (stateBadge[svc.state] || '')}, t(svc.state || 'unknown')),
                        svc.error ? h('span', {class: 'muted'}, ' ' + svc.error) : null]),
      row(t('Domain'), h('strong', {}, c.domain || '')),
      row(t('Domain controllers'), h('span', {class: 'mono'}, (c.servers && c.servers.length ? c.servers : [c.domain]).join(' '))),
      row(t('Encryption'), tls),
      row(t('Login'), c.auth === 'kerberos' ? t('Kerberos') + (c.kerberos_principal ? ' (' + c.kerberos_principal + ')' : '') : t('simple bind')),
      row(t('IDs'), mapping + (!c.id_mapping || c.id_mapping === 'rid' ? ' (' + (c.id_base || 200000) + ' + RID)' : '')),
      row(t('Offline cache'), offline > 0 ? t('up to {0} h old', Math.round(offline / 3600)) : t('off')))));
      svc.error ? h('span', {class: 'muted'}, ' ' + svc.error) : null);
  }

  function domainTable(a) {
    return table([
      {title: t('Domain'), render: d => h('strong', {}, d.config.domain)},
      {title: t('Domain controllers'), class: 'mono',
       render: d => (d.config.servers && d.config.servers.length ? d.config.servers : [d.config.domain]).join(' ')},
      {title: t('Login'), render: d => d.config.auth === 'kerberos'
        ? t('Kerberos') + (d.config.kerberos_principal ? ' (' + d.config.kerberos_principal + ')' : '') : t('simple bind')},
      {title: t('IDs'), render: d => ({rid: 'RID', rfc2307: 'RFC 2307', guid: 'GUID'})[d.config.id_mapping || 'rid'] +
        (!d.config.id_mapping || d.config.id_mapping === 'rid' ? (d.config.id_base ? ' (' + d.config.id_base + ' + RID)' : '') : '')},
      {title: t('Names'), render: d => d.config.qualified_names ? t('user@{0}', d.config.domain) : t('plain')},
      {title: '', class: 'actions', render: d => [
        h('button', {class: 'small', onclick: () => domainDialog(a.domains.indexOf(d))}, t('Settings …')),
        h('button', {class: 'small danger', onclick: () => removeDomain(d.config.domain)}, t('Remove')),
      ]},
    ], a.domains);
  }

  function joinDialog() {
    dialog({
      title: t('Add domain'), submit: t('Join'),
      fields: () => [
        h('label', {}, t('Domain (DNS name)'), h('input', {name: 'domain', required: true, placeholder: 'example.com', autocomplete: 'off'})),
        h('label', {}, t('Administrator'), h('input', {name: 'admin', required: true, placeholder: 'Administrator', autocomplete: 'off'})),
        h('label', {}, t('Password'), h('input', {name: 'password', type: 'password', required: true, autocomplete: 'off'})),
        h('p', {class: 'hint'}, t('This system gets a computer account in the domain and logs in with it (Kerberos); the administrator\'s password is used for this only and not stored. The domain controllers are found in DNS.')),
      ],
      onSubmit: async d => {
        const r = await call('users', 'adconnectJoin', {domain: d.domain.trim(), admin: d.admin.trim(), password: d.password});
        toast(t('{0} joined as computer {1}.', d.domain.trim().toLowerCase(), r.principal));
        loadSources();
      },
    });
  }

  async function removeDomain(domain) {
    if (!await confirmAction(t('Remove domain {0}', domain),
      t('Its users and groups are no longer known here. The computer account stays in the domain.'), t('Remove'))) return;
    if (await run(() => call('users', 'adconnectRemove', {domain}), t('Domain {0} removed.', domain)) !== undefined) loadSources();
  }

  // the dialog's fields as adconnect.yaml's keys; empty numbers: the default
  function adConfigFrom(d) {
    const num = v => v === '' || v === undefined ? '' : Number(v);
    return {
      domain: d.domain.trim(), base: d.base.trim(), servers: d.servers.split(/[\s,]+/).filter(Boolean),
      domain: d.domain.trim().toLowerCase(), base: d.base.trim(), servers: d.servers.split(/[\s,]+/).filter(Boolean),
      auth: d.auth, kerberos_principal: d.kerberos_principal.trim(), keytab: d.keytab.trim(),
      kdcs: d.kdcs.split(/[\s,]+/).filter(Boolean),
      tls: d.tls, port: num(d.port), insecure: !!d.insecure, ca_file: d.ca_file.trim(), bind_dn: d.bind_dn.trim(),
      id_mapping: d.id_mapping, id_base: num(d.id_base), id_range: num(d.id_range),
      home: d.home.trim(), shell: d.shell.trim(), lowercase_names: !!d.lowercase_names, enumerate: !!d.enumerate,
      user_filter: d.user_filter.trim(), group_filter: d.group_filter.trim(),
      cache_ttl: num(d.cache_ttl), negative_ttl: num(d.negative_ttl), offline_ttl: num(d.offline_ttl),
      retry_interval: num(d.retry_interval), timeout: num(d.timeout),
      home: d.home.trim(), shell: d.shell.trim(), lowercase_names: !!d.lowercase_names,
      qualified_names: !!d.qualified_names, enumerate: !!d.enumerate,
      user_filter: d.user_filter.trim(), group_filter: d.group_filter.trim(), timeout: num(d.timeout),
    };
  }

@@ -446,9 +487,20 @@
    });
  }

  function adDialog() {
    const a = sources.adconnect, c = a.config;
    const v = (k, d = '') => c[k] === undefined ? d : c[k];
  // the form's values the way dialog() hands them to onSubmit
  function collect(form) {
    const d = {};
    for (const el of form.elements) {
      if (!el.name) continue;
      d[el.name] = el.type === 'checkbox' ? el.checked : el.value;
    }
    return d;
  }

  // every setting of one domain
  function domainDialog(index) {
    const a = sources.adconnect, dom = a.domains[index], c = dom.config;
    const v = (k, dflt = '') => c[k] === undefined ? dflt : c[k];
    const input = (name, label, attrs = {}) => h('label', {}, label, h('input', {name, value: v(name), ...attrs}));
    const number = (name, label, placeholder, min, max) =>
      input(name, label, {type: 'number', min, max, placeholder: String(placeholder)});
@@ -460,18 +512,16 @@
      out.hidden = false;
      out.textContent = t('Testing …');
      try {
        const r = await call('users', 'adconnectTest', {config: adConfigFrom(d), password: d.password || ''});
        const r = await call('users', 'adconnectTest', {domain: {config: adConfigFrom(d), password: d.password || ''}, global: a.global});
        out.textContent = (r.ok ? '✓ ' + t('Connection works.') : '✗') + (r.output ? '\n' + r.output : '');
      } catch (e) {
        out.textContent = '✗ ' + e.message;
      }
    };
    dialog({
      title: t('Active Directory settings'), wide: true,
      title: t('Domain {0}', c.domain), wide: true,
      fields: f => [
        (form = f, null),
        h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'enabled', checked: a.listed || !a.configured}),
          h('strong', {}, t('Use Active Directory for users and groups'))),
        h('h3', {}, t('Connection')),
        h('div', {class: 'row'},
          input('domain', t('Domain (DNS name)'), {required: true, placeholder: 'example.com'}),
@@ -489,66 +539,76 @@
        h('label', {}, t('Login'), h('select', {name: 'auth'},
          [['simple', t('Simple bind (password over LDAPS/StartTLS)')], ['kerberos', t('Kerberos (SASL GSSAPI, sealed by Kerberos)')]]
            .map(([k, l]) => h('option', {value: k, selected: v('auth', 'simple') === k}, l)))),
        h('p', {class: 'hint'}, t('With Kerberos the connection is protected without TLS (encryption "none" is fine) and needs no DC certificate; the clock must be within 5 minutes of the DCs\' and the domain controllers must be given by DNS name.')),
        h('div', {class: 'row'},
          input('bind_dn', t('Account (user@domain or DN)'), {placeholder: 'svc-fenriros@example.com', autocomplete: 'off'}),
          h('label', {}, t('Password'), h('input', {name: 'password', type: 'password', autocomplete: 'new-password',
                                                     placeholder: a.passwordSet ? t('unchanged') : ''}))),
        h('p', {class: 'hint'}, t('Stored root-only in /etc/adconnect/bind.secret.')),
                                                     placeholder: dom.passwordSet ? t('unchanged') : ''}))),
        h('div', {class: 'row'},
          input('kerberos_principal', t('Kerberos principal (empty: the user part of the account)'), {placeholder: 'svc-fenriros', autocomplete: 'off'}),
          input('keytab', t('Keytab (instead of the password, optional)'), {placeholder: '/etc/adconnect/svc.keytab'})),
        h('label', {}, t('KDCs (space separated, empty: the domain controllers)'),
          h('input', {name: 'kdcs', value: (c.kdcs || []).join(' '), placeholder: 'dc1.example.com'})),
        h('p', {class: 'hint'}, t('With Kerberos the connection is protected without TLS (encryption "none" is fine) and needs no DC certificate; the clock must be within 5 minutes of the DCs\' and the domain controllers must be given by DNS name.')),
        h('h3', {}, t('ID mapping')),
        h('label', {}, t('ID mapping'), h('select', {name: 'id_mapping'},
          [['rid', t('id_base + RID of the SID (classic ids)')], ['rfc2307', t('uidNumber / gidNumber (RFC 2307)')],
           ['guid', t('objectGUID as GUID id (programs built for <sys/uid_r.h>)')]]
            .map(([k, l]) => h('option', {value: k, selected: v('id_mapping', 'rid') === k}, l)))),
        h('div', {class: 'row'},
          number('id_base', t('First id'), 200000, 1000, 4294967294),
          number('id_base', t('First id'), t('automatic'), 1000, 4294967294),
          number('id_range', t('Id range'), 2000000, 1, 4294967294)),
        h('h3', {}, t('Accounts')),
        check('qualified_names', t('Names with the domain (user@domain)'), a.domains.length > 1),
        h('div', {class: 'row'},
          input('home', t('Home directory (%u user, %d domain)'), {placeholder: '/home/%u'}),
          input('home', t('Home directory (%d domain, %n user, %u full name)'), {placeholder: '/home/%d/%n'}),
          input('shell', t('Shell'), {placeholder: '/bin/sh'})),
        check('lowercase_names', t('Lower case names'), true),
        check('enumerate', t('List the whole domain (getpwent; slow in big domains)'), false),
        input('user_filter', t('User filter (LDAP, optional)'),
              {placeholder: '(memberOf:1.2.840.113556.1.4.1941:=CN=Linux Users,OU=Groups,DC=example,DC=com)'}),
        input('group_filter', t('Group filter (LDAP, optional)')),
        h('h3', {}, t('Cache')),
        number('timeout', t('Timeout per request (s)'), 10, 1, 300),
        h('button', {type: 'button', onclick: testNow}, t('Test these settings')),
        out = h('pre', {class: 'output', hidden: true}),
      ],
      onSubmit: async d => {
        const domains = a.domains.map((x, i) => i === index ? {config: adConfigFrom(d), password: d.password || ''} : {config: x.config});
        await call('users', 'adconnectSave', {global: a.global, domains, enabled: a.listed});
        toast(t('Active Directory settings saved.'));
        loadSources();
      },
    });
  }

  // what every domain shares: the cache
  function cacheDialog() {
    const a = sources.adconnect, g = a.global || {};
    const number = (name, label, placeholder, min, max) => h('label', {}, label,
      h('input', {name, type: 'number', min, max, value: g[name] === undefined ? '' : g[name], placeholder: String(placeholder)}));
    dialog({
      title: t('Cache'),
      fields: () => [
        h('div', {class: 'row'},
          number('cache_ttl', t('Answers kept (s)'), 300, 0, 86400),
          number('negative_ttl', t('Unknown names kept (s)'), 60, 0, 86400)),
        h('div', {class: 'row'},
          number('offline_ttl', t('Offline: answers up to (s, 0: off)'), 259200, 0, 31536000),
          number('retry_interval', t('Retry after (s)'), 30, 1, 3600),
          number('timeout', t('Timeout per request (s)'), 10, 1, 300)),
          number('retry_interval', t('Retry after (s)'), 30, 1, 3600)),
        h('p', {class: 'hint'}, t('While no domain controller is reachable, users and groups come from the cache (kept on disk).')),
        h('button', {type: 'button', onclick: testNow}, t('Test these settings')),
        out = h('pre', {class: 'output', hidden: true}),
      ],
      onSubmit: async d => {
        await call('users', 'adconnectSave', {config: adConfigFrom(d), password: d.password || '', enabled: !!d.enabled});
        toast(t('Active Directory settings saved.'));
        const num = v => v === '' ? '' : Number(v);
        const global = {...g, cache_ttl: num(d.cache_ttl), negative_ttl: num(d.negative_ttl),
                        offline_ttl: num(d.offline_ttl), retry_interval: num(d.retry_interval)};
        await call('users', 'adconnectSave', {global, domains: a.domains.map(x => ({config: x.config})), enabled: a.listed});
        toast(t('Settings saved.'));
        loadSources();
      },
    });
  }

  // the form's values the way dialog() hands them to onSubmit
  function collect(form) {
    const d = {};
    for (const el of form.elements) {
      if (!el.name) continue;
      d[el.name] = el.type === 'checkbox' ? el.checked : el.value;
    }
    return d;
  }

  async function adTest() {
    const r = await run(() => call('users', 'adconnectTest', {config: sources.adconnect.config}));
    const r = await run(() => call('users', 'adconnectTest', {}));
    if (r) showTestResult(r);
  }

@@ -647,11 +707,13 @@
        h('div', {class: 'toolbar'},
          h('h2', {}, t('Active Directory')),
          a.installed ? h('button', {onclick: loadSources}, t('Refresh')) : null,
          a.installed && a.configured ? h('button', {onclick: adTest}, t('Test connection')) : null,
          a.installed ? h('button', {class: 'primary', onclick: adDialog}, a.configured ? t('Settings …') : t('Set up …')) : null),
          a.installed && a.domains.length ? h('button', {onclick: adTest}, t('Test connection')) : null,
          a.installed && a.domains.length ? h('button', {onclick: cacheDialog}, t('Cache …')) : null,
          a.installed ? h('button', {class: 'primary', onclick: joinDialog}, t('Add domain …')) : null),
        !a.installed ? h('p', {class: 'hint'}, t('adconnect isn\'t installed. Install the package adconnect to use an Active Directory domain.'))
          : !a.configured ? h('p', {class: 'empty'}, t('Not set up yet.'))
          : [adSummary(a), h('h3', {}, t('Look up')), lookupPanel()]),
          : !a.domains.length ? [h('p', {class: 'empty'}, t('No domain yet.')),
                                 h('p', {class: 'hint'}, t('Add a domain with an administrator account: this system gets a computer account there, everything else is found automatically.'))]
          : [adStatus(a), domainTable(a), h('h3', {}, t('Look up')), lookupPanel()]),
      h('section', {class: 'panel'},
        h('div', {class: 'toolbar'},
          h('h2', {}, t('Order of the sources')),