rozszerzone uprawnienia

This commit is contained in:
Mateusz Gruszczyński
2025-10-01 10:51:52 +02:00
parent 873e81d95d
commit 01114b4ca9
8 changed files with 908 additions and 380 deletions

176
static/js/access_users.js Normal file
View File

@@ -0,0 +1,176 @@
(function () {
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
const toast = (m, t = 'info') => (window.showToast ? window.showToast(m, t) : console.log(`[${t}]`, m));
function appendToken(box, user) {
const tokensBox = $('.tokens', box);
if (!tokensBox || !user?.id || !user?.username) return;
const empty = $('.no-perms', box);
if (empty) empty.remove();
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-secondary rounded-pill token';
btn.dataset.userId = user.id;
btn.dataset.username = user.username;
btn.title = 'Kliknij, aby odebrać dostęp';
btn.innerHTML = `@${user.username} <span aria-hidden="true">×</span>`;
tokensBox.appendChild(btn);
}
function wantsJSON() {
return {
'Accept': 'application/json',
'X-Requested-With': 'fetch'
};
}
async function postAction(postUrl, nextPath, params) {
const form = new FormData();
for (const [k, v] of Object.entries(params)) form.set(k, v);
form.set('next', nextPath); // dla trybu HTML fallback
try {
const res = await fetch(postUrl, {
method: 'POST',
body: form,
credentials: 'same-origin',
headers: wantsJSON()
});
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const data = await res.json().catch(() => ({}));
return { ok: !!data?.ok, data, status: res.status };
}
return { ok: res.ok, data: null, status: res.status };
} catch (e) {
console.error('POST failed', e);
return { ok: false, data: null, status: 0 };
}
}
function initEditor(box) {
if (!box || !box.classList?.contains('access-editor')) return;
if (box.dataset._accessEditorInit === '1') return;
box.dataset._accessEditorInit = '1';
const postUrl = box.dataset.postUrl || location.pathname;
const nextPath = box.dataset.next || location.pathname;
const suggestUrl = box.dataset.suggestUrl || '';
const grantAction = box.dataset.grantAction || 'grant';
const revokeField = box.dataset.revokeField || 'revoke_user_id';
const tokensBox = $('.tokens', box);
const input = $('.access-input', box);
const addBtn = $('.access-add', box);
// współdzielony datalist do sugestii
let datalist = $('#userHintsGeneric');
if (!datalist) {
datalist = document.createElement('datalist');
datalist.id = 'userHintsGeneric';
document.body.appendChild(datalist);
}
input?.setAttribute('list', datalist.id);
const unique = (arr) => Array.from(new Set(arr));
const parseUserText = (txt) => unique((txt || '').split(/[\s,;]+/g).map(s => s.trim().replace(/^@/, '').toLowerCase()).filter(Boolean));
const debounce = (fn, ms = 200) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
// Sugestie (GET JSON)
const renderHints = (users = []) => { datalist.innerHTML = users.slice(0, 20).map(u => `<option value="${u}">@${u}</option>`).join(''); };
let acCtrl = null;
const fetchHints = debounce(async (q) => {
if (!suggestUrl) return;
try {
acCtrl?.abort();
acCtrl = new AbortController();
const res = await fetch(`${suggestUrl}?q=${encodeURIComponent(q || '')}`, { credentials: 'same-origin', signal: acCtrl.signal });
if (!res.ok) return renderHints([]);
const data = await res.json().catch(() => ({ users: [] }));
renderHints(data.users || []);
} catch { renderHints([]); }
}, 200);
input?.addEventListener('focus', () => fetchHints(input.value));
input?.addEventListener('input', () => fetchHints(input.value));
// Revoke (klik w token)
box.addEventListener('click', async (e) => {
const btn = e.target.closest('.token');
if (!btn || !box.contains(btn)) return;
const userId = btn.dataset.userId;
const username = btn.dataset.username;
if (!userId) return toast('Brak identyfikatora użytkownika.', 'danger');
btn.disabled = true; btn.classList.add('disabled');
const res = await postAction(postUrl, nextPath, { [revokeField]: userId });
if (res.ok) {
btn.remove();
if (!$$('.token', box).length && tokensBox) {
const empty = document.createElement('span');
empty.className = 'no-perms text-warning small';
empty.textContent = 'Brak dodanych uprawnień.';
tokensBox.appendChild(empty);
}
toast(`Odebrano dostęp: @${username}`, 'success');
} else {
btn.disabled = false; btn.classList.remove('disabled');
toast(`Nie udało się odebrać dostępu @${username}`, 'danger');
}
});
// Grant (wiele loginów, bez przeładowania strony)
async function addUsers() {
const users = parseUserText(input?.value);
if (!users?.length) return toast('Podaj co najmniej jednego użytkownika', 'warning');
addBtn.disabled = true;
const prevText = addBtn.textContent;
addBtn.textContent = 'Dodaję…';
let okCount = 0, failCount = 0, appended = 0;
for (const u of users) {
const res = await postAction(postUrl, nextPath, { action: grantAction, grant_username: u });
if (res.ok) {
okCount++;
// jeśli backend odda JSON z userem dolep token live
if (res.data?.user) {
appendToken(box, res.data.user);
appended++;
}
} else {
failCount++;
}
}
addBtn.disabled = false;
addBtn.textContent = prevText;
if (input) input.value = '';
if (okCount) toast(`Dodano dostęp: ${okCount} użytkownika`, 'success');
if (failCount) toast(`Błędy przy dodawaniu: ${failCount}`, 'danger');
// fallback: jeśli nic nie dolepiliśmy (brak JSON), odśwież, by zobaczyć nowe tokeny
if (okCount && appended === 0) {
// opóźnij minimalnie, by toast mignął
setTimeout(() => location.reload(), 400);
}
}
addBtn?.addEventListener('click', addUsers);
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addUsers(); } });
}
document.addEventListener('DOMContentLoaded', () => {
$$('.access-editor').forEach(initEditor);
});
document.addEventListener('shown.bs.modal', (ev) => {
$$('.access-editor', ev.target).forEach(initEditor);
});
})();

254
static/js/lists_access.js Normal file
View File

@@ -0,0 +1,254 @@
(function () {
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
const filterInput = $('#listFilter');
const filterCount = $('#filterCount');
const selectAll = $('#selectAll');
const bulkTokens = $('#bulkTokens');
const bulkInput = $('#bulkUsersInput');
const bulkBtn = $('#bulkAddBtn');
const datalist = $('#userHints');
const unique = (arr) => Array.from(new Set(arr));
const parseUserText = (txt) => unique((txt || '')
.split(/[\s,;]+/g)
.map(s => s.trim().replace(/^@/, '').toLowerCase())
.filter(Boolean)
);
const selectedListIds = () =>
$$('.row-check:checked').map(ch => ch.dataset.listId);
const visibleRows = () =>
$$('#listsTable tbody tr').filter(r => r.style.display !== 'none');
// ===== Podpowiedzi (datalist) z DOM-u =====
(function buildHints() {
const names = new Set();
$$('.owner-username').forEach(el => names.add(el.dataset.username));
$$('.permitted-username').forEach(el => names.add(el.dataset.username));
// również tokeny już wyrenderowane
$$('.token[data-username]').forEach(el => names.add(el.dataset.username));
datalist.innerHTML = Array.from(names)
.sort((a, b) => a.localeCompare(b))
.map(u => `<option value="${u}">@${u}</option>`)
.join('');
})();
// ===== Live filter =====
function applyFilter() {
const q = (filterInput?.value || '').trim().toLowerCase();
let shown = 0;
$$('#listsTable tbody tr').forEach(tr => {
const hay = `${tr.dataset.id || ''} ${tr.dataset.title || ''} ${tr.dataset.owner || ''}`;
const ok = !q || hay.includes(q);
tr.style.display = ok ? '' : 'none';
if (ok) shown++;
});
if (filterCount) filterCount.textContent = shown ? `Widoczne: ${shown}` : 'Brak wyników';
}
filterInput?.addEventListener('input', applyFilter);
applyFilter();
// ===== Select all =====
selectAll?.addEventListener('change', () => {
visibleRows().forEach(tr => {
const cb = tr.querySelector('.row-check');
if (cb) cb.checked = selectAll.checked;
});
});
// ===== Copy share URL =====
$$('.copy-share').forEach(btn => {
btn.addEventListener('click', async () => {
const url = btn.dataset.url;
try {
await navigator.clipboard.writeText(url);
showToast('Skopiowano link udostępnienia', 'success');
} catch {
const ta = Object.assign(document.createElement('textarea'), { value: url });
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
showToast('Skopiowano link udostępnienia', 'success');
}
});
});
// ===== Tokenized users field (global belka) =====
function addGlobalToken(username) {
if (!username) return;
const exists = $(`.user-token[data-user="${username}"]`, bulkTokens);
if (exists) return;
const token = document.createElement('span');
token.className = 'badge rounded-pill text-bg-secondary user-token';
token.dataset.user = username;
token.innerHTML = `@${username} <button type="button" class="btn btn-sm btn-link p-0 ms-1 text-white">✕</button>`;
token.querySelector('button').addEventListener('click', () => token.remove());
bulkTokens.appendChild(token);
}
bulkInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
parseUserText(bulkInput.value).forEach(addGlobalToken);
bulkInput.value = '';
}
});
bulkInput?.addEventListener('change', () => {
parseUserText(bulkInput.value).forEach(addGlobalToken);
bulkInput.value = '';
});
// ===== Bulk grant (z belki) =====
async function bulkGrant() {
const lists = selectedListIds();
const users = $$('.user-token', bulkTokens).map(t => t.dataset.user);
if (!lists.length) { showToast('Zaznacz przynajmniej jedną listę', 'warning'); return; }
if (!users.length) { showToast('Dodaj przynajmniej jednego użytkownika', 'warning'); return; }
bulkBtn.disabled = true;
bulkBtn.textContent = 'Pracuję…';
const url = location.pathname + location.search;
let ok = 0, fail = 0;
for (const lid of lists) {
for (const u of users) {
const form = new FormData();
form.set('action', 'grant');
form.set('target_list_id', lid);
form.set('grant_username', u);
try {
const res = await fetch(url, { method: 'POST', body: form, credentials: 'same-origin' });
if (res.ok) ok++; else fail++;
} catch { fail++; }
}
}
bulkBtn.disabled = false;
bulkBtn.textContent = ' Nadaj dostęp';
showToast(`Gotowe. Sukcesy: ${ok}${fail ? `, błędy: ${fail}` : ''}`, fail ? 'danger' : 'success');
location.reload();
}
bulkBtn?.addEventListener('click', bulkGrant);
// ===== Per-row "Access editor" (tokeny + dodawanie) =====
async function postAction(params) {
const url = location.pathname + location.search;
const form = new FormData();
for (const [k, v] of Object.entries(params)) form.set(k, v);
const res = await fetch(url, { method: 'POST', body: form, credentials: 'same-origin' });
return res.ok;
}
// Delegacja zdarzeń: kliknięcie tokenu = revoke
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.access-editor .token');
if (!btn) return;
const wrapper = btn.closest('.access-editor');
const listId = wrapper?.dataset.listId;
const userId = btn.dataset.userId;
const username = btn.dataset.username;
if (!listId || !userId) return;
btn.disabled = true;
btn.classList.add('disabled');
const ok = await postAction({
action: 'revoke',
target_list_id: listId,
revoke_user_id: userId
});
if (ok) {
btn.remove();
const tokens = $$('.token', wrapper);
if (!tokens.length) {
// pokaż info „brak uprawnień”
let empty = $('.no-perms', wrapper);
if (!empty) {
empty = document.createElement('span');
empty.className = 'text-warning small no-perms';
empty.textContent = 'Brak dodanych uprawnień.';
$('.tokens', wrapper).appendChild(empty);
}
}
showToast(`Odebrano dostęp: @${username}`, 'success');
} else {
btn.disabled = false;
btn.classList.remove('disabled');
showToast(`Nie udało się odebrać dostępu @${username}`, 'danger');
}
});
// Dodawanie wielu użytkowników per-row
document.addEventListener('click', async (e) => {
const addBtn = e.target.closest('.access-editor .access-add');
if (!addBtn) return;
const wrapper = addBtn.closest('.access-editor');
const listId = wrapper?.dataset.listId;
const input = $('.access-input', wrapper);
if (!listId || !input) return;
const users = parseUserText(input.value);
if (!users.length) { showToast('Podaj co najmniej jednego użytkownika', 'warning'); return; }
addBtn.disabled = true;
addBtn.textContent = 'Dodaję…';
let okCount = 0, failCount = 0;
for (const u of users) {
const ok = await postAction({
action: 'grant',
target_list_id: listId,
grant_username: u
});
if (ok) {
okCount++;
// usuń info „brak uprawnień”
$('.no-perms', wrapper)?.remove();
// dodaj token jeśli nie ma
const exists = $(`.token[data-username="${u}"]`, wrapper);
if (!exists) {
const token = document.createElement('button');
token.type = 'button';
token.className = 'btn btn-sm btn-outline-secondary rounded-pill token';
token.dataset.username = u;
token.dataset.userId = ''; // nie znamy ID — token nadal klikany, ale bez revoke po ID
token.title = '@' + u;
token.innerHTML = `@${u} <span aria-hidden="true">×</span>`;
$('.tokens', wrapper).appendChild(token);
}
} else {
failCount++;
}
}
addBtn.disabled = false;
addBtn.textContent = ' Dodaj';
input.value = '';
if (okCount) showToast(`Dodano dostęp: ${okCount} użytk.`, 'success');
if (failCount) showToast(`Błędy przy dodawaniu: ${failCount}`, 'danger');
// Odśwież, by mieć poprawne user_id w tokenach (backend wie lepiej)
if (okCount) location.reload();
});
// Enter w polu per-row = zadziałaj jak przycisk
document.addEventListener('keydown', (e) => {
const inp = e.target.closest('.access-editor .access-input');
if (inp && e.key === 'Enter') {
e.preventDefault();
const btn = inp.closest('.access-editor')?.querySelector('.access-add');
btn?.click();
}
});
})();