OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

intune.php

38 KB
<?php $this->layout('layouts/admin', [
    'page_title'         => $page_title,
    'current_admin_page' => 'intune-mdm',
    'breadcrumbs'        => [['label' => 'Microsoft Intune']],
]); ?>
<style>
/* Nur fuer die Sync-Ergebnis-Boxen – alles andere nutzt die Standard-Admin-Styles. */
.int-result {
    border: 1px solid var(--green-200);
    background: var(--green-50);
    color: var(--green-700);
    border-radius: var(--radius, .5rem);
    padding: .9rem 1.1rem;
    font-size: .875rem;
    line-height: 1.5;
}
.int-result.int-result-error { background:var(--red-50); border-color:var(--red-300); color:var(--red-700); }
.int-result strong { display:block; margin-bottom:.35rem; }
.int-result ul { margin:.5rem 0 0; padding-left:1.2rem; }
.int-result ul.int-errs { color:var(--red-800); }
.int-result li { margin:.1rem 0; }
#int-sync-result { display:none; margin-top:1rem; }
#int-test-result { font-size:.83rem; }
</style>

<?php if ($last_result): ?>
<div class="int-result <?= empty($last_result['errors']) ? '' : 'int-result-error' ?>" style="margin-bottom:1rem;">
    <strong><?= $this->te('Letzter Sync: {zeitpunkt}', ['zeitpunkt' => $last_sync ?? '–']) ?></strong>
    <?= $this->te('{anzahl} neu angelegt', ['anzahl' => (int)($last_result['created'] ?? 0)]) ?> &nbsp;·&nbsp;
    <?= $this->te('{anzahl} aktualisiert', ['anzahl' => (int)($last_result['updated'] ?? 0)]) ?> &nbsp;·&nbsp;
    <?= $this->te('{anzahl} verkn&uuml;pft', ['anzahl' => (int)($last_result['linked'] ?? 0)]) ?> &nbsp;·&nbsp;
    <?= $this->te('{anzahl} unbekannt', ['anzahl' => (int)($last_result['unmatched'] ?? 0)]) ?> &nbsp;·&nbsp;
    <?= $this->te('{anzahl} dekommissioniert', ['anzahl' => (int)($last_result['decommissioned'] ?? 0)]) ?>
    <?php if (!empty($last_result['renamed'])): ?>
        &nbsp;·&nbsp; <?= $this->te('{anzahl} in Intune umbenannt', ['anzahl' => (int)$last_result['renamed']]) ?>
    <?php endif; ?>
    <?php if (!empty($last_result['unmatched_removed'])): ?>
        &nbsp;·&nbsp; <?= (int)$last_result['unmatched_removed'] ?> aus Unknown-Liste entfernt
    <?php endif; ?>
    <?php if (($last_result['trigger'] ?? '') === 'cron'): ?>
        <span style="font-size:.72rem;color:var(--gray-400);"><?= $this->te('(automatisch)') ?></span>
    <?php endif; ?>
    <?php if (!empty($last_result['errors'])): ?>
    <ul class="int-errs">
        <?php foreach (array_slice($last_result['errors'], 0, 5) as $err): ?>
        <li><?= $this->e($err) ?></li>
        <?php endforeach; ?>
    </ul>
    <?php endif; ?>
</div>
<?php endif; ?>

<?php if (isset($secret_expiry_days) && $secret_expiry_days !== null && $secret_expiry_days <= 30): ?>
<div class="alert <?= $secret_expiry_days < 0 ? 'alert-danger' : 'alert-warning' ?>" style="margin-bottom:1rem;font-size:.88rem;">
    <i class="fas fa-key"></i>
    <?php if ($secret_expiry_days < 0): ?>
        <?= $this->t('<strong>Client-Secret abgelaufen</strong> (seit {tage} Tag(en)). Der Sync schl&auml;gt fehl, bis ein neues Secret hinterlegt wird.', ['tage' => abs((int)$secret_expiry_days)]) ?>
    <?php else: ?>
        <?= $this->t('<strong>Client-Secret l&auml;uft in {tage} Tag(en) ab.</strong> Rechtzeitig in Entra ID ein neues Geheimnis erstellen und hier eintragen.', ['tage' => (int)$secret_expiry_days]) ?>
    <?php endif; ?>
</div>
<?php endif; ?>

<!-- ═══ Einstellungen ════════════════════════════════════════════════ -->
<form method="post" action="/admin/inventory/intune/save">
    <input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">

    <div class="card card-outline card-primary mb-3">
        <div class="card-header">
            <h3 class="card-title"><i class="fas fa-cloud"></i> <?= $this->te('Verbindungseinstellungen') ?></h3>
            <p class="text-muted small mb-0">
                <?= $this->t('Verbindung zu Microsoft Intune via Microsoft Graph. Verwendet die unter <a href="/admin/settings/m365">Einstellungen &rarr; M365</a> konfigurierte App-Registrierung; ben&ouml;tigt die <strong>Application-Permission</strong> <code>DeviceManagementManagedDevices.Read.All</code>.') ?>
            </p>
        </div>
        <div class="card-body">

            <!-- Zugangsdaten kommen aus den Microsoft-365-Einstellungen (wie der Verzeichnis-Sync) -->
            <div style="padding:1rem;background:var(--blue-50);border:1px solid var(--blue-200);border-radius:0.5rem;font-size:0.875rem;margin-bottom:1rem;">
                <strong><?= $this->te('Microsoft-365-Zugangsdaten') ?></strong><br>
                <?= $this->t('Tenant-ID, Client-ID und Client-Secret werden aus <a href="/admin/settings/m365" style="color:var(--blue-600);">Einstellungen &rarr; M365</a> übernommen (gemeinsame App mit Anmeldung, Lizenz- und Verzeichnis-Sync).') ?><br>
                <?php if (empty($cfg['uses_m365'])): ?>
                <div style="margin-top:0.75rem;color:var(--red-600);"><?= $this->t('⚠ M365-Zugangsdaten fehlen. Bitte unter <a href="/admin/settings/m365">M365-Einstellungen</a> Tenant-ID, Client-ID und Secret hinterlegen.') ?></div>
                <?php else: ?>
                <div style="margin-top:0.75rem;color:var(--green-600);"><?= $this->te('✓ Tenant ID:') ?> <code><?= $this->e($cfg['tenant_id']) ?></code></div>
                <?php endif; ?>
                <div style="margin-top:0.75rem;padding-top:0.75rem;border-top:1px dashed var(--blue-200);">
                    <strong><?= $this->te('Benötigte Berechtigung in der App-Registrierung:') ?></strong>
                    <ul style="margin:0.4rem 0 0 1.2rem;padding:0;">
                        <li><?= $this->t('Microsoft Graph &rarr; <strong>Anwendungsberechtigung</strong> (Application, <em>nicht</em> Delegiert): <code>DeviceManagementManagedDevices.Read.All</code>') ?></li>
                        <li><?= $this->t('Anschließend <em>Administratorzustimmung erteilen</em>') ?></li>
                    </ul>
                    <small style="color:var(--gray-500);"><?= $this->te('Der Sync läuft als App (Client-Credentials) – delegierte Berechtigungen genügen nicht und führen zu „Forbidden".') ?></small>
                </div>
            </div>

            <div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;margin-bottom:.5rem;">
                <button type="button" id="int-test-btn" class="btn btn-default">
                    <i class="fas fa-plug"></i> <?= $this->te('Verbindung testen') ?>
                </button>
                <button type="button" id="int-perm-btn" class="btn btn-default">
                    <i class="fas fa-shield-alt"></i> <?= $this->te('Berechtigungen prüfen') ?>
                </button>
                <span id="int-test-result"></span>
            </div>
            <div id="int-perm-result" style="margin-bottom:.75rem;"></div>

            <hr>

            <div class="form-group">
                <label><?= $this->te('Betriebssysteme synchronisieren') ?></label>
                <div style="display:flex;flex-wrap:wrap;gap:.5rem 1.25rem;margin-top:.3rem;">
                    <?php
                    $osList = ['Windows' => 'Windows', 'macOS' => 'macOS',
                               'iOS' => 'iOS / iPadOS', 'Android' => 'Android'];
                    foreach ($osList as $val => $label):
                        $checked = in_array($val, $cfg['os_filter'], true) ? 'checked' : '';
                    ?>
                    <label style="display:inline-flex;align-items:center;gap:.4rem;cursor:pointer;font-weight:400;">
                        <input type="checkbox" name="intune_os_filter[]" value="<?= $val ?>" <?= $checked ?>>
                        <span><?= $label ?></span>
                    </label>
                    <?php endforeach; ?>
                </div>
                <small class="form-text text-muted"><?= $this->te('Keine Auswahl = alle Betriebssysteme importieren.') ?></small>
            </div>

            <hr>

            <div class="form-group">
                <label><i class="fas fa-th-large"></i> <?= $this->te('Kategorien nach Ger&auml;teklasse') ?></label>
                <div class="form-row-2">
                    <?php
                    $catDropdowns = [
                        ['id'=>'int-cat-desktop',   'name'=>'intune_category_desktop',     'selected'=>$cfg['category_desktop'],   'label'=>'Desktop (Windows)','icon'=>'fa-desktop'],
                        ['id'=>'int-cat-laptop',    'name'=>'intune_category_laptop',      'selected'=>$cfg['category_laptop'],    'label'=>'Notebook / Laptop','icon'=>'fa-laptop'],
                        ['id'=>'int-cat-mac',       'name'=>'intune_category_mac',         'selected'=>$cfg['category_mac'],       'label'=>'Mac (macOS)','icon'=>'fa-laptop'],
                        ['id'=>'int-cat-smartphone','name'=>'intune_category_smartphone',  'selected'=>$cfg['category_smartphone'],'label'=>'Smartphone','icon'=>'fa-mobile-alt'],
                        ['id'=>'int-cat-tablet',    'name'=>'intune_category_tablet',      'selected'=>$cfg['category_tablet'],    'label'=>'Tablet','icon'=>'fa-tablet-alt'],
                        ['id'=>'int-cat-default',   'name'=>'intune_default_category_id',  'selected'=>$cfg['default_category_id'],'label'=>'Fallback-Kategorie','icon'=>'fa-question-circle'],
                    ];
                    foreach ($catDropdowns as $dd):
                    ?>
                    <div class="form-group">
                        <label for="<?= $dd['id'] ?>"><i class="fas <?= $dd['icon'] ?>"></i> <?= $dd['label'] ?></label>
                        <select id="<?= $dd['id'] ?>" name="<?= $dd['name'] ?>" class="form-control">
                            <option value="0"><?= $this->te('– Keine Zuweisung (Fallback) –') ?></option>
                            <?php foreach ($categories as $cat): ?>
                            <option value="<?= (int)$cat['id'] ?>"
                                <?= $dd['selected'] == $cat['id'] ? 'selected' : '' ?>>
                                <?= $this->e($cat['name']) ?>
                            </option>
                            <?php endforeach; ?>
                        </select>
                    </div>
                    <?php endforeach; ?>
                </div>
                <small class="form-text text-muted">
                    <?= $this->t('Die Ger&auml;teklasse wird automatisch aus Betriebssystem, dem Intune-Feld <code>chassisType</code>/<code>deviceType</code> sowie dem Modellnamen ermittelt. &bdquo;Fallback&ldquo; greift, wenn keine Klasse erkannt wird oder die jeweilige Kategorie leer gelassen wurde.') ?>
                </small>
            </div>

            <div class="alert alert-info" style="font-size:.83rem;margin-bottom:0;">
                <i class="fas fa-info-circle"></i>
                <?= $this->t('Geräte werden zuerst über die Intune-Geräte-ID, andernfalls über die <strong>Seriennummer</strong> einem bereits vorhandenen Asset zugeordnet (manuell angelegt oder aus Relution migriert) &ndash; es entstehen keine Duplikate.') ?>
            </div>

            <hr>

            <div class="form-group">
                <label><i class="fas fa-inbox"></i> <?= $this->te('Neue / unbekannte Ger&auml;te') ?></label>
                <label style="display:flex;align-items:flex-start;gap:.5rem;cursor:pointer;font-weight:400;">
                    <input type="checkbox" name="intune_auto_create_assets" style="margin-top:.2rem;"
                           <?= $cfg['auto_create_assets'] ? 'checked' : '' ?>>
                    <span>
                        <?= $this->te('Nicht zuordenbare Ger&auml;te automatisch als Asset anlegen') ?><br>
                        <small class="text-muted">
                            <?= $this->t('<strong>Aus:</strong> Ger&auml;te, die keinem vorhandenen Asset (Ger&auml;te-ID oder Seriennummer) zugeordnet werden k&ouml;nnen, landen in der <a href="/admin/inventory/unmatched-devices">Unbekannte-Ger&auml;te-Liste</a> zur manuellen Pr&uuml;fung. <strong>An:</strong> sie werden sofort mit der je Ger&auml;teklasse gew&auml;hlten Kategorie als neues Asset angelegt.') ?>
                        </small>
                    </span>
                </label>

                <label style="display:flex;align-items:flex-start;gap:.5rem;cursor:pointer;font-weight:400;margin-top:.6rem;">
                    <input type="checkbox" name="intune_number_from_category" style="margin-top:.2rem;"
                           <?= $cfg['number_from_category'] ? 'checked' : '' ?>>
                    <span>
                        <?= $this->te('Inventarnummer aus dem Nummernkreis der Kategorie vergeben') ?><br>
                        <small class="text-muted">
                            <?= $this->t('<strong>Aus:</strong> neue Ger&auml;te erhalten die Seriennummer als Inventarnummer. <strong>An:</strong> sie erhalten die n&auml;chste freie Nummer aus dem Nummernkreis ihrer Kategorie &ndash; wie bei der manuellen Anlage (Format je Kategorie unter <a href="/admin/inventory/categories">Kategorien</a>).') ?>
                        </small>
                    </span>
                </label>

                <label style="display:flex;align-items:flex-start;gap:.5rem;cursor:pointer;font-weight:400;margin-top:.6rem;">
                    <input type="checkbox" name="intune_writeback_name" style="margin-top:.2rem;"
                           <?= $cfg['writeback_name'] ? 'checked' : '' ?>>
                    <span>
                        <?= $this->te('Vergebene Inventarnummer als Ger&auml;tename nach Intune zur&uuml;ckschreiben') ?><br>
                        <small class="text-muted">
                            <?= $this->t('Benennt das Ger&auml;t in Intune beim Anlegen auf die vergebene Inventarnummer um (Graph-Aktion <code>setDeviceName</code>). Erfordert zus&auml;tzlich die Application-Permission <code>DeviceManagementManagedDevices.PrivilegedOperations.All</code> mit Admin-Einwilligung. Bei Windows-Ger&auml;ten wird der <strong>Computername</strong> ge&auml;ndert (wirksam nach Neustart, max. 15 Zeichen, keine Leer-/Sonderzeichen au&szlig;er Bindestrich &ndash; das Nummernformat der Kategorie sollte dazu passen).') ?>
                        </small>
                    </span>
                </label>
            </div>

            <hr>

            <div class="form-group">
                <label style="display:flex;align-items:flex-start;gap:.5rem;cursor:pointer;font-weight:400;">
                    <input type="checkbox" name="intune_primary_user" style="margin-top:.2rem;"
                           <?= $cfg['primary_user'] ? 'checked' : '' ?>>
                    <span>
                        <?= $this->te('Prim&auml;ren Benutzer &uuml;bernehmen statt der registrierenden Person') ?><br>
                        <small class="text-muted">
                            <?= $this->t('Intune f&uuml;hrt beides getrennt: Am Ger&auml;t steht, <em>wer es registriert hat</em>; der <strong>prim&auml;re Benutzer</strong> ist die Person, der es zugedacht ist. Bereitet die IT Ger&auml;te vor der Ausgabe vor, sind das zwei verschiedene &ndash; und &bdquo;Zugewiesen an&ldquo; zeigt dann die IT statt der Person, die das Ger&auml;t hat.<br> Der prim&auml;re Benutzer steht nicht in der Ger&auml;teliste. OpenNIT versucht, ihn mitliefern zu lassen; geht das nicht, wird er <strong>je Ger&auml;t einzeln</strong> geholt &ndash; bei vielen Ger&auml;ten dauert der Abgleich dadurch sp&uuml;rbar l&auml;nger. Meldet Intune keinen prim&auml;ren Benutzer, bleibt es bei der registrierenden Person.') ?>
                        </small>
                    </span>
                </label>

                <label style="display:flex;align-items:flex-start;gap:.5rem;cursor:pointer;font-weight:400;margin-top:.6rem;">
                    <input type="checkbox" name="intune_writeback_user" style="margin-top:.2rem;"
                           <?= $cfg['writeback_user'] ? 'checked' : '' ?>>
                    <span>
                        <?= $this->te('&Auml;nderungen an &bdquo;Zugewiesen an&ldquo; nach Intune zur&uuml;ckschreiben') ?><br>
                        <small class="text-muted">
                            <?= $this->t('Ohne diesen Schalter ist das Feld bei Intune-Ger&auml;ten nur lesbar, weil der n&auml;chste Abgleich eine Eingabe ohnehin &uuml;berschreibt. Mit ihm geht die &Auml;nderung den umgekehrten Weg: erst nach Intune als prim&auml;rer Benutzer, und nur wenn das gelingt, auch in OpenNIT.<br> Nur Personen aus dem <strong>Entra-Verzeichnis</strong> sind m&ouml;glich &ndash; Intune kennt keine Personen aus LDAP oder einem CSV-Import. Erfordert dieselbe Application-Permission wie das Umbenennen (<code>DeviceManagementManagedDevices.PrivilegedOperations.All</code>).<br> Setzt den Schalter dar&uuml;ber voraus: Ohne das &Uuml;bernehmen des prim&auml;ren Benutzers l&auml;se der n&auml;chste Abgleich wieder die registrierende Person und die &Auml;nderung w&auml;re still verloren. Wird deshalb mit eingeschaltet.') ?>
                        </small>
                    </span>
                </label>

                <label style="display:flex;align-items:flex-start;gap:.5rem;cursor:pointer;font-weight:400;margin-top:.6rem;">
                    <input type="checkbox" name="intune_delete_device" style="margin-top:.2rem;"
                           <?= $cfg['delete_device'] ? 'checked' : '' ?>>
                    <span>
                        <?= $this->te('Beim L&ouml;schen eines Ger&auml;ts auch aus der Intune-Verwaltung entfernen') ?><br>
                        <small class="text-muted">
                            <?= $this->t('Wird ein Ger&auml;t im Inventar gel&ouml;scht, wird der Verwaltungsdatensatz in Intune mit entfernt. Das Ger&auml;t selbst wird dabei <strong>weder zur&uuml;ckgesetzt noch gel&ouml;scht</strong> &ndash; es ist danach nur unverwaltet und m&uuml;sste neu registriert werden.<br> Gilt nur beim L&ouml;schen <strong>eines einzelnen</strong> Ger&auml;ts; Sammelaktionen bleiben rein &ouml;rtlich, damit ein Fehlgriff nicht auf viele Ger&auml;te im Mandanten durchschl&auml;gt. Scheitert das Entfernen in Intune, wird das Ger&auml;t auch hier <strong>nicht</strong> gel&ouml;scht &ndash; sonst w&auml;re es in OpenNIT weg und liefe in Intune weiter.') ?>
                        </small>
                    </span>
                </label>

                <div style="margin:.6rem 0 0 1.6rem;">
                    <div style="font-size:.85rem;color:var(--gray-600);margin-bottom:.35rem;">
                        <?= $this->te('Vorauswahl beim L&ouml;schen &ndash; entschieden wird je Ger&auml;t:') ?>
                    </div>
                    <?php foreach (\OpenNIT\Services\IntuneSyncService::REMOVAL_ACTIONS as $aktionKey => $aktion): ?>
                    <label style="display:flex;align-items:flex-start;gap:.5rem;cursor:pointer;font-weight:400;margin-bottom:.3rem;">
                        <input type="radio" name="intune_delete_action" value="<?= $this->e($aktionKey) ?>" style="margin-top:.25rem;"
                               <?= ($cfg['delete_action'] ?? 'delete') === $aktionKey ? 'checked' : '' ?>>
                        <span><?= $this->e($aktion['label']) ?></span>
                    </label>
                    <?php endforeach; ?>
                    <small class="text-muted" style="display:block;margin-top:.35rem;">
                        <?= $this->t('&bdquo;Firmendaten entfernen&ldquo; und &bdquo;Zur&uuml;cksetzen&ldquo; stellen in Intune einen <strong>Auftrag</strong> ein, den das Ger&auml;t beim n&auml;chsten Kontakt ausf&uuml;hrt &ndash; der Verwaltungsdatensatz verschwindet dort also erst danach, bei einem Ger&auml;t ohne Verbindung unter Umst&auml;nden lange nicht. Das Zur&uuml;cksetzen l&ouml;scht <strong>alle Daten</strong> auf dem Ger&auml;t und l&auml;sst sich nicht zur&uuml;cknehmen; es muss beim L&ouml;schen eigens best&auml;tigt werden. Alle drei Wege erfordern die Application-Permission <code>DeviceManagementManagedDevices.PrivilegedOperations.All</code>.') ?>
                    </small>
                </div>
            </div>

            <hr>

            <div class="form-group">
                <label style="display:flex;align-items:flex-start;gap:.5rem;cursor:pointer;font-weight:400;">
                    <input type="checkbox" name="intune_sync_policies" style="margin-top:.2rem;"
                           <?= $cfg['sync_policies'] ? 'checked' : '' ?>>
                    <span>
                        <?= $this->te('Intune-Richtlinien je Ger&auml;t laden (Compliance &amp; Konfiguration)') ?><br>
                        <small class="text-muted">
                            <?= $this->te('Zeigt zugewiesene Richtlinien und deren Status als eigenen Tab am Asset an. Erfordert zus&auml;tzliche Graph-Aufrufe pro Ger&auml;t &ndash; bei sehr vielen Ger&auml;ten l&auml;uft der Sync entsprechend l&auml;nger.') ?>
                        </small>
                    </span>
                </label>
            </div>

            <div class="form-group">
                <label style="display:flex;align-items:flex-start;gap:.5rem;cursor:pointer;font-weight:400;">
                    <input type="checkbox" name="intune_use_beta" style="margin-top:.2rem;"
                           <?= $cfg['use_beta'] ? 'checked' : '' ?>>
                    <span>
                        <?= $this->te('Erweiterte Hardware-Infos via Graph-') ?><code>beta</code> <?= $this->te('laden') ?><br>
                        <small class="text-muted">
                            <?= $this->t('Holt zus&auml;tzliche Felder wie TPM, lokale IP, OS-Edition und (sofern verf&uuml;gbar) Arbeitsspeicher aus <code>hardwareInformation</code>. Nutzt den beta-Endpoint von Microsoft Graph &ndash; Felder k&ouml;nnen je nach Ger&auml;t leer bleiben.') ?>
                        </small>
                    </span>
                </label>
            </div>

            <div class="form-group">
                <label style="display:flex;align-items:flex-start;gap:.5rem;cursor:pointer;font-weight:400;">
                    <input type="checkbox" name="intune_decommission_missing" style="margin-top:.2rem;"
                           <?= $cfg['decommission_missing'] ? 'checked' : '' ?>>
                    <span>
                        <?= $this->te('Ger&auml;te, die nicht mehr in Intune stehen, auf &bdquo;Dekommissioniert&ldquo; setzen') ?><br>
                        <small class="text-muted"><?= $this->t('Achtung: Betrifft nur Assets mit <code>mdm_source = intune</code>.') ?></small>
                    </span>
                </label>
            </div>

            <div class="form-row-2">
                <div class="form-group">
                    <label for="int_grace"><?= $this->te('Karenzzeit vor dem Ausmustern (Tage)') ?></label>
                    <input type="number" class="form-control form-control-sm" id="int_grace" min="0" max="365"
                           name="intune_decommission_grace_days" value="<?= (int)($cfg['grace_days'] ?? 0) ?>">
                    <small class="form-text text-muted">
                        <?= $this->t('Ein Ger&auml;t wird erst ausgemustert, wenn es so viele Tage nicht mehr gemeldet wurde. <strong>0</strong> mustert wie bisher sofort aus.') ?>
                    </small>
                </div>
                <div class="form-group">
                    <label for="int_maxpct"><?= $this->te('H&ouml;chstanteil je Lauf (%)') ?></label>
                    <input type="number" class="form-control form-control-sm" id="int_maxpct" min="0" max="100"
                           name="intune_decommission_max_percent" value="<?= (int)($cfg['max_percent'] ?? 20) ?>">
                    <small class="form-text text-muted">
                        <?= $this->t('W&uuml;rde ein Lauf mehr als diesen Anteil der verwalteten Ger&auml;te ausmustern, unterbleibt die Ausmusterung und wird im Protokoll vermerkt. <strong>0</strong> schaltet die Pr&uuml;fung ab.') ?>
                    </small>
                </div>
            </div>

            <div class="form-group" style="margin-bottom:0;">
                <label style="display:flex;align-items:flex-start;gap:.5rem;cursor:pointer;font-weight:400;">
                    <input type="checkbox" name="intune_sync_enabled" style="margin-top:.2rem;"
                           <?= $cfg['sync_enabled'] ? 'checked' : '' ?>>
                    <span>
                        <?= $this->te('Automatischen Sync aktivieren (via Cronjob)') ?><br>
                        <small class="text-muted">
                            <?= $this->te('Wird vom Scheduler-Task &bdquo;Microsoft Intune Sync&ldquo; ausgef&uuml;hrt (Admin &rarr; Cronjobs). Alternativ') ?>
                            <code>/admin/inventory/intune/cron</code> <?= $this->te('aufrufen.') ?>
                        </small>
                    </span>
                </label>
            </div>

        </div>
    </div>

    <div class="form-actions">
        <button type="submit" class="btn btn-primary">
            <i class="fas fa-save"></i> <?= $this->te('Einstellungen speichern') ?>
        </button>
    </div>
</form>

<!-- ═══ Manueller Sync ════════════════════════════════════════════════ -->
<?php
/**
 * Welche Graph-Berechtigungen die App-Registrierung braucht.
 *
 * Abgeleitet aus den Aufrufen, die der Abgleich tatsaechlich macht. Die Liste
 * richtet sich nach den Schaltern oben: Was ausgeschaltet ist, braucht seine
 * Berechtigung nicht.
 */
$intunePflicht = [
    [
        'recht'  => 'DeviceManagementManagedDevices.Read.All',
        'wofuer' => 'Geräte und ihre Angaben lesen – die Grundlage jedes Abgleichs.',
        'noetig' => true,
    ],
    [
        'recht'  => 'User.Read.All',
        'wofuer' => 'Die am Gerät hinterlegte Person auflösen (Name und E-Mail).',
        'noetig' => !empty($cfg['primary_user']),
    ],
    [
        'recht'  => 'DeviceManagementManagedDevices.ReadWrite.All',
        'wofuer' => 'Die Zuordnung der Person zurückschreiben und ein Gerät aus der Verwaltung entfernen.',
        'noetig' => !empty($cfg['writeback_user'])
                    || (!empty($cfg['delete_device']) && ($cfg['delete_action'] ?? 'delete') === 'delete'),
    ],
    [
        'recht'  => 'DeviceManagementManagedDevices.PrivilegedOperations.All',
        'wofuer' => 'Gerätenamen setzen sowie „Firmendaten entfernen“ und „Auf Werkseinstellungen zurücksetzen“ auslösen.',
        'noetig' => !empty($cfg['writeback_name'])
                    || (!empty($cfg['delete_device']) && in_array($cfg['delete_action'] ?? 'delete', ['retire', 'wipe'], true)),
    ],
    [
        'recht'  => 'DeviceManagementConfiguration.Read.All',
        'wofuer' => 'Zugewiesene Richtlinien und Konfigurationsprofile mitlesen.',
        'noetig' => !empty($cfg['sync_policies']),
    ],
];
?>
<div class="card card-outline card-primary mb-3">
    <div class="card-header">
        <h3 class="card-title"><i class="fas fa-key"></i> <?= $this->te('Benötigte Berechtigungen') ?></h3>
        <p class="text-muted small mb-0">
            <?= $this->t('Als <strong>Anwendungsberechtigungen</strong> in der App-Registrierung (Entra ID &rarr; App-Registrierungen &rarr; API-Berechtigungen &rarr; Microsoft Graph), jeweils mit <strong>Administratorzustimmung</strong>.') ?>
        </p>
    </div>
    <div class="card-body">
        <div class="table-wrapper">
            <table class="data-table">
                <thead>
                    <tr><th scope="col"><?= $this->te('Berechtigung') ?></th><th scope="col"><?= $this->te('Wofür') ?></th><th scope="col" style="white-space:nowrap;"><?= $this->te('Jetzt nötig') ?></th></tr>
                </thead>
                <tbody>
                <?php foreach ($intunePflicht as $z): ?>
                    <tr<?= $z['noetig'] ? '' : ' style="opacity:.55;"' ?>>
                        <td><code><?= $this->e($z['recht']) ?></code></td>
                        <td><?= $z['wofuer'] ?></td>
                        <td>
                            <?php if ($z['noetig']): ?>
                            <span class="status-pill status-pill--success"><?= $this->te('ja') ?></span>
                            <?php else: ?>
                            <span class="status-pill status-pill--neutral"><?= $this->te('nur bei Bedarf') ?></span>
                            <?php endif; ?>
                        </td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
        </div>
        <p class="text-muted small" style="margin:.75rem 0 0;line-height:1.6;">
            <?= $this->t('„Nur bei Bedarf“ heißt: Die zugehörige Einstellung ist hier ausgeschaltet. Schalten Sie sie ein, muss die Berechtigung vorher erteilt sein – sonst antwortet Intune mit <code>Forbidden</code>. Das Zurücksetzen auf Werkseinstellungen ist eine privilegierte Aktion und verlangt dieselbe Berechtigung wie das Entfernen der Firmendaten.') ?>
        </p>
    </div>
</div>

<div class="card card-outline card-primary mb-3">
    <div class="card-header">
        <h3 class="card-title"><i class="fas fa-sync-alt"></i> <?= $this->te('Manueller Sync') ?></h3>
        <p class="text-muted small mb-0">
            <?= $this->t('<strong>Vorschau (Probelauf)</strong> zeigt, was ein Sync tun w&uuml;rde, ohne etwas zu speichern &ndash; ideal vor dem ersten echten Lauf. <strong>Sync jetzt starten</strong> f&uuml;hrt den Abgleich tats&auml;chlich aus.') ?>
        </p>
    </div>
    <div class="card-body">
        <?php $intDisabled = empty($cfg['tenant_id']) || empty($cfg['client_id']) || empty($cfg['client_secret']); ?>
        <div style="display:flex;gap:.5rem;flex-wrap:wrap;">
            <button type="button" id="int-preview-btn" class="btn btn-default"
                <?= $intDisabled ? 'disabled title="Bitte zuerst Verbindung konfigurieren"' : '' ?>>
                <i class="fas fa-search"></i> <?= $this->te('Vorschau (Probelauf)') ?>
            </button>
            <button type="button" id="int-sync-btn" class="btn btn-success"
                <?= $intDisabled ? 'disabled title="Bitte zuerst Verbindung konfigurieren"' : '' ?>>
                <i class="fas fa-play"></i> <?= $this->te('Sync jetzt starten') ?>
            </button>
        </div>
        <div id="int-sync-result"></div>
    </div>
</div>

<script>
(function () {
    // ── Verbindungstest ────────────────────────────────────────────
    document.getElementById('int-test-btn')?.addEventListener('click', function () {
        var btn    = this;
        var result = document.getElementById('int-test-result');
        btn.disabled    = true;
        btn.innerHTML   = 'Teste…';
        result.textContent = '';
        result.style.color = '#6b7280';

        fetch('/admin/inventory/intune/test')
        .then(function (r) { return r.json(); })
        .then(function (d) {
            result.textContent = d.ok ? '✓ ' + d.message : '✗ ' + d.message;
            result.style.color = d.ok ? '#15803d' : '#b91c1c';
        })
        .catch(function () {
            result.textContent = '✗ Verbindungsfehler';
            result.style.color = '#b91c1c';
        })
        .finally(function () {
            btn.disabled  = false;
            btn.innerHTML = '<i class="fas fa-plug"></i> Verbindung testen';
        });
    });

    // ── Berechtigungen prüfen ──────────────────────────────────────
    // Gefragt wird der Anspruch "roles" im App-Token: Dort steht genau das,
    // was vergeben UND bestätigt ist.
    document.getElementById('int-perm-btn')?.addEventListener('click', function () {
        var btn = this;
        var box = document.getElementById('int-perm-result');
        btn.disabled  = true;
        btn.innerHTML = <?= json_encode($this->t('Prüfe …'), JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
        box.innerHTML = '';

        fetch('/admin/inventory/intune/permissions')
        .then(function (r) { return r.json(); })
        .then(function (d) {
            var html = '';
            if (!d.erreichbar) {
                html = '<div class="int-result-error"><strong>✗ ' + esc(d.meldung) + '</strong></div>';
            } else if (d.ok) {
                html = '<div style="color:var(--green-700);"><strong>✓ ' + esc(d.meldung) + '</strong></div>';
            } else {
                html = '<div class="int-result-error"><strong>✗ ' + esc(d.meldung) + '</strong><ul style="margin:.4rem 0 0 1.1rem;">';
                (d.fehlend || []).forEach(function (f) {
                    html += '<li><code>' + esc(f.name) + '</code> – ' + esc(f.zweck) + '</li>';
                });
                html += '</ul><p style="margin:.5rem 0 0;">'
                     + <?= json_encode($this->t('In Entra unter <em>App-Registrierungen → API-Berechtigungen</em> als <strong>Anwendungsberechtigung</strong> eintragen und anschließend die Administrator-Zustimmung erteilen. Ohne die Zustimmung bleibt eine eingetragene Berechtigung wirkungslos.'), JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT) ?>
                     + '</p></div>';
            }
            if (d.gewaehrt && d.gewaehrt.length) {
                html += '<details style="margin-top:.5rem;"><summary style="cursor:pointer;color:var(--gray-600);">'
                     + <?= json_encode($this->t('Vergebene Berechtigungen anzeigen'), JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT) ?>
                     + ' (' + d.gewaehrt.length + ')</summary><ul style="margin:.4rem 0 0 1.1rem;">';
                d.gewaehrt.forEach(function (g) { html += '<li><code>' + esc(g) + '</code></li>'; });
                html += '</ul></details>';
            }
            box.innerHTML = html;
        })
        .catch(function () {
            box.innerHTML = '<div class="int-result-error">✗ '
                + <?= json_encode($this->t('Die Prüfung konnte nicht ausgeführt werden.'), JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT) ?>
                + '</div>';
        })
        .finally(function () {
            btn.disabled  = false;
            btn.innerHTML = '<i class="fas fa-shield-alt"></i> '
                + <?= json_encode($this->t('Berechtigungen prüfen'), JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
        });
    });

    // Fremde Angaben gehoeren maskiert in die Seite.
    function esc(t) {
        var d = document.createElement('div');
        d.textContent = t === null || t === undefined ? '' : String(t);
        return d.innerHTML;
    }

    // ── Gemeinsame Ergebnis-Darstellung ───────────────────────────
    function renderResult(d, isPreview) {
        var result = document.getElementById('int-sync-result');
        var cls    = d.ok ? '' : 'int-result-error';
        var title  = isPreview
            ? 'Probelauf – so würde der Sync laufen (' + d.synced_at + ')'
            : 'Sync abgeschlossen – ' + d.synced_at;
        var verb   = isPreview ? ' würde(n) ' : ' ';
        var html = '<div class="int-result ' + cls + '">'
                 + '<strong>' + escHtml(title) + '</strong>'
                 + d.created + verb + 'neu angelegt &nbsp;·&nbsp; '
                 + d.updated + verb + 'aktualisiert &nbsp;·&nbsp; '
                 + (d.linked || 0) + verb + 'verknüpft &nbsp;·&nbsp; '
                 + (d.unmatched || 0) + verb + 'unbekannt &nbsp;·&nbsp; '
                 + d.decommissioned + verb + 'dekommissioniert'
                 + (d.renamed ? ' &nbsp;·&nbsp; ' + d.renamed + verb + 'in Intune umbenannt' : '')
                 + (d.unmatched_removed ? ' &nbsp;·&nbsp; ' + d.unmatched_removed + ' aus Unknown-Liste entfernt' : '')
                 + (typeof d.skipped !== 'undefined' && d.skipped ? ' &nbsp;·&nbsp; ' + d.skipped + ' übersprungen' : '');

        if (d.message && !d.ok) { html += '<div style="margin-top:.4rem;">' + escHtml(d.message) + '</div>'; }
        if (d.log && d.log.length) {
            html += '<ul>';
            d.log.forEach(function (l) { html += '<li>' + escHtml(l) + '</li>'; });
            html += '</ul>';
        }
        if (d.errors && d.errors.length) {
            html += '<ul class="int-errs">';
            d.errors.slice(0, 10).forEach(function (e) { html += '<li>' + escHtml(e) + '</li>'; });
            html += '</ul>';
        }
        html += '</div>';
        result.innerHTML     = html;
        result.style.display = 'block';
    }

    function runIntuneJob(url, btn, busyLabel, idleLabel, isPreview) {
        var result = document.getElementById('int-sync-result');
        btn.disabled  = true;
        var orig = btn.innerHTML;
        btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> ' + busyLabel;
        result.style.display = 'none';

        var params = new URLSearchParams();
        params.append('_csrf', document.querySelector('meta[name="csrf-token"]')?.content ?? '');

        fetch(url, {
            method: 'POST', body: params,
            headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
        })
        .then(function (r) { return r.json(); })
        .then(function (d) { renderResult(d, isPreview); })
        .catch(function () {
            result.innerHTML     = '<div class="int-result int-result-error">Verbindungsfehler.</div>';
            result.style.display = 'block';
        })
        .finally(function () {
            btn.disabled  = false;
            btn.innerHTML = orig;
        });
    }

    // ── Vorschau / Probelauf ──────────────────────────────────────
    document.getElementById('int-preview-btn')?.addEventListener('click', function () {
        runIntuneJob('/admin/inventory/intune/preview', this, 'Probelauf läuft…', null, true);
    });

    // ── Manueller Sync ────────────────────────────────────────────
    document.getElementById('int-sync-btn')?.addEventListener('click', function () {
        runIntuneJob('/admin/inventory/intune/sync', this, 'Sync läuft…', null, false);
    });

    function escHtml(s) {
        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
    }
})();
</script>