feat: role-scoped viewer tokens — restrict shared links to student or staff items
Add a Role scope dropdown to the Share modal (All roles / Ansatte / Elever).
Scope is stored as {"role": "student"|"staff"} in viewer_tokens.json and
enforced server-side in GET /api/db/flagged via session["viewer_scope"].
Client-side, #filterRole is pre-set and hidden for scoped viewers so the
constraint cannot be bypassed. Existing tokens and PIN sessions remain
unrestricted. Role badge shown on each scoped token row in the Active links list.
Files: app_config.py, routes/viewer.py, routes/database.py, gdpr_scanner.py,
templates/index.html, static/js/viewer.js, static/js/auth.js,
lang/en.json, lang/da.json, lang/de.json,
CLAUDE.md, CHANGELOG.md, README.md, MANUAL-EN.md, MANUAL-DA.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0c35a7a83d
commit
1aaf400771
@ -11,6 +11,8 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
|
||||
|
||||
### Added
|
||||
|
||||
- **Role-scoped viewer tokens** — viewer token links can now be restricted to a single role so the recipient can only see student or staff items. A new **Role scope** dropdown (All roles / Ansatte / Elever) in the Share modal is selected when creating a token. The scope is stored as `"scope": {"role": "student"|"staff"}` in `viewer_tokens.json`. Enforcement is two-layered: `GET /api/db/flagged` filters items server-side using `session["viewer_scope"].role` set at token validation time; the `#filterRole` dropdown in the viewer is pre-set and hidden so the constraint cannot be bypassed client-side. Tokens without a scope field (existing tokens, PIN sessions) remain unrestricted. Role badge (Ansatte / Elever) shown on each scoped token row in the Active links list.
|
||||
|
||||
- **Role filter in results + role-scoped exports** — a new **Role** dropdown in the filter bar (All roles / Ansatte / Elever) narrows the results grid to staff or student items. Clicking **Excel** or **Art.30** while a role is selected exports only that group — the `?role=student|staff` param is forwarded to both export endpoints. `_build_excel_bytes()` and `_build_article30_docx()` now accept a `role` param; all internal sheets (GPS, External transfers, Art.30 staff/student tables) respect the filter. Filenames get an `_elever` or `_ansatte` suffix.
|
||||
|
||||
- **Scan filter options for student environments** — two new profile options reduce noise when scanning student accounts:
|
||||
|
||||
@ -44,9 +44,12 @@ Read-only access for DPOs and reviewers. Key invariants:
|
||||
|
||||
- **`/view` auth chain** — token (`?token=`) → session cookie (`session["viewer_ok"]`) → PIN form (if PIN configured) → 403. Never skip this order.
|
||||
- **`window.VIEWER_MODE`** — injected by Jinja2 in `index.html`. `auth.js` reads it at startup; adds `viewer-mode` class to `<body>`. All hide rules are CSS (`body.viewer-mode …`), not scattered JS checks — except `delBtn` in the card builder which is also guarded in JS. Hidden in viewer mode: `.sidebar` (entire left panel), `#logWrap`, `#progressBar`, scan/stop/profile/bulk-delete buttons, share button.
|
||||
- **`viewer_tokens.json` format** — stored as `{"tokens": [...], "__pin__": {"hash": "…", "salt": "…"}}`. The old bare-list format is migrated transparently on first write. Do not write the file as a bare list.
|
||||
- **`window.VIEWER_SCOPE`** — injected alongside `VIEWER_MODE`. Contains the scope dict from the token (e.g. `{"role": "student"}`). Empty object `{}` means unrestricted. `auth.js` reads it at startup; if `VIEWER_SCOPE.role` is set, it pre-sets `#filterRole` to that value and hides the dropdown so the viewer cannot change it.
|
||||
- **Token scope** — stored as `"scope": {"role": "student"|"staff"}` or `"scope": {}` in each token dict inside `viewer_tokens.json`. Enforced in two places: server-side (`GET /api/db/flagged` skips items whose `role` column does not match `session["viewer_scope"].role`) and client-side (the `#filterRole` dropdown is locked). Server-side is the authoritative guard.
|
||||
- **`session["viewer_scope"]`** — set when a token is validated at `/view`. Persists for the browser session alongside `session["viewer_ok"]`. Reads from `session.get("viewer_scope", {})` in `/api/db/flagged` — defaults to `{}` (unrestricted) for PIN-authenticated sessions and legacy tokens without a scope key.
|
||||
- **`viewer_tokens.json` format** — stored as `{"tokens": [...], "__pin__": {"hash": "…", "salt": "…"}}`. Token dicts now include `"scope": {}`. The old bare-list format and tokens without a `scope` key are handled transparently (`t.get("scope", {})`). Do not write the file as a bare list.
|
||||
- **`app.secret_key`** — derived from `machine_id` bytes so Flask sessions survive restarts. Set once at startup in `gdpr_scanner.py`; do not override it.
|
||||
- **`GET /api/db/flagged`** — returns `get_session_items()` (last completed scan session, joined with dispositions). Used exclusively by `_loadViewerResults()` in `results.js`. Do not confuse with `get_flagged_items()` (single scan_id, no disposition join).
|
||||
- **`GET /api/db/flagged`** — returns `get_session_items()` (last completed scan session, joined with dispositions), filtered by `session["viewer_scope"].role` when set. Used exclusively by `_loadViewerResults()` in `results.js`. Do not confuse with `get_flagged_items()` (single scan_id, no disposition join).
|
||||
- **Rate-limit state** (`_pin_attempts` dict in `routes/viewer.py`) — in-memory only, resets on server restart. Intentional — a restart clears lockouts without a persistent store.
|
||||
- **Token onclick attributes** — Copy/Revoke buttons in `_renderTokenList()` pass the token as a single-quoted JS string literal (`'\'' + tok.token + '\''`), never via `JSON.stringify`. `JSON.stringify` produces double-quoted strings that break the surrounding `onclick="…"` HTML attribute.
|
||||
- **Settings Security pane** — Admin PIN and Viewer PIN groups live in `stPaneSecurity`, not `stPaneGeneral`. `switchSettingsTab('security')` in `sources.js` triggers both `stLoadPinStatus()` and `stLoadViewerPinStatus()`. The Share modal Configure button opens `openSettings('security')`.
|
||||
|
||||
@ -42,7 +42,7 @@ an IDE with intelligent completion. The result is the author's work.
|
||||
- **Retention policy enforcement** — flag items older than a configurable retention period with a Overdue badge; supports both rolling and fiscal-year-aligned cutoffs (e.g. Bogføringsloven Dec 31); headless auto-delete via `--retention-years`
|
||||
- **Data subject lookup** — find all flagged items containing a specific CPR number across all scans; CPR is SHA-256 hashed before querying — never stored in plaintext
|
||||
- **Disposition tagging** — compliance officers can tag each flagged item with a legal basis (retain / delete-scheduled / deleted) directly from the preview panel
|
||||
- **Read-only viewer mode** — share scan results with a DPO or manager via a secure token URL (`/view?token=…`) or a numeric PIN; viewers see the full results grid and disposition panel but cannot scan, delete, or change settings
|
||||
- **Read-only viewer mode** — share scan results with a DPO or manager via a secure token URL (`/view?token=…`) or a numeric PIN; viewers see the full results grid and disposition panel but cannot scan, delete, or change settings. Tokens can be **role-scoped** (Ansatte / Elever) so a recipient can only see the items relevant to their remit
|
||||
- **Article 30 report** — one-click export of a structured Word document (`.docx`) satisfying the GDPR Article 30 register of processing activities obligation
|
||||
- **SQLite results database** — scan results, CPR index, PII breakdown, disposition decisions, and scan history are persisted to `~/.gdprscanner/scanner.db` alongside the JSON cache, enabling cross-scan queries and trend tracking
|
||||
- **Built-in user manual** — click the **?** button in the top bar to open the manual in a dedicated window. Available in Danish and English. Printable via the browser's print function. Served from `MANUAL-DA.md` / `MANUAL-EN.md` at `/manual?lang=da|en` — always in sync with the installed version, no internet required. In the packaged desktop app the manual opens as a native pywebview window; in the browser it opens as a popup.
|
||||
@ -624,7 +624,7 @@ See [SUGGESTIONS.md](SUGGESTIONS.md) for the full feature roadmap with implement
|
||||
| `routes/email.py` | `/api/smtp/*` and `/api/send_report` |
|
||||
| `routes/database.py` | `/api/db/*`, `/api/admin/*`, `/api/preview`, `/api/thumb` |
|
||||
| `routes/export.py` | `/api/export_excel`, `/api/export_article30`, `/api/delete_bulk` |
|
||||
| `routes/viewer.py` | `/view`, `/api/viewer/tokens`, `/api/viewer/pin` — read-only viewer mode: token + PIN auth, share-link management |
|
||||
| `routes/viewer.py` | `/view`, `/api/viewer/tokens`, `/api/viewer/pin` — read-only viewer mode: token + PIN auth, share-link management, role-scoped tokens |
|
||||
| `routes/app_routes.py` | `/api/about`, `/api/langs`, `/api/lang`, `/manual` |
|
||||
| `docs/manuals/MANUAL-EN.md` | End-user manual in English (15 sections) — served at `/manual?lang=en` |
|
||||
| `docs/manuals/MANUAL-DA.md` | End-user manual in Danish (15 sections) — served at `/manual?lang=da` |
|
||||
|
||||
@ -558,12 +558,14 @@ def _save_viewer_tokens(tokens: list) -> None:
|
||||
logger.error("[viewer_tokens] write failed: %s", e)
|
||||
|
||||
|
||||
def create_viewer_token(label: str = "", expires_days: int | None = None) -> dict:
|
||||
def create_viewer_token(label: str = "", expires_days: int | None = None, scope: dict | None = None) -> dict:
|
||||
"""Generate a new viewer token, persist it, and return the token dict.
|
||||
|
||||
Args:
|
||||
label: Human-readable description (e.g. "DPO review April 2026").
|
||||
expires_days: Days until expiry. None = no expiry.
|
||||
scope: Optional access scope, e.g. {"role": "student"} or {"role": "staff"}.
|
||||
Empty dict / None means unrestricted.
|
||||
"""
|
||||
import secrets as _secrets
|
||||
token = _secrets.token_hex(32) # 64-char URL-safe hex string
|
||||
@ -571,6 +573,7 @@ def create_viewer_token(label: str = "", expires_days: int | None = None) -> dic
|
||||
entry: dict = {
|
||||
"token": token,
|
||||
"label": label or "",
|
||||
"scope": scope or {},
|
||||
"created_at": now,
|
||||
"expires_at": now + expires_days * 86400 if expires_days else None,
|
||||
"last_used_at": None,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# GDPR Scanner — Brugermanual
|
||||
|
||||
Version 1.6.14
|
||||
Version 1.6.15
|
||||
|
||||
---
|
||||
|
||||
@ -359,15 +359,18 @@ Du kan give en DPO, skoleleder eller compliance-koordinator skrivebeskyttet adga
|
||||
Klik på **🔗**-knappen øverst til højre i topbjælken for at åbne delingspanelet.
|
||||
|
||||
1. Angiv eventuelt en **Betegnelse** for at identificere, hvem linket er til (f.eks. "DPO-gennemgang april 2026").
|
||||
2. Vælg en **Udløbsdato** — 7 dage, 30 dage, 90 dage, 1 år eller Aldrig.
|
||||
3. Klik på **Opret**. Der genereres et unikt link: `http://host:5100/view?token=…`
|
||||
4. Klik på **Kopiér** for at kopiere linket til udklipsholderen, og send det til gennemgangeren.
|
||||
2. Vælg et **Rolleomfang** — **Alle roller**, **Ansatte** eller **Elever**. Et afgrænset link begrænser modtageren til elementer tilhørende den valgte rollegruppe; de kan ikke se andre elementer, og rollefilteret er låst i deres visning.
|
||||
3. Vælg en **Udløbsdato** — 7 dage, 30 dage, 90 dage, 1 år eller Aldrig.
|
||||
4. Klik på **Opret**. Der genereres et unikt link: `http://host:5100/view?token=…`
|
||||
5. Klik på **Kopiér** for at kopiere linket til udklipsholderen, og send det til gennemgangeren.
|
||||
|
||||
Gennemgangeren åbner linket i en browser. De kan se det fulde resultatgitter og mærke dispositioner, men kan ikke starte scanninger, ændre indstillinger, se loginoplysninger eller slette elementer.
|
||||
Gennemgangeren åbner linket i en browser. De kan se resultatgitteret (afgrænset til det tilladte rolleomfang) og mærke dispositioner, men kan ikke starte scanninger, ændre indstillinger, se loginoplysninger eller slette elementer.
|
||||
|
||||
**Administrer eksisterende links**
|
||||
|
||||
Delingspanelet viser alle aktive links. Hver række viser betegnelse, udløbsdato og hvornår linket sidst blev brugt. Klik på **Kopiér** for at kopiere et link igen, eller **Tilbagekald** for at gøre det ugyldigt med det samme.
|
||||
Delingspanelet viser alle aktive links. Hver række viser betegnelse, rollemærkat (hvis afgrænset), udløbsdato og hvornår linket sidst blev brugt. Klik på **Kopiér** for at kopiere et link igen, eller **Tilbagekald** for at gøre det ugyldigt med det samme.
|
||||
|
||||
> **Tip:** I skoler og kommuner er det almindeligt at have separate DPO'er eller compliance-ansvarlige for henholdsvis ansatte og elever. Opret ét afgrænset link til hver — eleve-DPO'en vil kun se elevdata, og ansatte-DPO'en vil kun se ansattedata.
|
||||
|
||||
### 10.2 Viewer-PIN
|
||||
|
||||
@ -375,7 +378,7 @@ Som alternativ til token-links kan du angive en numerisk PIN-kode (4–8 cifre)
|
||||
|
||||
For at angive eller ændre PIN-koden skal du indtaste den nye kode i feltet **Ny PIN** og klikke på **Gem PIN**. Klik på **Ryd PIN** for at fjerne den.
|
||||
|
||||
> **Sikkerhedsnote:** Token-links er mere sikre end en PIN-kode, fordi hvert link kan tilbagekaldes individuelt og har en udløbsdato. Brug PIN-indstillingen kun til betroede interne gennemgangere på dit lokale netværk.
|
||||
> **Sikkerhedsnote:** Token-links er mere sikre end en PIN-kode, fordi hvert link kan tilbagekaldes individuelt, har en udløbsdato og kan afgrænses til en bestemt rollegruppe. Brug PIN-indstillingen kun til betroede interne gennemgangere på dit lokale netværk, der har brug for adgang til alle resultater.
|
||||
|
||||
### 10.3 Hvad gennemgangeren kan gøre
|
||||
|
||||
@ -392,6 +395,7 @@ For at angive eller ændre PIN-koden skal du indtaste den nye kode i feltet **Ny
|
||||
| Slette elementer | Nej |
|
||||
| Tilgå indstillinger | Nej |
|
||||
| Oprette eller tilbagekalde viewer-links | Nej |
|
||||
| Se elementer uden for deres rolleomfang | Nej |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# GDPR Scanner — User Manual
|
||||
|
||||
Version 1.6.14
|
||||
Version 1.6.15
|
||||
|
||||
---
|
||||
|
||||
@ -359,15 +359,18 @@ You can give a DPO, school principal, or compliance coordinator read-only access
|
||||
Click the **🔗** button in the top-right of the top bar to open the Share panel.
|
||||
|
||||
1. Optionally enter a **Label** to identify who the link is for (e.g. "DPO review April 2026").
|
||||
2. Choose an **Expiry** — 7 days, 30 days, 90 days, 1 year, or Never.
|
||||
3. Click **Create**. A unique link is generated: `http://host:5100/view?token=…`
|
||||
4. Click **Copy** to copy the link to your clipboard, then send it to the reviewer.
|
||||
2. Choose a **Role scope** — **All roles**, **Ansatte** (staff only), or **Elever** (students only). A scoped link restricts the recipient to items belonging to that role group; they cannot see any other items, and the role filter is locked in their view.
|
||||
3. Choose an **Expiry** — 7 days, 30 days, 90 days, 1 year, or Never.
|
||||
4. Click **Create**. A unique link is generated: `http://host:5100/view?token=…`
|
||||
5. Click **Copy** to copy the link to your clipboard, then send it to the reviewer.
|
||||
|
||||
The reviewer opens the link in any browser. They see the full results grid and can tag dispositions but cannot start scans, change settings, view credentials, or delete items.
|
||||
The reviewer opens the link in any browser. They see the results grid (filtered to their permitted scope) and can tag dispositions but cannot start scans, change settings, view credentials, or delete items.
|
||||
|
||||
**Managing existing links**
|
||||
|
||||
The Share panel lists all active links. Each row shows the label, expiry date, and when the link was last used. Click **Copy** to copy a link again, or **Revoke** to invalidate it immediately.
|
||||
The Share panel lists all active links. Each row shows the label, role badge (if scoped), expiry date, and when the link was last used. Click **Copy** to copy a link again, or **Revoke** to invalidate it immediately.
|
||||
|
||||
> **Tip:** In schools and municipalities it is common to have separate DPOs or compliance officers for staff data and student data. Create one scoped link for each — the student DPO will only ever see student items, and the staff DPO will only see staff items.
|
||||
|
||||
### 10.2 Viewer PIN
|
||||
|
||||
@ -375,7 +378,7 @@ As an alternative to token links, you can set a numeric PIN (4–8 digits) in **
|
||||
|
||||
To set or change the PIN, enter the new PIN in the **New PIN** field and click **Save PIN**. To remove it, click **Clear PIN**.
|
||||
|
||||
> **Security note:** Token links are more secure than a PIN because each link can be individually revoked and has an expiry date. Use the PIN option only for trusted internal reviewers on your local network.
|
||||
> **Security note:** Token links are more secure than a PIN because each link can be individually revoked, has an expiry date, and can be role-scoped. Use the PIN option only for trusted internal reviewers on your local network who need access to all results.
|
||||
|
||||
### 10.3 What the reviewer can do
|
||||
|
||||
@ -392,6 +395,7 @@ To set or change the PIN, enter the new PIN in the **New PIN** field and click *
|
||||
| Delete items | No |
|
||||
| Access Settings | No |
|
||||
| Create or revoke viewer links | No |
|
||||
| See items outside their role scope | No |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -383,17 +383,21 @@ def viewer():
|
||||
from app_config import validate_viewer_token, get_viewer_pin_hash
|
||||
token = request.args.get("token", "").strip()
|
||||
if token:
|
||||
if validate_viewer_token(token) is None:
|
||||
entry = validate_viewer_token(token)
|
||||
if entry is None:
|
||||
return render_template("viewer_denied.html"), 403
|
||||
# Bind a session so the viewer doesn't need the token on every navigation
|
||||
session["viewer_ok"] = True
|
||||
session["viewer_scope"] = entry.get("scope", {})
|
||||
return render_template("index.html", app_version=APP_VERSION,
|
||||
lang_json=json.dumps(LANG, ensure_ascii=False),
|
||||
viewer_mode=True)
|
||||
viewer_mode=True,
|
||||
viewer_scope=json.dumps(entry.get("scope", {}), ensure_ascii=False))
|
||||
if session.get("viewer_ok"):
|
||||
return render_template("index.html", app_version=APP_VERSION,
|
||||
lang_json=json.dumps(LANG, ensure_ascii=False),
|
||||
viewer_mode=True)
|
||||
viewer_mode=True,
|
||||
viewer_scope=json.dumps(session.get("viewer_scope", {}), ensure_ascii=False))
|
||||
# No token, no session — show PIN form if a PIN is configured, else deny
|
||||
pin_hash = get_viewer_pin_hash()
|
||||
if pin_hash:
|
||||
|
||||
@ -766,6 +766,10 @@
|
||||
"share_create_error": "Kunne ikke oprette link:",
|
||||
"share_revoke_confirm": "Tilbagekald dette link? Alle der bruger det, mister straks adgang.",
|
||||
"share_revoke_error": "Kunne ikke tilbagekalde:",
|
||||
"share_scope_lbl": "Rolleomfang",
|
||||
"share_scope_all": "Alle roller",
|
||||
"share_scope_staff": "Ansatte",
|
||||
"share_scope_student": "Elever",
|
||||
|
||||
"viewer_pin_group_title": "Seerens PIN",
|
||||
"viewer_pin_desc": "En numerisk PIN (4–8 cifre), der lader alle åbne <code style=\"font-size:10px\">/view</code> i en browser for skrivebeskyttet adgang til resultater uden et token-link.",
|
||||
|
||||
@ -766,6 +766,10 @@
|
||||
"share_create_error": "Link konnte nicht erstellt werden:",
|
||||
"share_revoke_confirm": "Diesen Link widerrufen? Alle Nutzer verlieren sofort den Zugriff.",
|
||||
"share_revoke_error": "Widerrufen fehlgeschlagen:",
|
||||
"share_scope_lbl": "Rollenumfang",
|
||||
"share_scope_all": "Alle Rollen",
|
||||
"share_scope_staff": "Mitarbeitende",
|
||||
"share_scope_student": "Schüler",
|
||||
|
||||
"viewer_pin_group_title": "Betrachter-PIN",
|
||||
"viewer_pin_desc": "Eine numerische PIN (4–8 Stellen), die es jedem ermöglicht, <code style=\"font-size:10px\">/view</code> im Browser zu öffnen und schreibgeschützt auf Ergebnisse zuzugreifen \u2013 ohne Token-Link.",
|
||||
|
||||
@ -766,6 +766,10 @@
|
||||
"share_create_error": "Failed to create link:",
|
||||
"share_revoke_confirm": "Revoke this link? Anyone using it will immediately lose access.",
|
||||
"share_revoke_error": "Failed to revoke:",
|
||||
"share_scope_lbl": "Role scope",
|
||||
"share_scope_all": "All roles",
|
||||
"share_scope_staff": "Staff",
|
||||
"share_scope_student": "Students",
|
||||
|
||||
"viewer_pin_group_title": "Viewer PIN",
|
||||
"viewer_pin_desc": "A numeric PIN (4\u20138 digits) that lets anyone open <code style=\"font-size:10px\">/view</code> in a browser for read-only access to results without a token URL.",
|
||||
|
||||
@ -148,13 +148,19 @@ def db_get_disposition(item_id):
|
||||
def db_flagged_items():
|
||||
"""Return flagged items from the most recent completed scan session.
|
||||
Used by the read-only viewer to load results without an active SSE connection.
|
||||
Respects viewer_scope.role stored in the session for scoped tokens.
|
||||
"""
|
||||
if not DB_OK: return jsonify([])
|
||||
from flask import session as _session
|
||||
scope = _session.get("viewer_scope", {})
|
||||
role_filt = scope.get("role", "") if isinstance(scope, dict) else ""
|
||||
items = _get_db().get_session_items()
|
||||
# Normalise JSON-encoded columns the same way scan_engine does for SSE cards
|
||||
import json as _json
|
||||
out = []
|
||||
for row in items:
|
||||
if role_filt and row.get("role", "") != role_filt:
|
||||
continue
|
||||
row["special_category"] = _json.loads(row.get("special_category") or "[]") if isinstance(row.get("special_category"), str) else row.get("special_category", [])
|
||||
row["exif"] = _json.loads(row.get("exif_json") or "{}") if isinstance(row.get("exif_json"), str) else row.get("exif", {})
|
||||
row.pop("exif_json", None)
|
||||
|
||||
@ -52,6 +52,7 @@ def list_tokens():
|
||||
"token_hint": t["token"][:8] + "…",
|
||||
"token": t["token"],
|
||||
"label": t.get("label", ""),
|
||||
"scope": t.get("scope", {}),
|
||||
"created_at": t.get("created_at"),
|
||||
"expires_at": t.get("expires_at"),
|
||||
"last_used_at": t.get("last_used_at"),
|
||||
@ -73,7 +74,14 @@ def create_token():
|
||||
return jsonify({"error": "expires_days must be a positive integer"}), 400
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "expires_days must be a positive integer"}), 400
|
||||
entry = create_viewer_token(label=label, expires_days=expires_days)
|
||||
raw_scope = body.get("scope", {})
|
||||
if not isinstance(raw_scope, dict):
|
||||
return jsonify({"error": "scope must be an object"}), 400
|
||||
role = str(raw_scope.get("role", "")).strip()
|
||||
if role not in ("", "student", "staff"):
|
||||
return jsonify({"error": "scope.role must be '', 'student', or 'staff'"}), 400
|
||||
scope = {"role": role} if role else {}
|
||||
entry = create_viewer_token(label=label, expires_days=expires_days, scope=scope)
|
||||
return jsonify(entry), 201
|
||||
|
||||
|
||||
|
||||
@ -159,6 +159,12 @@ if (window.VIEWER_MODE) {
|
||||
document.body.classList.add('viewer-mode');
|
||||
document.getElementById('authScreen').style.display = 'none';
|
||||
document.getElementById('scannerScreen').style.display = 'flex';
|
||||
// If this token is role-scoped, lock the filter to that role and hide the dropdown.
|
||||
const _scopeRole = (window.VIEWER_SCOPE || {}).role || '';
|
||||
if (_scopeRole) {
|
||||
const _fr = document.getElementById('filterRole');
|
||||
if (_fr) { _fr.value = _scopeRole; _fr.style.display = 'none'; }
|
||||
}
|
||||
try { loadTrend(); } catch(e) {}
|
||||
} else {
|
||||
(async function() {
|
||||
|
||||
@ -20,6 +20,8 @@ function openShareModal() {
|
||||
document.getElementById('shareNewLinkRow').style.display = 'none';
|
||||
document.getElementById('shareLabel').value = '';
|
||||
document.getElementById('shareExpiry').value = '30';
|
||||
const scopeSel = document.getElementById('shareScope');
|
||||
if (scopeSel) scopeSel.value = '';
|
||||
_renderTokenList();
|
||||
fetch('/api/viewer/pin').then(function(r){ return r.json(); }).then(function(d) {
|
||||
const el = document.getElementById('sharePinStatus');
|
||||
@ -51,10 +53,18 @@ async function _renderTokenList() {
|
||||
: '—';
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:12px';
|
||||
const roleVal = tok.scope?.role || '';
|
||||
const roleLbl = roleVal === 'student' ? t('share_scope_student', 'Elever')
|
||||
: roleVal === 'staff' ? t('share_scope_staff', 'Ansatte')
|
||||
: '';
|
||||
const roleBadge = roleLbl
|
||||
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--accent);color:#fff;margin-left:5px;font-weight:600;vertical-align:middle">' + roleLbl + '</span>'
|
||||
: '';
|
||||
row.innerHTML =
|
||||
'<div style="flex:1;min-width:0">' +
|
||||
'<div style="font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
|
||||
(tok.label || '<span style="color:var(--muted);font-style:italic">' + t('share_unlabelled', 'Unlabelled') + '</span>') +
|
||||
roleBadge +
|
||||
'</div>' +
|
||||
'<div style="font-size:10px;color:var(--muted);margin-top:1px">' +
|
||||
t('share_expires_prefix', 'Expires:') + ' ' + expires + ' · ' + t('share_last_used', 'Last used:') + ' ' + lastUsed +
|
||||
@ -74,8 +84,10 @@ async function _renderTokenList() {
|
||||
async function createShareLink() {
|
||||
const label = document.getElementById('shareLabel').value.trim();
|
||||
const expiry = document.getElementById('shareExpiry').value;
|
||||
const role = document.getElementById('shareScope')?.value || '';
|
||||
const body = {label};
|
||||
if (expiry) body.expires_days = parseInt(expiry);
|
||||
if (role) body.scope = {role};
|
||||
try {
|
||||
const r = await fetch('/api/viewer/tokens', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
var LANG = {{ lang_json | safe }};
|
||||
// ── Viewer mode ───────────────────────────────────────────────────────────────
|
||||
window.VIEWER_MODE = {{ 'true' if viewer_mode else 'false' }};
|
||||
window.VIEWER_SCOPE = {{ viewer_scope | safe if viewer_scope is defined else '{}' }};
|
||||
function t(key, fallback) {
|
||||
return LANG[key] !== undefined ? LANG[key] : (fallback !== undefined ? fallback : key);
|
||||
}
|
||||
@ -881,6 +882,14 @@ document.addEventListener('DOMContentLoaded', applyI18n);
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:3px" data-i18n="share_label_lbl">Label (optional)</div>
|
||||
<input id="shareLabel" type="text" data-i18n-placeholder="share_label_placeholder" placeholder="e.g. DPO review 2026" style="width:100%;box-sizing:border-box;font-size:12px;padding:5px 8px;background:var(--surface);border:1px solid var(--border);border-radius:5px;color:var(--text)">
|
||||
</div>
|
||||
<div style="width:100px">
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:3px" data-i18n="share_scope_lbl">Role scope</div>
|
||||
<select id="shareScope" style="width:100%;font-size:12px;padding:5px 6px;background:var(--surface);border:1px solid var(--border);border-radius:5px;color:var(--text)">
|
||||
<option value="" data-i18n="share_scope_all">All roles</option>
|
||||
<option value="staff" data-i18n="share_scope_staff">Ansatte</option>
|
||||
<option value="student" data-i18n="share_scope_student">Elever</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="width:100px">
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:3px" data-i18n="share_expires_in">Expires in</div>
|
||||
<select id="shareExpiry" style="width:100%;font-size:12px;padding:5px 6px;background:var(--surface);border:1px solid var(--border);border-radius:5px;color:var(--text)">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user