rozszerzone uprawnienia

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

View File

@@ -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
View File

@@ -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
View File

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

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

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

View File

@@ -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 %}

View 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 %}

View File

@@ -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 %}

View File

@@ -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>