rozszerzone uprawnienia
This commit is contained in:
@@ -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"]
|
261
app.py
261
app.py
@@ -1796,39 +1796,49 @@ def system_auth():
|
||||
@app.route("/edit_my_list/<int:list_id>", 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/<int:list_id>/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/<int:list_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_user_list(list_id):
|
||||
@@ -2040,47 +2110,54 @@ def create_list():
|
||||
|
||||
@app.route("/list/<int:list_id>")
|
||||
@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/<int:list_id>/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,
|
||||
|
176
static/js/access_users.js
Normal file
176
static/js/access_users.js
Normal 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
254
static/js/lists_access.js
Normal 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();
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
@@ -1,179 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Zarządzanie dostępem do list{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-4">
|
||||
<h2 class="mb-2">🔐{% if list_id %} Zarządzanie dostępem listy #{{ list_id }}{% else %} Zarządzanie dostępem do list {% endif %}</h2>
|
||||
<div>
|
||||
{% if list_id %}
|
||||
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light me-2">Powrót do wszystkich list</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="save_changes">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark align-middle sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Nazwa listy</th>
|
||||
<th scope="col">Właściciel</th>
|
||||
<th scope="col">Utworzono</th>
|
||||
<th scope="col">Statusy</th>
|
||||
<th scope="col">Udostępnianie</th>
|
||||
<th scope="col">Uprawnienia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in lists %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ l.id }}
|
||||
<input type="hidden" name="visible_ids" value="{{ l.id }}">
|
||||
</td>
|
||||
|
||||
<td class="fw-bold align-middle">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white">{{ l.title }}</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if l.owner %}
|
||||
👤 {{ l.owner.username }} ({{ l.owner.id }})
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
|
||||
<td>{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
|
||||
|
||||
<td style="min-width: 220px;">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="pub_{{ l.id }}"
|
||||
name="is_public_{{ l.id }}" {% if l.is_public %}checked{% endif %}>
|
||||
<label class="form-check-label" for="pub_{{ l.id }}">🌐 Publiczna</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="tmp_{{ l.id }}"
|
||||
name="is_temporary_{{ l.id }}" {% if l.is_temporary %}checked{% endif %}>
|
||||
<label class="form-check-label" for="tmp_{{ l.id }}">⏳ Tymczasowa</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="arc_{{ l.id }}"
|
||||
name="is_archived_{{ l.id }}" {% if l.is_archived %}checked{% endif %}>
|
||||
<label class="form-check-label" for="arc_{{ l.id }}">📦 Archiwalna</label>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td style="min-width: 220px;">
|
||||
{% if l.share_token %}
|
||||
{% set share_url = url_for('shared_list', token=l.share_token, _external=True) %}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="flex-grow-1 text-truncate mono" title="{{ share_url }}">
|
||||
{{ share_url }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-info small">
|
||||
{% if l.is_public %}Lista widoczna publicznie{% else %}Lista dostępna przez
|
||||
link/uprawnienia{%
|
||||
endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-warning small">Brak tokenu</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td style="min-width: 320px;">
|
||||
<ul class="list-group list-group-flush mb-2">
|
||||
{% for u in permitted_by_list.get(l.id, []) %}
|
||||
<li
|
||||
class="list-group-item bg-dark text-white d-flex justify-content-between align-items-center border-secondary">
|
||||
<div>
|
||||
<span class="fw-semibold">@{{ u.username }}</span>
|
||||
</div>
|
||||
<form method="post" class="m-0"
|
||||
onsubmit="return confirm('Odebrać dostęp @{{ u.username }}?');">
|
||||
<input type="hidden" name="action" value="revoke">
|
||||
<input type="hidden" name="target_list_id" value="{{ l.id }}">
|
||||
<input type="hidden" name="revoke_user_id" value="{{ u.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">🚫
|
||||
Odbierz</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if permitted_by_list.get(l.id, [])|length == 0 %}
|
||||
<li class="list-group-item bg-dark text-white border-secondary">
|
||||
<div class="text-warning small">Brak dodanych uprawnień.</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<!-- Nadawanie dostępu -->
|
||||
<form method="post" class="m-0">
|
||||
<input type="hidden" name="action" value="grant">
|
||||
<input type="hidden" name="target_list_id" value="{{ l.id }}">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" name="grant_username"
|
||||
class="form-control bg-dark text-white border-secondary"
|
||||
placeholder="nazwa użytkownika">
|
||||
<button type="submit" class="btn btn-outline-light">➕ Dodaj</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if lists|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-4">Brak list do wyświetlenia</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz zmiany</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not list_id %}
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||
<form method="get" class="d-flex align-items-center">
|
||||
<label for="per_page" class="me-2">🔢 Pozycji na stronę:</label>
|
||||
<select id="per_page" name="per_page" class="form-select form-select-sm me-2"
|
||||
onchange="this.form.page.value = 1; this.form.submit();">
|
||||
<option value="25" {% if per_page==25 %}selected{% endif %}>25</option>
|
||||
<option value="50" {% if per_page==50 %}selected{% endif %}>50</option>
|
||||
<option value="100" {% if per_page==100 %}selected{% endif %}>100</option>
|
||||
</select>
|
||||
<input type="hidden" name="page" value="{{ page }}">
|
||||
</form>
|
||||
|
||||
<nav aria-label="Nawigacja stron">
|
||||
<ul class="pagination pagination-dark mb-0">
|
||||
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page - 1 }}">«</a>
|
||||
</li>
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page + 1 }}">»</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
220
templates/admin/lists_access.html
Normal file
220
templates/admin/lists_access.html
Normal file
@@ -0,0 +1,220 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Zarządzanie dostępem do list{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-3">
|
||||
<h2 class="mb-2">🔐{% if list_id %} Zarządzanie dostępem listy #{{ list_id }}{% else %} Zarządzanie dostępem do list
|
||||
{% endif %}</h2>
|
||||
<div class="d-flex gap-2">
|
||||
{% if list_id %}
|
||||
<a href="{{ url_for('admin_lists_access') }}" class="btn btn-outline-light">Powrót do wszystkich list</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-outline-secondary">← Powrót do panelu</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STICKY ACTION BAR -->
|
||||
<div id="bulkBar" class="position-sticky top-0 z-3 mb-3" style="backdrop-filter: blur(6px);">
|
||||
<div class="card bg-dark border-secondary shadow-sm">
|
||||
<div class="card-body py-2 d-flex flex-wrap align-items-center gap-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input id="selectAll" class="form-check-input" type="checkbox" />
|
||||
<label for="selectAll" class="form-check-label">Zaznacz wszystko</label>
|
||||
</div>
|
||||
|
||||
<div class="vr text-secondary"></div>
|
||||
|
||||
<div class="flex-grow-1 d-flex align-items-center gap-2">
|
||||
<input id="listFilter" class="form-control form-control-sm bg-dark text-white border-secondary"
|
||||
placeholder="Szukaj po tytule/ID/właścicielu…" aria-label="Filtruj listy">
|
||||
<span class="text-secondary small ms-1" id="filterCount">—</span>
|
||||
</div>
|
||||
|
||||
<div class="vr text-secondary d-none d-md-block"></div>
|
||||
|
||||
<!-- BULK GRANT -->
|
||||
<div class="flex-grow-1">
|
||||
<div class="input-group input-group-sm">
|
||||
<input id="bulkUsersInput" class="form-control bg-dark text-white border-secondary"
|
||||
placeholder="Podaj użytkowników (po przecinku lub enterach)" list="userHints">
|
||||
<button id="bulkAddBtn" class="btn btn-outline-light" type="button">➕ Nadaj dostęp</button>
|
||||
</div>
|
||||
<div id="bulkTokens" class="d-flex flex-wrap gap-2 mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="vr text-secondary d-none d-md-block"></div>
|
||||
|
||||
<!-- ZAPISZ – ten sam poziom co reszta -->
|
||||
<div class="d-flex align-items-center gap-2 flex-shrink-0">
|
||||
<button form="statusForm" type="submit" class="btn btn-sm btn-outline-light">
|
||||
💾 Zapisz
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- HINTS -->
|
||||
<datalist id="userHints"></datalist>
|
||||
|
||||
<div class="card bg-dark text-white mb-5">
|
||||
<div class="card-body">
|
||||
<form id="statusForm" method="post">
|
||||
<input type="hidden" name="action" value="save_changes">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark align-middle" id="listsTable">
|
||||
<thead class="align-middle">
|
||||
<tr>
|
||||
<th scope="col" style="width:36px;"></th>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Nazwa listy</th>
|
||||
<th scope="col">Właściciel</th>
|
||||
<th scope="col">Utworzono</th>
|
||||
<th scope="col">Statusy</th>
|
||||
<th scope="col">Udostępnianie</th>
|
||||
<th scope="col" style="min-width: 340px;">Uprawnienia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in lists %}
|
||||
<tr data-id="{{ l.id }}" data-title="{{ l.title|lower }}"
|
||||
data-owner="{{ (l.owner.username if l.owner else '-')|lower }}">
|
||||
<td>
|
||||
<input class="row-check form-check-input" type="checkbox" data-list-id="{{ l.id }}">
|
||||
<input type="hidden" name="visible_ids" value="{{ l.id }}">
|
||||
</td>
|
||||
|
||||
<td class="text-nowrap">#{{ l.id }}</td>
|
||||
|
||||
<td class="fw-bold align-middle">
|
||||
<a href="{{ url_for('view_list', list_id=l.id) }}" class="text-white text-decoration-none">{{ l.title
|
||||
}}</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if l.owner %}
|
||||
👤 <span class="owner-username" data-username="{{ l.owner.username }}">@{{ l.owner.username }}</span>
|
||||
({{ l.owner.id }})
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="text-nowrap">{{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }}</td>
|
||||
|
||||
<td style="min-width: 230px;">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="pub_{{ l.id }}" name="is_public_{{ l.id }}" {% if
|
||||
l.is_public %}checked{% endif %}>
|
||||
<label class="form-check-label" for="pub_{{ l.id }}">🌐 Publiczna</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="tmp_{{ l.id }}" name="is_temporary_{{ l.id }}" {%
|
||||
if l.is_temporary %}checked{% endif %}>
|
||||
<label class="form-check-label" for="tmp_{{ l.id }}">⏳ Tymczasowa</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="arc_{{ l.id }}" name="is_archived_{{ l.id }}" {%
|
||||
if l.is_archived %}checked{% endif %}>
|
||||
<label class="form-check-label" for="arc_{{ l.id }}">📦 Archiwalna</label>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td style="min-width: 260px;">
|
||||
{% if l.share_token %}
|
||||
{% set share_url = url_for('shared_list', token=l.share_token, _external=True) %}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="flex-grow-1 text-truncate mono small" title="{{ share_url }}">{{ share_url }}</div>
|
||||
<button class="btn btn-sm btn-outline-secondary copy-share" type="button" data-url="{{ share_url }}"
|
||||
aria-label="Kopiuj link">📋</button>
|
||||
</div>
|
||||
<div class="text-info small mt-1">
|
||||
{% if l.is_public %}Lista widoczna publicznie{% else %}Dostęp przez link / uprawnienia{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-warning small">Brak tokenu</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="access-editor" data-list-id="{{ l.id }}">
|
||||
<!-- Tokeny z uprawnieniami -->
|
||||
<div class="d-flex flex-wrap gap-2 mb-2 tokens">
|
||||
{% for u in permitted_by_list.get(l.id, []) %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary rounded-pill token"
|
||||
data-user-id="{{ u.id }}" data-username="{{ u.username }}" title="Kliknij, aby odebrać dostęp">
|
||||
@{{ u.username }} <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% if permitted_by_list.get(l.id, [])|length == 0 %}
|
||||
<span class="text-warning small no-perms">Brak dodanych uprawnień.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Dodawanie (wiele na raz) -->
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm bg-dark text-white border-secondary access-input"
|
||||
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" list="userHints"
|
||||
aria-label="Dodaj użytkowników">
|
||||
<button type="button" class="btn btn-sm btn-outline-light access-add">➕ Dodaj</button>
|
||||
</div>
|
||||
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp.</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if lists|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-4">Brak list do wyświetlenia</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz zmiany statusów</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not list_id %}
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||
<form method="get" class="d-flex align-items-center">
|
||||
<label for="per_page" class="me-2">🔢 Pozycji na stronę:</label>
|
||||
<select id="per_page" name="per_page" class="form-select form-select-sm me-2"
|
||||
onchange="this.form.page.value = 1; this.form.submit();">
|
||||
<option value="25" {% if per_page==25 %}selected{% endif %}>25</option>
|
||||
<option value="50" {% if per_page==50 %}selected{% endif %}>50</option>
|
||||
<option value="100" {% if per_page==100 %}selected{% endif %}>100</option>
|
||||
</select>
|
||||
<input type="hidden" name="page" value="{{ page }}">
|
||||
</form>
|
||||
|
||||
<nav aria-label="Nawigacja stron">
|
||||
<ul class="pagination pagination-dark mb-0">
|
||||
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page - 1 }}">«</a>
|
||||
</li>
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ p }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="page-item {% if page >= total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="?{{ query_string }}{% if query_string %}&{% endif %}page={{ page + 1 }}">»</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='lists_access.js') }}?v={{ APP_VERSION }}"></script>
|
||||
|
||||
{% endblock %}
|
@@ -95,67 +95,33 @@
|
||||
</div>
|
||||
|
||||
<!-- DOSTĘP DO LISTY -->
|
||||
<div class="card bg-secondary bg-opacity-10 text-white mb-5">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-3">🔐 Dostęp do listy</h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">👥 Użytkownicy z dostępem</label>
|
||||
|
||||
<!-- Link udostępniania -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">🔗 Link udostępniania (wejście przez link daje dostęp; zalogowani dostają
|
||||
uprawnienia na stałę po kliknięciu w link)</label>
|
||||
<div class="access-editor border rounded p-2 bg-dark" data-post-url="{{ request.path }}"
|
||||
data-suggest-url="{{ url_for('edit_my_list_suggestions', list_id=list.id) }}" data-next="{{ request.path }}"
|
||||
data-list-id="{{ list.id }}">
|
||||
|
||||
{% if list.share_token %}
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control bg-dark text-white border-secondary" readonly
|
||||
value="{{ url_for('shared_list', token=list.share_token, _external=True) }}" id="sharedListUrl"
|
||||
aria-label="Udostępniony link">
|
||||
<a class="btn btn-outline-light" href="{{ url_for('shared_list', token=list.share_token) }}" target="_blank"
|
||||
title="Otwórz">Otwórz
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-warning small">Brak tokenu udostępniania.</div>
|
||||
{% endif %}
|
||||
<div class="text-info small">Ustawienie „🌐 Publiczna” nie jest wymagane dla dostępu z linku.</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="m-0">
|
||||
<div class="row g-3 align-items-end mb-4">
|
||||
<div class="col-md-6">
|
||||
<label for="grant_username" class="form-label">➕ Nadaj dostęp użytkownikowi (login)</label>
|
||||
<input type="text" name="grant_username" id="grant_username"
|
||||
class="form-control bg-dark text-white border-secondary rounded" placeholder="np. marek">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-outline-light w-100">➕ Dodaj</button>
|
||||
</div>
|
||||
<input type="hidden" name="action" value="grant">
|
||||
<input type="hidden" name="next" value="{{ request.path }}">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Lista uprawnionych -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">👥 Użytkownicy z dostępem</label>
|
||||
{% if permitted_users and permitted_users|length > 0 %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for u in permitted_users %}
|
||||
<li
|
||||
class="list-group-item bg-dark text-white d-flex justify-content-between align-items-center border-secondary">
|
||||
<div>
|
||||
<span class="fw-semibold">@{{ u.username }}</span>
|
||||
</div>
|
||||
<form method="post" onsubmit="return confirm('Odebrać dostęp użytkownikowi @{{ u.username }}?');">
|
||||
<input type="hidden" name="revoke_user_id" value="{{ u.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">🚫 Odbierz uprawnienia</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}<br>
|
||||
<div class="text-warning small">Brak dodanych uprawnień.</div>
|
||||
<!-- Tokeny uprawnionych -->
|
||||
<div class="tokens d-flex flex-wrap gap-2 mb-2">
|
||||
{% for u in permitted_users %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary rounded-pill token" data-user-id="{{ u.id }}"
|
||||
data-username="{{ u.username }}" title="Kliknij, aby odebrać dostęp">
|
||||
@{{ u.username }} <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% if not permitted_users or permitted_users|length == 0 %}
|
||||
<span class="no-perms text-warning small">Brak dodanych uprawnień.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Dodawanie (wiele: przecinki/enter) + prywatne podpowiedzi -->
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="access-input form-control form-control-sm bg-dark text-white border-secondary"
|
||||
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" aria-label="Dodaj użytkowników">
|
||||
<button type="button" class="access-add btn btn-sm btn-outline-light">➕ Dodaj</button>
|
||||
</div>
|
||||
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -282,4 +248,5 @@
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='user_receipt_crop.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_crop_logic.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='select.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='access_users.js') }}?v={{ APP_VERSION }}"></script>
|
||||
{% endblock %}
|
@@ -244,31 +244,48 @@
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="grantAccessModalLabel">Nadaj dostęp użytkownikowi</h5>
|
||||
<h5 class="modal-title" id="grantAccessModalLabel">Nadaj dostęp użytkownikom</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('list_settings', list_id=list.id) }}" class="m-0">
|
||||
<div class="modal-body">
|
||||
<label for="grant_username" class="form-label">👥 Login użytkownika</label>
|
||||
<input type="text" name="grant_username" id="grant_username"
|
||||
class="form-control bg-dark text-white border-secondary rounded" placeholder="np. marek" required>
|
||||
|
||||
<input type="hidden" name="action" value="grant_access">
|
||||
<input type="hidden" name="next" value="{{ url_for('view_list', list_id=list.id) }}">
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="access-editor border rounded p-2 bg-dark"
|
||||
data-post-url="{{ url_for('list_settings', list_id=list.id) }}"
|
||||
data-suggest-url="{{ url_for('edit_my_list_suggestions', list_id=list.id) }}"
|
||||
data-next="{{ url_for('view_list', list_id=list.id) }}" data-list-id="{{ list.id }}"
|
||||
data-grant-action="grant_access" data-revoke-field="revoke_user_id">
|
||||
|
||||
<div class="modal-footer justify-content-end">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-outline-light" data-bs-dismiss="modal">❌ Anuluj</button>
|
||||
<button type="submit" class="btn btn-sm btn-outline-light">💾 Zapisz</button>
|
||||
<!-- Tokeny aktualnie uprawnionych -->
|
||||
<div class="tokens d-flex flex-wrap gap-2 mb-2">
|
||||
{% for u in permitted_users %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary rounded-pill token" data-user-id="{{ u.id }}"
|
||||
data-username="{{ u.username }}" title="Kliknij, aby odebrać dostęp">
|
||||
@{{ u.username }} <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% if not permitted_users or permitted_users|length == 0 %}
|
||||
<span class="no-perms text-warning small">Brak dodanych uprawnień.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Dodawanie wielu na raz + podpowiedzi prywatne -->
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="access-input form-control form-control-sm bg-dark text-white border-secondary"
|
||||
placeholder="Dodaj @użytkownika (wiele: przecinki/enter)" aria-label="Dodaj użytkowników">
|
||||
<button type="button" class="access-add btn btn-sm btn-outline-light">➕ Dodaj</button>
|
||||
</div>
|
||||
<div class="text-secondary small mt-1">Kliknij token, aby odebrać dostęp.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer justify-content-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-light" data-bs-dismiss="modal">Zamknij</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal fade" id="massAddModal" tabindex="-1" aria-labelledby="massAddModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
@@ -285,7 +302,7 @@
|
||||
<ul id="mass-add-list" class="list-group"></ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-light btn-sm" data-bs-dismiss="modal">Zamknij</button>
|
||||
<button type="button" class="btn btn-outline-light btn-sm w-100" data-bs-dismiss="modal">Zamknij</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -302,7 +319,7 @@
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='mass_add.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='receipt_upload.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='sort_mode.js') }}?v={{ APP_VERSION }}"></script>
|
||||
|
||||
<script src="{{ url_for('static_bp.serve_js', filename='access_users.js') }}?v={{ APP_VERSION }}"></script>
|
||||
<script>
|
||||
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');
|
||||
</script>
|
||||
|
Reference in New Issue
Block a user