qwewqe
This commit is contained in:
@@ -932,6 +932,7 @@
|
|||||||
note: '',
|
note: '',
|
||||||
});
|
});
|
||||||
const [payoutModalOpen, setPayoutModalOpen] = useState(false);
|
const [payoutModalOpen, setPayoutModalOpen] = useState(false);
|
||||||
|
const [payoutMode, setPayoutMode] = useState('partial');
|
||||||
const [walletTransferRows, setWalletTransferRows] = useState([]);
|
const [walletTransferRows, setWalletTransferRows] = useState([]);
|
||||||
const [walletWithdrawalForm, setWalletWithdrawalForm] = useState({
|
const [walletWithdrawalForm, setWalletWithdrawalForm] = useState({
|
||||||
withdrawal_at: '',
|
withdrawal_at: '',
|
||||||
@@ -1006,6 +1007,21 @@
|
|||||||
const currentMinerOffers = Array.isArray(currentSettings.miner_offers) ? currentSettings.miner_offers : [];
|
const currentMinerOffers = Array.isArray(currentSettings.miner_offers) ? currentSettings.miner_offers : [];
|
||||||
const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : [];
|
const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : [];
|
||||||
const currentMiningCurrency = String((latest && latest.coin_currency) || currentSettings.crypto_currency || 'DOGE').toUpperCase();
|
const currentMiningCurrency = String((latest && latest.coin_currency) || currentSettings.crypto_currency || 'DOGE').toUpperCase();
|
||||||
|
const latestMeasurementDate = latest && latest.measured_at ? parseStoredUtcDate(latest.measured_at) : null;
|
||||||
|
const latestMeasurementAgeMs = latestMeasurementDate ? (Date.now() - latestMeasurementDate.getTime()) : null;
|
||||||
|
const canTransferAll = !!latestMeasurementDate
|
||||||
|
&& Number.isFinite(latestMeasurementAgeMs)
|
||||||
|
&& latestMeasurementAgeMs >= 0
|
||||||
|
&& latestMeasurementAgeMs <= 180000
|
||||||
|
&& Number(latest?.coins_total_visible ?? latest?.coins_total ?? 0) > 0;
|
||||||
|
const fullTransferCoins = Number(latest?.coins_total_visible ?? latest?.coins_total ?? 0);
|
||||||
|
const transferAllHint = !latestMeasurementDate
|
||||||
|
? 'Kein Mining-Upload vorhanden.'
|
||||||
|
: latestMeasurementAgeMs < 0
|
||||||
|
? `Letzter Upload ${fmtDate(latest.measured_at)} liegt in der Zukunft.`
|
||||||
|
: canTransferAll
|
||||||
|
? `Verwendet den letzten Upload von ${fmtDate(latest.measured_at)} mit ${fmtNumber(fullTransferCoins, 6)} ${currentMiningCurrency}.`
|
||||||
|
: `Alles uebertragen ist nur bis 3 Minuten nach dem letzten Upload moeglich. Letzter Upload: ${fmtDate(latest.measured_at)}.`;
|
||||||
const latestWalletSnapshot = currentWalletSnapshots.length ? currentWalletSnapshots[0] : null;
|
const latestWalletSnapshot = currentWalletSnapshots.length ? currentWalletSnapshots[0] : null;
|
||||||
const currentWalletMiningBalance = walletAssetBalance(latestWalletSnapshot, currentMiningCurrency);
|
const currentWalletMiningBalance = walletAssetBalance(latestWalletSnapshot, currentMiningCurrency);
|
||||||
const renewableOfferIds = new Set(
|
const renewableOfferIds = new Set(
|
||||||
@@ -1949,6 +1965,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitFullPayout() {
|
||||||
|
if (!canTransferAll) {
|
||||||
|
setError(transferAllHint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const savedPayout = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/payouts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
transfer_mode: 'full_latest',
|
||||||
|
note: 'Kompletttransfer aus letztem Upload',
|
||||||
|
}),
|
||||||
|
timeoutMs: 8000,
|
||||||
|
});
|
||||||
|
applySavedPayout(savedPayout);
|
||||||
|
setWalletTransferRows((previous) => previous.filter((row) => String(row.id) !== String(savedPayout.id)).concat(savedPayout).sort((left, right) => String(left?.payout_at || '').localeCompare(String(right?.payout_at || ''))));
|
||||||
|
invalidateProjectBootstrapCache(projectKey);
|
||||||
|
setMessage('Gesamter aktueller Miner-Bestand wurde ins NC Wallet uebertragen.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteWalletTransfer(transfer) {
|
async function deleteWalletTransfer(transfer) {
|
||||||
if (!transfer || !transfer.id) {
|
if (!transfer || !transfer.id) {
|
||||||
return;
|
return;
|
||||||
@@ -2758,11 +2804,20 @@
|
|||||||
: h('div', { className: 'mc-empty' }, 'Noch keine Wallet-Assets erkannt.')),
|
: h('div', { className: 'mc-empty' }, 'Noch keine Wallet-Assets erkannt.')),
|
||||||
panel('Wallet-Transfers', 'Transfers zum hier verfolgten Wallet erhoehen den Wallet-Bestand und reduzieren den sichtbaren Miner-Bestand. Reinvestierte Krypto-Minerkaeufe werden davon automatisch wieder abgezogen.', [
|
panel('Wallet-Transfers', 'Transfers zum hier verfolgten Wallet erhoehen den Wallet-Bestand und reduzieren den sichtbaren Miner-Bestand. Reinvestierte Krypto-Minerkaeufe werden davon automatisch wieder abgezogen.', [
|
||||||
h('div', { key: 'actions', className: 'mc-inline-row' }, [
|
h('div', { key: 'actions', className: 'mc-inline-row' }, [
|
||||||
|
h('button', {
|
||||||
|
key: 'transfer-all',
|
||||||
|
type: 'button',
|
||||||
|
className: 'mc-button mc-button--secondary',
|
||||||
|
onClick: submitFullPayout,
|
||||||
|
disabled: saving || !canTransferAll,
|
||||||
|
title: transferAllHint,
|
||||||
|
}, 'Alles uebertragen'),
|
||||||
h('button', {
|
h('button', {
|
||||||
key: 'add-transfer',
|
key: 'add-transfer',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
className: 'mc-button mc-button--secondary',
|
className: 'mc-button mc-button--secondary',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
|
setPayoutMode('partial');
|
||||||
setPayoutForm((previous) => ({
|
setPayoutForm((previous) => ({
|
||||||
payout_at: previous.payout_at || nowDateTimeLocalValue(),
|
payout_at: previous.payout_at || nowDateTimeLocalValue(),
|
||||||
coins_amount: previous.coins_amount || '',
|
coins_amount: previous.coins_amount || '',
|
||||||
@@ -2771,8 +2826,9 @@
|
|||||||
}));
|
}));
|
||||||
setPayoutModalOpen(true);
|
setPayoutModalOpen(true);
|
||||||
},
|
},
|
||||||
}, 'Zu Wallet transferieren'),
|
}, 'Teiluebertragung'),
|
||||||
]),
|
]),
|
||||||
|
h('div', { key: 'transfer-help', className: 'mc-text' }, transferAllHint),
|
||||||
h('div', { key: 'transfer-list', className: 'mc-table-shell' }, [
|
h('div', { key: 'transfer-list', className: 'mc-table-shell' }, [
|
||||||
h('table', { key: 'transfer-table', className: 'mc-table' }, [
|
h('table', { key: 'transfer-table', className: 'mc-table' }, [
|
||||||
h('thead', { key: 'head' }, h('tr', null, ['Zeit', 'Coins', 'Waehrung', 'Notiz', 'Aktion'].map((label) => h('th', { key: label }, label)))),
|
h('thead', { key: 'head' }, h('tr', null, ['Zeit', 'Coins', 'Waehrung', 'Notiz', 'Aktion'].map((label) => h('th', { key: label }, label)))),
|
||||||
@@ -3213,8 +3269,9 @@
|
|||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
], () => setCostPlanModalOpen(false)) : null,
|
], () => setCostPlanModalOpen(false)) : null,
|
||||||
payoutModalOpen ? renderModal('Zu Wallet transferieren', [
|
payoutModalOpen ? renderModal(payoutMode === 'partial' ? 'Teiluebertragung ins NC Wallet' : 'Zu Wallet transferieren', [
|
||||||
h('form', { key: 'form', className: 'mc-form', onSubmit: submitPayout }, [
|
h('form', { key: 'form', className: 'mc-form', onSubmit: submitPayout }, [
|
||||||
|
h('div', { key: 'hint', className: 'mc-text' }, 'Hier kannst du einen Teilbetrag vom aktuellen Miner-Bestand in das NC Wallet uebertragen.'),
|
||||||
inputField('Transferzeitpunkt', 'datetime-local', payoutForm.payout_at, (value) => setPayoutForm({ ...payoutForm, payout_at: value })),
|
inputField('Transferzeitpunkt', 'datetime-local', payoutForm.payout_at, (value) => setPayoutForm({ ...payoutForm, payout_at: value })),
|
||||||
inputField('Coins', 'number', payoutForm.coins_amount, (value) => setPayoutForm({ ...payoutForm, coins_amount: value }), '0.000001'),
|
inputField('Coins', 'number', payoutForm.coins_amount, (value) => setPayoutForm({ ...payoutForm, coins_amount: value }), '0.000001'),
|
||||||
selectField('Waehrung', payoutForm.payout_currency, [currentCoinCurrency].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== currentCoinCurrency)), (value) => setPayoutForm({ ...payoutForm, payout_currency: value })),
|
selectField('Waehrung', payoutForm.payout_currency, [currentCoinCurrency].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== currentCoinCurrency)), (value) => setPayoutForm({ ...payoutForm, payout_currency: value })),
|
||||||
|
|||||||
@@ -1788,6 +1788,40 @@ final class Router
|
|||||||
|
|
||||||
private function savePayout(string $projectKey, array $input): array
|
private function savePayout(string $projectKey, array $input): array
|
||||||
{
|
{
|
||||||
|
$transferMode = $this->enumValue($input['transfer_mode'] ?? 'manual', ['manual', 'full_latest'], 'transfer_mode');
|
||||||
|
if ($transferMode === 'full_latest') {
|
||||||
|
$measurements = $this->repository()->listRecentMeasurements($projectKey, 1);
|
||||||
|
$latestMeasurement = $measurements[0] ?? null;
|
||||||
|
if (!is_array($latestMeasurement)) {
|
||||||
|
throw new ApiException('Es ist kein aktueller Mining-Upload vorhanden.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestTs = strtotime((string) ($latestMeasurement['measured_at'] ?? ''));
|
||||||
|
if ($latestTs === false || (time() - $latestTs) > 180) {
|
||||||
|
throw new ApiException('Alles uebertragen ist nur moeglich, wenn der letzte Upload hoechstens 3 Minuten alt ist.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = $this->settings($projectKey, [
|
||||||
|
'currencies' => false,
|
||||||
|
'miner_offers' => false,
|
||||||
|
]);
|
||||||
|
$summary = $this->analytics()->buildSummary($measurements, $settings, [], ['include_offer_scenarios' => false]);
|
||||||
|
$visibleCoins = (float) ($summary['payouts']['current_visible_coins'] ?? 0.0);
|
||||||
|
if ($visibleCoins <= 0) {
|
||||||
|
throw new ApiException('Es stehen keine Coins fuer einen Kompletttransfer zur Verfuegung.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$coinCurrency = strtoupper(trim((string) ($latestMeasurement['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE')));
|
||||||
|
$payload = [
|
||||||
|
'payout_at' => (string) ($latestMeasurement['measured_at'] ?? ''),
|
||||||
|
'coins_amount' => $visibleCoins,
|
||||||
|
'payout_currency' => $coinCurrency !== '' ? $coinCurrency : 'DOGE',
|
||||||
|
'note' => $this->optionalString($input['note'] ?? 'Kompletttransfer aus letztem Upload', 1000),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->repository()->savePayout($projectKey, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'payout_at' => $this->requiredDateTime($input['payout_at'] ?? null, 'payout_at', $this->projectTimezone($projectKey)),
|
'payout_at' => $this->requiredDateTime($input['payout_at'] ?? null, 'payout_at', $this->projectTimezone($projectKey)),
|
||||||
'coins_amount' => $this->requiredDecimal($input['coins_amount'] ?? null, 'coins_amount'),
|
'coins_amount' => $this->requiredDecimal($input['coins_amount'] ?? null, 'coins_amount'),
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
$pendingRequests = $registration->pendingApprovals();
|
$pendingRequests = $registration->pendingApprovals();
|
||||||
$availableGroups = $registration->availableGroups();
|
$availableGroups = $registration->availableGroups();
|
||||||
|
$existingUsers = $registration->searchExistingUsers('', 250);
|
||||||
$selected = $selectedId !== ''
|
$selected = $selectedId !== ''
|
||||||
? $registration->find($selectedId)
|
? $registration->find($selectedId)
|
||||||
: ($pendingRequests[0] ?? null);
|
: ($pendingRequests[0] ?? null);
|
||||||
@@ -246,6 +247,8 @@ ob_start();
|
|||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<script id="um-existing-users-data" type="application/json"><?= json_encode($existingUsers, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?></script>
|
||||||
|
|
||||||
<?php if ($section === 'pending'): ?>
|
<?php if ($section === 'pending'): ?>
|
||||||
<div class="window-app-grid um-grid um-grid--split">
|
<div class="window-app-grid um-grid um-grid--split">
|
||||||
<section class="window-app-card um-card">
|
<section class="window-app-card um-card">
|
||||||
@@ -337,7 +340,10 @@ ob_start();
|
|||||||
|
|
||||||
<label class="um-field">
|
<label class="um-field">
|
||||||
<span>Benutzername</span>
|
<span>Benutzername</span>
|
||||||
<input type="text" name="manual_username" value="<?= $h($manualUsername) ?>" required>
|
<div class="um-user-picker" data-user-picker>
|
||||||
|
<input type="text" name="manual_username" value="<?= $h($manualUsername) ?>" required autocomplete="off" data-user-picker-input>
|
||||||
|
<div class="um-user-picker-list" data-user-picker-list hidden></div>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<fieldset class="um-fieldset">
|
<fieldset class="um-fieldset">
|
||||||
@@ -376,7 +382,10 @@ ob_start();
|
|||||||
|
|
||||||
<label class="um-field">
|
<label class="um-field">
|
||||||
<span>Benutzername</span>
|
<span>Benutzername</span>
|
||||||
<input type="text" name="reset_username" value="<?= $h($manualResetUsername) ?>" required>
|
<div class="um-user-picker" data-user-picker>
|
||||||
|
<input type="text" name="reset_username" value="<?= $h($manualResetUsername) ?>" required autocomplete="off" data-user-picker-input>
|
||||||
|
<div class="um-user-picker-list" data-user-picker-list hidden></div>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="um-field">
|
<label class="um-field">
|
||||||
|
|||||||
@@ -171,6 +171,62 @@
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#user-management-app .um-user-picker {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-management-app .um-user-picker-list {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
box-shadow: 0 20px 44px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-management-app .um-user-picker-option {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(248, 250, 252, 0.95);
|
||||||
|
color: #0f172a;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-management-app .um-user-picker-option:hover {
|
||||||
|
border-color: rgba(37, 99, 235, 0.35);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.3);
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-management-app .um-user-picker-option strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-management-app .um-user-picker-option span,
|
||||||
|
#user-management-app .um-user-picker-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-management-app .um-user-picker-empty {
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
#user-management-app .um-fieldset {
|
#user-management-app .um-fieldset {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -21,6 +21,114 @@
|
|||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (value) => String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
|
||||||
|
const bindUserPickers = (host) => {
|
||||||
|
const dataNode = host.querySelector('#um-existing-users-data');
|
||||||
|
if (!(dataNode instanceof HTMLScriptElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let users = [];
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(dataNode.textContent || '[]');
|
||||||
|
if (Array.isArray(decoded)) {
|
||||||
|
users = decoded;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
users = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUsers = users.map((user) => ({
|
||||||
|
username: String(user?.username || ''),
|
||||||
|
display_name: String(user?.display_name || ''),
|
||||||
|
email: String(user?.email || ''),
|
||||||
|
search: [
|
||||||
|
String(user?.username || ''),
|
||||||
|
String(user?.display_name || ''),
|
||||||
|
String(user?.email || ''),
|
||||||
|
].join(' ').toLowerCase(),
|
||||||
|
})).filter((user) => user.username !== '');
|
||||||
|
|
||||||
|
const closeAll = () => {
|
||||||
|
host.querySelectorAll('[data-user-picker-list]').forEach((list) => {
|
||||||
|
list.setAttribute('hidden', 'hidden');
|
||||||
|
list.innerHTML = '';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
host.querySelectorAll('[data-user-picker]').forEach((picker) => {
|
||||||
|
const input = picker.querySelector('[data-user-picker-input]');
|
||||||
|
const list = picker.querySelector('[data-user-picker-list]');
|
||||||
|
if (!(input instanceof HTMLInputElement) || !(list instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.dataset.umPickerBound === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.dataset.umPickerBound = '1';
|
||||||
|
|
||||||
|
const renderMatches = () => {
|
||||||
|
const needle = input.value.trim().toLowerCase();
|
||||||
|
const matches = normalizedUsers
|
||||||
|
.filter((user) => needle === '' || user.search.includes(needle))
|
||||||
|
.slice(0, needle === '' ? 80 : 25);
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (matches.length === 0) {
|
||||||
|
list.innerHTML = '<div class="um-user-picker-empty">Keine passenden Benutzer gefunden.</div>';
|
||||||
|
list.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = matches.map((user) => `
|
||||||
|
<button class="um-user-picker-option" type="button" data-user-picker-value="${escapeHtml(user.username)}">
|
||||||
|
<strong>${escapeHtml(user.username)}</strong>
|
||||||
|
<span>${escapeHtml(user.display_name || 'Ohne Anzeigename')}</span>
|
||||||
|
<span>${escapeHtml(user.email || 'Keine E-Mail')}</span>
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
list.hidden = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener('focus', renderMatches);
|
||||||
|
input.addEventListener('input', renderMatches);
|
||||||
|
|
||||||
|
list.addEventListener('click', (event) => {
|
||||||
|
const option = event.target.closest('[data-user-picker-value]');
|
||||||
|
if (!(option instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.value = String(option.dataset.userPickerValue || '');
|
||||||
|
list.hidden = true;
|
||||||
|
list.innerHTML = '';
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!host.dataset.umPickerOutsideBound) {
|
||||||
|
host.dataset.umPickerOutsideBound = '1';
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
if (!(event.target instanceof Node) || !host.contains(event.target)) {
|
||||||
|
closeAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insidePicker = event.target instanceof Element && event.target.closest('[data-user-picker]');
|
||||||
|
if (!insidePicker) {
|
||||||
|
closeAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const bindInteractions = (host, loadRoute) => {
|
const bindInteractions = (host, loadRoute) => {
|
||||||
host.querySelectorAll('a[href]').forEach((link) => {
|
host.querySelectorAll('a[href]').forEach((link) => {
|
||||||
if (link.dataset.umBound === '1') {
|
if (link.dataset.umBound === '1') {
|
||||||
@@ -68,6 +176,7 @@
|
|||||||
|
|
||||||
host.innerHTML = html;
|
host.innerHTML = html;
|
||||||
bindInteractions(host, loadRoute);
|
bindInteractions(host, loadRoute);
|
||||||
|
bindUserPickers(host);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
renderError(host, error?.message || 'Formular konnte nicht verarbeitet werden.');
|
renderError(host, error?.message || 'Formular konnte nicht verarbeitet werden.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -103,6 +212,7 @@
|
|||||||
|
|
||||||
host.innerHTML = html;
|
host.innerHTML = html;
|
||||||
bindInteractions(host, loadRoute);
|
bindInteractions(host, loadRoute);
|
||||||
|
bindUserPickers(host);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
renderError(host, error?.message || 'User Management konnte nicht geladen werden.');
|
renderError(host, error?.message || 'User Management konnte nicht geladen werden.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -112,6 +222,7 @@
|
|||||||
|
|
||||||
if (options.standalone) {
|
if (options.standalone) {
|
||||||
bindInteractions(host, loadRoute);
|
bindInteractions(host, loadRoute);
|
||||||
|
bindUserPickers(host);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,82 @@ final class LdapProvisioner
|
|||||||
return $this->getUserByUsername($username, ['uid']) !== null;
|
return $this->getUserByUsername($username, ['uid']) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, string>>
|
||||||
|
*/
|
||||||
|
public function searchUsers(string $needle = '', int $limit = 200): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$link = $this->connectAsService();
|
||||||
|
$baseDn = trim((string) ($this->config['ldap']['users_dn'] ?? ''));
|
||||||
|
|
||||||
|
if ($baseDn === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$needle = trim($needle);
|
||||||
|
$escaped = $this->escapeFilterValue($needle);
|
||||||
|
$filter = $escaped === ''
|
||||||
|
? '(&(objectClass=person)(uid=*))'
|
||||||
|
: '(&(objectClass=person)(|(uid=*' . $escaped . '*)(cn=*' . $escaped . '*)(displayName=*' . $escaped . '*)(mail=*' . $escaped . '*)(givenName=*' . $escaped . '*)(sn=*' . $escaped . '*)))';
|
||||||
|
|
||||||
|
$search = @ldap_search(
|
||||||
|
$link,
|
||||||
|
$baseDn,
|
||||||
|
$filter,
|
||||||
|
['uid', 'cn', 'displayName', 'mail', 'givenName', 'sn'],
|
||||||
|
0,
|
||||||
|
max(1, min($limit, 500))
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($search === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = ldap_get_entries($link, $search);
|
||||||
|
if (!is_array($entries) || (int) ($entries['count'] ?? 0) < 1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
for ($index = 0; $index < (int) $entries['count']; $index++) {
|
||||||
|
$entry = $entries[$index] ?? null;
|
||||||
|
if (!is_array($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = $this->firstAttributeValue($entry, 'uid');
|
||||||
|
if ($username === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$displayName = $this->firstAttributeValue($entry, 'displayname');
|
||||||
|
if ($displayName === '') {
|
||||||
|
$displayName = $this->firstAttributeValue($entry, 'cn');
|
||||||
|
}
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'username' => $username,
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'email' => $this->firstAttributeValue($entry, 'mail'),
|
||||||
|
'dn' => (string) ($entry['dn'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort(
|
||||||
|
$results,
|
||||||
|
static fn (array $left, array $right): int => strnatcasecmp(
|
||||||
|
(string) ($left['username'] ?? ''),
|
||||||
|
(string) ($right['username'] ?? '')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, string> $attributes
|
* @param array<int, string> $attributes
|
||||||
* @return array<string, mixed>|null
|
* @return array<string, mixed>|null
|
||||||
@@ -608,6 +684,38 @@ final class LdapProvisioner
|
|||||||
return is_string($error) && $error !== '' ? $error : 'Unbekannter LDAP-Fehler';
|
return is_string($error) && $error !== '' ? $error : 'Unbekannter LDAP-Fehler';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function escapeFilterValue(string $value): string
|
||||||
|
{
|
||||||
|
if ($value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('ldap_escape')) {
|
||||||
|
return (string) ldap_escape($value, '', LDAP_ESCAPE_FILTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtr($value, [
|
||||||
|
'\\' => '\5c',
|
||||||
|
'*' => '\2a',
|
||||||
|
'(' => '\28',
|
||||||
|
')' => '\29',
|
||||||
|
"\0" => '\00',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $entry
|
||||||
|
*/
|
||||||
|
private function firstAttributeValue(array $entry, string $attribute): string
|
||||||
|
{
|
||||||
|
$value = $entry[strtolower($attribute)] ?? null;
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($value[0]) ? trim((string) $value[0]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
private function generateUuidV4(): string
|
private function generateUuidV4(): string
|
||||||
{
|
{
|
||||||
$bytes = random_bytes(16);
|
$bytes = random_bytes(16);
|
||||||
|
|||||||
@@ -335,6 +335,14 @@ final class RegistrationService
|
|||||||
return $this->ldapProvisioner->updateUserPassword($username, $password);
|
return $this->ldapProvisioner->updateUserPassword($username, $password);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, string>>
|
||||||
|
*/
|
||||||
|
public function searchExistingUsers(string $needle = '', int $limit = 200): array
|
||||||
|
{
|
||||||
|
return $this->ldapProvisioner->searchUsers($needle, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user