diff --git a/Dockerfile_alpine b/Dockerfile_alpine deleted file mode 100644 index 0067eee..0000000 --- a/Dockerfile_alpine +++ /dev/null @@ -1,66 +0,0 @@ -# ========================= -# Stage 1 – Build -# ========================= -FROM python:3.13-alpine AS builder - -WORKDIR /app - -# Instalacja bibliotek do kompilacji + zależności runtime -RUN apk add --no-cache \ - tesseract-ocr \ - tesseract-ocr-data-pol \ - poppler-utils \ - libjpeg-turbo \ - zlib \ - libpng \ - libwebp \ - libffi \ - libmagic \ - && apk add --no-cache --virtual .build-deps \ - build-base \ - jpeg-dev \ - zlib-dev \ - libpng-dev \ - libwebp-dev \ - libffi-dev - -# Kopiujemy plik wymagań -COPY requirements.txt . - -# Instalujemy zależności Pythona do folderu tymczasowego -RUN pip install --no-cache-dir --prefix=/install -r requirements.txt - - -# ========================= -# Stage 2 – Final image -# ========================= -FROM python:3.13-alpine - -WORKDIR /app - -# Instalacja tylko bibliotek runtime (bez dev) -RUN apk add --no-cache \ - tesseract-ocr \ - tesseract-ocr-data-pol \ - poppler-utils \ - libjpeg-turbo \ - zlib \ - libpng \ - libwebp \ - libffi \ - libmagic - -# Kopiujemy zbudowane biblioteki z buildera -COPY --from=builder /install /usr/local - -# Kopiujemy kod aplikacji -COPY . . - -# Ustawiamy entrypoint -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -# Otwieramy port aplikacji -EXPOSE 8000 - -ENTRYPOINT ["/entrypoint.sh"] diff --git a/app.py b/app.py index e3ed435..dbfecd3 100644 --- a/app.py +++ b/app.py @@ -1796,39 +1796,49 @@ def system_auth(): @app.route("/edit_my_list/", methods=["GET", "POST"]) @login_required def edit_my_list(list_id): + # --- Pobranie listy i weryfikacja właściciela --- + l = db.session.get(ShoppingList, list_id) + if l is None: + abort(404) + if l.owner_id != current_user.id: + abort(403, description="Nie jesteś właścicielem tej listy.") + + # Dane do widoku receipts = ( Receipt.query.filter_by(list_id=list_id) .order_by(Receipt.uploaded_at.desc()) .all() ) - - l = db.session.get(ShoppingList, list_id) - if l is None: - abort(404) - - if l.owner_id != current_user.id: - abort(403, description="Nie jesteś właścicielem tej listy.") - categories = Category.query.order_by(Category.name.asc()).all() selected_categories_ids = {c.id for c in l.categories} next_page = request.args.get("next") or request.referrer + wants_json = ( + "application/json" in (request.headers.get("Accept") or "") + or request.headers.get("X-Requested-With") == "fetch" + ) if request.method == "POST": action = request.form.get("action") - # --- Nadanie dostępu --- + # --- Nadanie dostępu (grant) --- if action == "grant": grant_username = (request.form.get("grant_username") or "").strip().lower() if not grant_username: + if wants_json: + return jsonify(ok=False, error="empty"), 400 flash("Podaj nazwę użytkownika do nadania dostępu.", "danger") return redirect(next_page or request.url) u = User.query.filter(func.lower(User.username) == grant_username).first() if not u: + if wants_json: + return jsonify(ok=False, error="not_found"), 404 flash("Użytkownik nie istnieje.", "danger") return redirect(next_page or request.url) if u.id == current_user.id: + if wants_json: + return jsonify(ok=False, error="owner"), 409 flash("Jesteś właścicielem tej listy.", "info") return redirect(next_page or request.url) @@ -1843,22 +1853,30 @@ def edit_my_list(list_id): if not exists: db.session.add(ListPermission(list_id=l.id, user_id=u.id)) db.session.commit() + if wants_json: + return jsonify(ok=True, user={"id": u.id, "username": u.username}) flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success") else: + if wants_json: + return jsonify(ok=False, error="exists"), 409 flash("Ten użytkownik już ma dostęp.", "info") return redirect(next_page or request.url) - # --- Odebranie dostępu --- + # --- Odebranie dostępu (revoke) --- revoke_user_id = request.form.get("revoke_user_id") if revoke_user_id: try: uid = int(revoke_user_id) except ValueError: + if wants_json: + return jsonify(ok=False, error="bad_id"), 400 flash("Błędny identyfikator użytkownika.", "danger") return redirect(next_page or request.url) ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete() db.session.commit() + if wants_json: + return jsonify(ok=True, removed_user_id=uid) flash("Odebrano dostęp użytkownikowi.", "success") return redirect(next_page or request.url) @@ -1866,28 +1884,29 @@ def edit_my_list(list_id): if "unarchive" in request.form: l.is_archived = False db.session.commit() + if wants_json: + return jsonify(ok=True, unarchived=True) flash(f"Lista „{l.title}” została przywrócona.", "success") return redirect(next_page or request.url) - # --- Główny zapis pól formularza (bez wczesnych redirectów) --- - # Przenieś do miesiąca + # --- Główny zapis pól formularza --- move_to_month = request.form.get("move_to_month") if move_to_month: try: year, month = map(int, move_to_month.split("-")) l.created_at = datetime(year, month, 1, tzinfo=timezone.utc) - flash( - f"Zmieniono datę utworzenia listy na {l.created_at.strftime('%Y-%m-%d')}", - "success", - ) + if not wants_json: + flash( + f"Zmieniono datę utworzenia listy na {l.created_at.strftime('%Y-%m-%d')}", + "success", + ) except ValueError: - # Błędny format: informujemy, ale pozwalamy zapisać resztę pól - flash( - "Nieprawidłowy format miesiąca — zignorowano zmianę miesiąca.", - "danger", - ) + if not wants_json: + flash( + "Nieprawidłowy format miesiąca — zignorowano zmianę miesiąca.", + "danger", + ) - # Tytuł i statusy new_title = (request.form.get("title") or "").strip() is_public = "is_public" in request.form is_temporary = "is_temporary" in request.form @@ -1896,6 +1915,8 @@ def edit_my_list(list_id): expires_time = request.form.get("expires_time") if not new_title: + if wants_json: + return jsonify(ok=False, error="title_empty"), 400 flash("Podaj poprawny tytuł", "danger") return redirect(next_page or request.url) @@ -1904,26 +1925,29 @@ def edit_my_list(list_id): l.is_temporary = is_temporary l.is_archived = is_archived - # Wygasanie if expires_date and expires_time: try: combined = f"{expires_date} {expires_time}" expires_dt = datetime.strptime(combined, "%Y-%m-%d %H:%M") l.expires_at = expires_dt.replace(tzinfo=timezone.utc) except ValueError: + if wants_json: + return jsonify(ok=False, error="bad_expiry"), 400 flash("Błędna data lub godzina wygasania", "danger") return redirect(next_page or request.url) else: l.expires_at = None - # Kategorie + # Kategorie (używa Twojej pomocniczej funkcji) update_list_categories_from_form(l, request.form) - # Jeden commit na koniec db.session.commit() + if wants_json: + return jsonify(ok=True, saved=True) flash("Zaktualizowano dane listy", "success") return redirect(next_page or request.url) + # GET: użytkownicy z dostępem permitted_users = ( db.session.query(User) .join(ListPermission, ListPermission.user_id == User.id) @@ -1942,6 +1966,52 @@ def edit_my_list(list_id): ) + +@app.route("/edit_my_list//suggestions", methods=["GET"]) +@login_required +def edit_my_list_suggestions(list_id: int): + # Weryfikacja listy i właściciela (prywatność) + l = db.session.get(ShoppingList, list_id) + if l is None: + abort(404) + if l.owner_id != current_user.id: + abort(403, description="Nie jesteś właścicielem tej listy.") + + q = (request.args.get("q") or "").strip().lower() + + # Historia nadawań uprawnień przez tego właściciela (po wszystkich jego listach) + subq = ( + db.session.query( + ListPermission.user_id.label("uid"), + func.count(ListPermission.id).label("grant_count"), + func.max(ListPermission.id).label("last_grant_id"), + ) + .join(ShoppingList, ShoppingList.id == ListPermission.list_id) + .filter(ShoppingList.owner_id == current_user.id) + .group_by(ListPermission.user_id) + .subquery() + ) + + query = ( + db.session.query(User.username, subq.c.grant_count, subq.c.last_grant_id) + .join(subq, subq.c.uid == User.id) + ) + if q: + query = query.filter(func.lower(User.username).like(f"{q}%")) + + rows = ( + query.order_by( + subq.c.grant_count.desc(), + subq.c.last_grant_id.desc(), + func.lower(User.username).asc(), + ) + .limit(20) + .all() + ) + + return jsonify({"users": [r.username for r in rows]}) + + @app.route("/delete_user_list/", methods=["POST"]) @login_required def delete_user_list(list_id): @@ -2040,47 +2110,54 @@ def create_list(): @app.route("/list/") @login_required +# ───────────────────────────────────────────────────────────────────────────── +# Widok listy właściciela – dopięcie permitted_users do kontekstu +# ───────────────────────────────────────────────────────────────────────────── +@login_required def view_list(list_id): - shopping_list = db.session.get(ShoppingList, list_id) if not shopping_list: abort(404) is_owner = current_user.id == shopping_list.owner_id if not is_owner: - flash( - "Nie jesteś właścicielem listy, przekierowano do widoku publicznego.", - "warning", - ) + flash("Nie jesteś właścicielem listy, przekierowano do widoku publicznego.", "warning") if current_user.is_admin: - flash( - "W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info" - ) + flash("W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info") return redirect(url_for("shared_list", token=shopping_list.share_token)) + # Twoja obecna logika ładująca szczegóły listy: shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id) total_count = len(items) purchased_count = len([i for i in items if i.purchased]) percent = (purchased_count / total_count * 100) if total_count > 0 else 0 - is_owner = current_user.id == shopping_list.owner_id + # Uzupełnienie "added_by_display" — jak u Ciebie: for item in items: if item.added_by != shopping_list.owner_id: - item.added_by_display = ( - item.added_by_user.username if item.added_by_user else "?" - ) + item.added_by_display = (item.added_by_user.username if item.added_by_user else "?") else: item.added_by_display = None + # Badges kategorii (jak u Ciebie) shopping_list.category_badges = [ {"name": c.name, "color": category_to_color(c.name)} for c in shopping_list.categories ] - # dane do modala kategorii + # Dane do modala kategorii categories = Category.query.order_by(Category.name.asc()).all() selected_categories_ids = {c.id for c in shopping_list.categories} + # ⬅️ NOWE: użytkownicy z uprawnieniami do tej listy (dla modala w list.html) + permitted_users = ( + db.session.query(User) + .join(ListPermission, ListPermission.user_id == User.id) + .filter(ListPermission.list_id == shopping_list.id) + .order_by(User.username.asc()) + .all() + ) + return render_template( "list.html", list=shopping_list, @@ -2095,78 +2172,140 @@ def view_list(list_id): is_owner=is_owner, categories=categories, selected_categories=selected_categories_ids, + permitted_users=permitted_users, # ⬅️ ważne dla tokenów w modalu ) -# proste akcje ustawień listy @app.route("/list//settings", methods=["POST"]) @login_required def list_settings(list_id): + # Uprawnienia: właściciel l = db.session.get(ShoppingList, list_id) if l is None: abort(404) if l.owner_id != current_user.id: - abort(403, description="Nie jesteś właścicielem tej listy.") + abort(403, description="Brak uprawnień do ustawień tej listy.") next_page = request.form.get("next") or url_for("view_list", list_id=list_id) - action = (request.form.get("action") or "").strip() + wants_json = ( + "application/json" in (request.headers.get("Accept") or "") + or request.headers.get("X-Requested-With") == "fetch" + ) + action = request.form.get("action") + + # 1) Ustawienie kategorii (pojedynczy wybór z list.html -> modal kategorii) if action == "set_category": - cat_id = request.form.get("category_id", "").strip() - if not cat_id: - l.categories.clear() + cid = request.form.get("category_id") + if cid in (None, "", "none"): + # usunięcie kategorii lub brak zmiany – w zależności od Twojej logiki + l.categories = [] db.session.commit() - flash("Usunięto kategorię.", "success") + if wants_json: + return jsonify(ok=True, saved=True) + flash("Zapisano kategorię.", "success") return redirect(next_page) try: - cid = int(cat_id) - except ValueError: - flash("Nieprawidłowa kategoria.", "danger") + cid = int(cid) + except (TypeError, ValueError): + if wants_json: + return jsonify(ok=False, error="bad_category"), 400 + flash("Błędna kategoria.", "danger") return redirect(next_page) - cat = db.session.get(Category, cid) - if not cat: - flash("Taka kategoria nie istnieje.", "danger") + c = db.session.get(Category, cid) + if not c: + if wants_json: + return jsonify(ok=False, error="bad_category"), 400 + flash("Błędna kategoria.", "danger") return redirect(next_page) - # pojedyncza kategoria - l.categories = [cat] + # Jeśli jeden wybór – zastąp listę kategorii jedną: + l.categories = [c] db.session.commit() - flash(f"Ustawiono kategorię: „{cat.name}”.", "success") + if wants_json: + return jsonify(ok=True, saved=True) + flash("Zapisano kategorię.", "success") return redirect(next_page) - # 2) Nadanie dostępu użytkownikowi - if action == "grant_access": + # 2) Nadanie dostępu (akceptuj 'grant_access' i 'grant') + if action in ("grant_access", "grant"): grant_username = (request.form.get("grant_username") or "").strip().lower() + if not grant_username: - flash("Podaj login użytkownika.", "danger") + if wants_json: + return jsonify(ok=False, error="empty_username"), 400 + flash("Podaj nazwę użytkownika.", "danger") return redirect(next_page) + # Szukamy użytkownika po username (case-insensitive) u = User.query.filter(func.lower(User.username) == grant_username).first() if not u: + if wants_json: + return jsonify(ok=False, error="not_found"), 404 flash("Użytkownik nie istnieje.", "danger") return redirect(next_page) - if u.id == current_user.id: + + # Właściciel już ma dostęp + if u.id == l.owner_id: + if wants_json: + return jsonify(ok=False, error="owner"), 409 flash("Jesteś właścicielem tej listy.", "info") return redirect(next_page) + # Czy już ma dostęp? exists = ( db.session.query(ListPermission.id) .filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id) .first() ) if exists: + if wants_json: + return jsonify(ok=False, error="exists"), 409 flash("Ten użytkownik już ma dostęp.", "info") return redirect(next_page) + # Zapis uprawnienia db.session.add(ListPermission(list_id=l.id, user_id=u.id)) db.session.commit() + + if wants_json: + # Zwracamy usera, żeby JS mógł dokleić token bez odświeżania + return jsonify(ok=True, user={"id": u.id, "username": u.username}) flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success") return redirect(next_page) - # nieznana akcja - flash("Nieznana akcja.", "warning") + # 3) Odebranie dostępu (po polu revoke_user_id, nie po action) + revoke_uid = request.form.get("revoke_user_id") + if revoke_uid: + try: + uid = int(revoke_uid) + except (TypeError, ValueError): + if wants_json: + return jsonify(ok=False, error="bad_user_id"), 400 + flash("Błędny identyfikator użytkownika.", "danger") + return redirect(next_page) + + # Nie pozwalaj usunąć właściciela + if uid == l.owner_id: + if wants_json: + return jsonify(ok=False, error="cannot_revoke_owner"), 400 + flash("Nie można odebrać dostępu właścicielowi.", "danger") + return redirect(next_page) + + ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete() + db.session.commit() + + if wants_json: + return jsonify(ok=True, removed_user_id=uid) + flash("Odebrano dostęp użytkownikowi.", "success") + return redirect(next_page) + + # 4) Nieznana akcja + if wants_json: + return jsonify(ok=False, error="unknown_action"), 400 + flash("Nieznana akcja.", "danger") return redirect(next_page) @@ -3789,7 +3928,7 @@ def admin_lists_access(list_id=None): query_string = f"per_page={per_page}" return render_template( - "admin/admin_lists_access.html", + "admin/lists_access.html", lists=lists, permitted_by_list=permitted_by_list, page=page, diff --git a/static/js/access_users.js b/static/js/access_users.js new file mode 100644 index 0000000..8a5e905 --- /dev/null +++ b/static/js/access_users.js @@ -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} `; + 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 => ``).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); + }); +})(); diff --git a/static/js/lists_access.js b/static/js/lists_access.js new file mode 100644 index 0000000..0ce3376 --- /dev/null +++ b/static/js/lists_access.js @@ -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 => ``) + .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} `; + 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} `; + $('.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(); + } + }); + +})(); diff --git a/templates/admin/admin_lists_access.html b/templates/admin/admin_lists_access.html deleted file mode 100644 index 89fb241..0000000 --- a/templates/admin/admin_lists_access.html +++ /dev/null @@ -1,179 +0,0 @@ -{% extends 'base.html' %} -{% block title %}Zarządzanie dostępem do list{% endblock %} - -{% block content %} -
-

🔐{% if list_id %} Zarządzanie dostępem listy #{{ list_id }}{% else %} Zarządzanie dostępem do list {% endif %}

-
- {% if list_id %} - Powrót do wszystkich list - {% endif %} - ← Powrót do panelu -
-
- -
-
-
- - -
- - - - - - - - - - - - - - {% for l in lists %} - - - - - - - - - - - - - - - - {% endfor %} - {% if lists|length == 0 %} - - - - {% endif %} - -
IDNazwa listyWłaścicielUtworzonoStatusyUdostępnianieUprawnienia
- {{ l.id }} - - - {{ l.title }} - - {% if l.owner %} - 👤 {{ l.owner.username }} ({{ l.owner.id }}) - {% else %}-{% endif %} - {{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }} -
- - -
-
- - -
-
- - -
-
- {% if l.share_token %} - {% set share_url = url_for('shared_list', token=l.share_token, _external=True) %} -
-
- {{ share_url }} -
-
-
- {% if l.is_public %}Lista widoczna publicznie{% else %}Lista dostępna przez - link/uprawnienia{% - endif %} -
- {% else %} -
Brak tokenu
- {% endif %} -
-
    - {% for u in permitted_by_list.get(l.id, []) %} -
  • -
    - @{{ u.username }} -
    - - - - - - -
  • - {% endfor %} - {% if permitted_by_list.get(l.id, [])|length == 0 %} -
  • -
    Brak dodanych uprawnień.
    -
  • - {% endif %} -
- - -
- - -
- - -
-
- -
Brak list do wyświetlenia
-
- -
- -
- -
-
- -{% if not list_id %} -
-
-
- - - -
- - -
-{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/templates/admin/lists_access.html b/templates/admin/lists_access.html new file mode 100644 index 0000000..840ffcb --- /dev/null +++ b/templates/admin/lists_access.html @@ -0,0 +1,220 @@ +{% extends 'base.html' %} +{% block title %}Zarządzanie dostępem do list{% endblock %} + +{% block content %} +
+

🔐{% if list_id %} Zarządzanie dostępem listy #{{ list_id }}{% else %} Zarządzanie dostępem do list + {% endif %}

+
+ {% if list_id %} + Powrót do wszystkich list + {% endif %} + ← Powrót do panelu +
+
+ + +
+
+
+
+ + +
+ +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+
+ +
+ + +
+ +
+
+
+
+ + + + + +
+
+
+ + +
+ + + + + + + + + + + + + + + {% for l in lists %} + + + + + + + + + + + + + + + + + + + {% endfor %} + {% if lists|length == 0 %} + + + + {% endif %} + +
IDNazwa listyWłaścicielUtworzonoStatusyUdostępnianieUprawnienia
+ + + #{{ l.id }} + {{ l.title + }} + + {% if l.owner %} + 👤 @{{ l.owner.username }} + ({{ l.owner.id }}) + {% else %}-{% endif %} + {{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }} +
+ + +
+
+ + +
+
+ + +
+
+ {% if l.share_token %} + {% set share_url = url_for('shared_list', token=l.share_token, _external=True) %} +
+
{{ share_url }}
+ +
+
+ {% if l.is_public %}Lista widoczna publicznie{% else %}Dostęp przez link / uprawnienia{% endif %} +
+ {% else %} +
Brak tokenu
+ {% endif %} +
+
+ +
+ {% for u in permitted_by_list.get(l.id, []) %} + + {% endfor %} + {% if permitted_by_list.get(l.id, [])|length == 0 %} + Brak dodanych uprawnień. + {% endif %} +
+ + +
+ + +
+
Kliknij token, aby odebrać dostęp.
+
+
Brak list do wyświetlenia
+
+ +
+ +
+
+
+
+ +{% if not list_id %} +
+
+
+ + + +
+ + +
+{% endif %} + +{% endblock %} +{% block scripts %} + + +{% endblock %} \ No newline at end of file diff --git a/templates/edit_my_list.html b/templates/edit_my_list.html index 1603ff6..191e8a4 100644 --- a/templates/edit_my_list.html +++ b/templates/edit_my_list.html @@ -95,67 +95,33 @@ -
-
-
🔐 Dostęp do listy
+
+ - -
- +
- {% if list.share_token %} -
- - Otwórz - -
- {% else %} -
Brak tokenu udostępniania.
- {% endif %} -
Ustawienie „🌐 Publiczna” nie jest wymagane dla dostępu z linku.
-
- -
-
-
- - -
-
- -
- - -
-
- - -
- - {% if permitted_users and permitted_users|length > 0 %} -
    - {% for u in permitted_users %} -
  • -
    - @{{ u.username }} -
    -
    - - -
    -
  • - {% endfor %} -
- {% else %}
-
Brak dodanych uprawnień.
+ +
+ {% for u in permitted_users %} + + {% endfor %} + {% if not permitted_users or permitted_users|length == 0 %} + Brak dodanych uprawnień. {% endif %}
+ + +
+ + +
+
Kliknij token, aby odebrać dostęp.
@@ -282,4 +248,5 @@ + {% endblock %} \ No newline at end of file diff --git a/templates/list.html b/templates/list.html index 622e01b..bbf74a4 100644 --- a/templates/list.html +++ b/templates/list.html @@ -244,31 +244,48 @@ + @@ -302,7 +319,7 @@ - +