Compare commits

...

6 Commits

6 changed files with 499 additions and 65 deletions
+194 -45
View File
@@ -206,9 +206,10 @@ PERMISSION_ACTION_ENDPOINTS = {
'register': 'can_manage_users',
'admin_reset_user_password': 'can_manage_users',
'admin_update_user_permissions': 'can_manage_users',
'admin_anonymize_names': 'can_manage_users',
'home_admin': 'can_manage_settings',
'upload_admin': 'can_manage_settings',
'library_admin': 'can_manage_settings',
'upload_admin': 'can_insert',
'library_admin': 'can_insert',
'admin_borrowings': 'can_manage_settings',
'library_loans_admin': 'can_manage_settings',
'admin_damaged_items': 'can_manage_settings',
@@ -297,7 +298,7 @@ def _enforce_user_permissions():
return jsonify({'ok': False, 'message': message}), 403
flash(message, 'error')
fallback_endpoint = _permission_denied_fallback_endpoint(permissions)
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint=endpoint)
return redirect(url_for(fallback_endpoint))
action_key = PERMISSION_ACTION_ENDPOINTS.get(endpoint)
@@ -307,7 +308,7 @@ def _enforce_user_permissions():
return jsonify({'ok': False, 'message': message}), 403
flash(message, 'error')
fallback_endpoint = _permission_denied_fallback_endpoint(permissions)
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint=endpoint)
return redirect(url_for(fallback_endpoint))
return None
@@ -375,8 +376,16 @@ def _action_access_allowed(permissions, action_key):
return bool(action_permissions.get(action_key, True))
def _permission_denied_fallback_endpoint(permissions):
for candidate in ('home', 'my_borrowed_items', 'tutorial_page', 'notifications_view', 'impressum'):
def _permission_denied_fallback_endpoint(permissions, current_endpoint=None):
username = session.get('username')
is_admin_user = bool(username and us.check_admin(username))
admin_home_allowed = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
for candidate in ('my_borrowed_items', 'tutorial_page', 'notifications_view', 'impressum', 'home'):
if current_endpoint and candidate == current_endpoint:
continue
if candidate == 'home' and is_admin_user and not admin_home_allowed:
continue
if _page_access_allowed(permissions, candidate):
return candidate
return 'logout'
@@ -1588,14 +1597,25 @@ def _student_card_id_slug(value):
return re.sub(r'[^a-z0-9]+', '', normalized).upper()
def _name_to_alias(full_name):
"""Convert clear names to deterministic aliases, e.g. Simon Frings -> SimFri."""
text = sanitize_form_value(full_name)
if not text:
return 'User'
parts = [p for p in re.split(r'\s+', text) if p]
if len(parts) >= 2:
return us.build_name_synonym(parts[0], parts[-1])
return us.build_name_synonym(parts[0], '')
def _build_student_card_excel_id(student_name, class_name, row_number, used_ids):
"""Create a stable student-card ID when the spreadsheet does not provide one."""
name_slug = _student_card_id_slug(student_name)
"""Create a stable student-card ID without embedding personal names."""
class_slug = _student_card_id_slug(class_name)
base_parts = [part for part in (class_slug, name_slug) if part]
base_parts = [part for part in (class_slug,) if part]
if base_parts:
base_id = f"SC-{'-'.join(base_parts[:2])}"
base_id = f"SC-{'-'.join(base_parts[:1])}-ROW-{row_number}"
else:
base_id = f"SC-ROW-{row_number}"
@@ -1715,6 +1735,8 @@ def _upload_student_cards_excel():
student_name = f'{first_name} {last_name}'.strip()
validation_warnings.append((row_number, 'Schülername wurde aus Vorname und Nachname zusammengesetzt'))
student_name_alias = _name_to_alias(student_name)
if not ausweis_id and not student_name and not class_name:
continue
@@ -1739,7 +1761,7 @@ def _upload_student_cards_excel():
planned_rows.append({
'row_number': row_number,
'ausweis_id': ausweis_id,
'student_name': student_name,
'student_name': student_name_alias,
'class_name': class_name,
'notes': notes,
'default_borrow_days': default_borrow_days,
@@ -1804,8 +1826,9 @@ def _upload_excel_items(scope='inventory'):
flash('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
flash('Administratorrechte erforderlich.', 'error')
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if not _action_access_allowed(permissions, 'can_insert'):
flash('Einfüge-Rechte erforderlich.', 'error')
return redirect(url_for('home'))
is_library_scope = scope == 'library'
@@ -1815,7 +1838,7 @@ def _upload_excel_items(scope='inventory'):
if is_library_scope:
if not cfg.LIBRARY_MODULE_ENABLED:
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
return redirect(url_for('home'))
excel_file = request.files.get(file_field)
if not excel_file or not excel_file.filename:
@@ -2393,7 +2416,14 @@ def home():
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
)
else:
return redirect(url_for('home_admin'))
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings'):
return redirect(url_for('home_admin'))
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint='home')
if fallback_endpoint == 'logout':
flash('Für diesen Benutzer sind aktuell keine Seiten freigegeben.', 'error')
return redirect(url_for(fallback_endpoint))
@app.route('/home_admin')
@@ -2988,8 +3018,8 @@ def api_library_item_update(item_id):
@app.route('/upload_admin')
def upload_admin():
"""
Admin upload page route.
Only accessible by users with admin privileges.
Upload page route for inventory items.
Accessible to users with insert permission.
Supports duplication by passing duplicate_from parameter.
Returns:
@@ -2998,7 +3028,8 @@ def upload_admin():
if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if not _action_access_allowed(permissions, 'can_insert'):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
@@ -3062,13 +3093,14 @@ def upload_admin():
@app.route('/library_admin')
def library_admin():
"""
Dedicated admin page for library/book uploads with ISBN scanning.
Only accessible by admins and only when the library module is enabled.
Dedicated page for library/book uploads with ISBN scanning.
Accessible to users with insert permission when the library module is enabled.
"""
if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if not _action_access_allowed(permissions, 'can_insert'):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
if not cfg.LIBRARY_MODULE_ENABLED:
@@ -3172,6 +3204,7 @@ def student_cards_admin():
action = request.form.get('action', 'add')
ausweis_id = request.form.get('ausweis_id', '').strip().upper()
student_name = request.form.get('student_name', '').strip()
student_name_alias = _name_to_alias(student_name)
default_borrow_days = request.form.get('default_borrow_days', 14)
class_name = request.form.get('class_name', '').strip()
notes = request.form.get('notes', '').strip()
@@ -3198,7 +3231,7 @@ def student_cards_admin():
else:
encrypted_payload = encrypt_document_fields(
{
'SchülerName': student_name,
'SchülerName': student_name_alias,
'Klasse': class_name,
'Notizen': notes,
},
@@ -3231,7 +3264,7 @@ def student_cards_admin():
try:
encrypted_payload = encrypt_document_fields(
{
'SchülerName': student_name,
'SchülerName': student_name_alias,
'Klasse': class_name,
'Notizen': notes,
},
@@ -3700,8 +3733,19 @@ def login():
is_admin_user = bool(user.get('Admin', False))
session['admin'] = is_admin_user
session['is_admin'] = is_admin_user
# Bind session favorites to the authenticated user to avoid cross-user leakage.
try:
session['favorites_owner'] = username
session['favorites'] = list(dict.fromkeys([str(f) for f in us.get_favorites(username)]))
except Exception:
session['favorites_owner'] = username
session['favorites'] = []
if is_admin_user:
return redirect(url_for('home_admin'))
permissions = us.get_effective_permissions(username)
if _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings'):
return redirect(url_for('home_admin'))
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint='login')
return redirect(url_for(fallback_endpoint))
else:
return redirect(url_for('home'))
else:
@@ -3790,6 +3834,8 @@ def logout():
session.pop('username', None)
session.pop('admin', None)
session.pop('is_admin', None)
session.pop('favorites', None)
session.pop('favorites_owner', None)
return redirect(url_for('login'))
@@ -3798,6 +3844,7 @@ def get_items():
"""Return items plus merged favorites (session + DB) and per-item favorite flag."""
client = None
try:
_ensure_session_favs()
username = session.get('username')
# Merge DB favorites into session if logged in
if username:
@@ -4044,8 +4091,23 @@ def api_booking_conflicts():
"""Favorites management endpoints (persistent + session cache)."""
def _ensure_session_favs():
if 'favorites' not in session:
username = session.get('username')
owner = session.get('favorites_owner')
if not username:
if 'favorites' not in session or not isinstance(session.get('favorites'), list):
session['favorites'] = []
return
if owner != username:
session['favorites_owner'] = username
session['favorites'] = []
session.modified = True
return
if 'favorites' not in session or not isinstance(session.get('favorites'), list):
session['favorites'] = []
session.modified = True
@app.route('/favorites', methods=['GET'])
def list_favorites():
@@ -4132,6 +4194,7 @@ def toggle_fav(item_id):
@app.route('/debug/favorites')
def debug_favorites():
"""Diagnostic endpoint: shows session favorites, DB favorites and merged output."""
_ensure_session_favs()
username = session.get('username')
session_favs = list(session.get('favorites', []))
db_favs = []
@@ -4158,11 +4221,15 @@ def upload_item():
if 'username' not in session:
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
# Check if user is an admin
# Check if user may insert items
username = session['username']
if not us.check_admin(username):
return jsonify({'success': False, 'message': 'Administratorrechte erforderlich'}), 403
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if not _action_access_allowed(permissions, 'can_insert'):
return jsonify({'success': False, 'message': 'Einfüge-Rechte erforderlich'}), 403
can_access_admin_home = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
success_redirect_endpoint = 'home_admin' if can_access_admin_home else 'home'
# Detect if request is from mobile device
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
is_ios = 'iPhone' in request.headers.get('User-Agent', '') or 'iPad' in request.headers.get('User-Agent', '')
@@ -4243,7 +4310,7 @@ def upload_item():
return jsonify({'success': False, 'message': error_msg}), 400
else:
flash('Fehler beim Verarbeiten der Formulardaten. Bitte versuchen Sie es erneut.', 'error')
return redirect(url_for('home_admin'))
return redirect(url_for(success_redirect_endpoint))
# Expand special "all values" selections for predefined filters.
filter_upload = expand_filter_selection(filter_upload, 1)
@@ -4256,7 +4323,7 @@ def upload_item():
return jsonify({'success': False, 'message': error_msg}), 400
else:
flash(error_msg, 'error')
return redirect(url_for('home_admin'))
return redirect(url_for(success_redirect_endpoint))
item_isbn = ''
item_type = 'general'
@@ -4267,7 +4334,7 @@ def upload_item():
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for('home_admin'))
return redirect(url_for(success_redirect_endpoint))
if item_isbn:
item_type = 'book'
@@ -4277,7 +4344,7 @@ def upload_item():
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for('home_admin'))
return redirect(url_for(success_redirect_endpoint))
if not item_isbn:
error_msg = 'Für Bücher ist eine gültige ISBN erforderlich.'
if is_mobile:
@@ -4294,7 +4361,7 @@ def upload_item():
return jsonify({'success': False, 'message': error_msg}), 400
else:
flash(error_msg, 'error')
return redirect(url_for('home_admin'))
return redirect(url_for(success_redirect_endpoint))
# Check if base code is unique for single-item uploads
if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]):
@@ -4303,7 +4370,7 @@ def upload_item():
return jsonify({'success': False, 'message': error_msg}), 400
else:
flash(error_msg, 'error')
return redirect(url_for('home_admin'))
return redirect(url_for(success_redirect_endpoint))
# Validate optional per-item codes
if individual_codes:
@@ -4312,14 +4379,14 @@ def upload_item():
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for('home_admin'))
return redirect(url_for(success_redirect_endpoint))
if len(set(individual_codes)) != len(individual_codes):
error_msg = 'Doppelte Einzelcodes erkannt. Bitte alle Codes eindeutig eintragen.'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for('home_admin'))
return redirect(url_for(success_redirect_endpoint))
for specific_code in individual_codes:
if not it.is_code_unique(specific_code):
@@ -4327,7 +4394,7 @@ def upload_item():
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for('home_admin'))
return redirect(url_for(success_redirect_endpoint))
def generate_unique_batch_code(base_code, position):
"""Generate a unique code for every item in a batch."""
@@ -5020,7 +5087,7 @@ def upload_item():
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for('home_admin'))
return redirect(url_for(success_redirect_endpoint))
parent_item_id = str(created_item_ids[0]) if created_item_ids else None
item_id = it.add_item(
@@ -5047,7 +5114,7 @@ def upload_item():
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 500
flash(error_msg, 'error')
return redirect(url_for('home_admin'))
return redirect(url_for(success_redirect_endpoint))
item_id = created_item_ids[0] if created_item_ids else None
@@ -5071,14 +5138,14 @@ def upload_item():
})
else:
flash(success_msg, 'success')
return redirect(url_for('home_admin', highlight_item=str(item_id)))
return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id)))
else:
error_msg = 'Fehler beim Hinzufügen des Elements'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 500
else:
flash(error_msg, 'error')
return redirect(url_for('home_admin'))
return redirect(url_for(success_redirect_endpoint))
@app.route('/duplicate_item', methods=['POST'])
@@ -6654,12 +6721,14 @@ def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
name = request.form['name']
last_name = request.form['last-name']
name = (request.form.get('name') or '').strip()
last_name = (request.form.get('last-name') or '').strip()
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
use_custom_permissions = request.form.get('use_custom_permissions') == 'on'
is_student = bool(request.form.get('is_student')) if cfg.STUDENT_CARDS_MODULE_ENABLED else False
student_card_id = us.normalize_student_card_id(request.form.get('student_card_id')) if cfg.STUDENT_CARDS_MODULE_ENABLED else ''
max_borrow_days_raw = request.form.get('max_borrow_days') if cfg.STUDENT_CARDS_MODULE_ENABLED else None
if not username or not password:
if not username or not password or not name or not last_name:
flash('Bitte füllen Sie alle Felder aus', 'error')
return redirect(url_for('register'))
if us.get_user(username):
@@ -6669,6 +6738,17 @@ def register():
flash('Passwort ist zu schwach', 'error')
return redirect(url_for('register'))
action_permissions = None
page_permissions = None
if use_custom_permissions:
action_permissions = {}
for action_key, _ in PERMISSION_ACTION_OPTIONS:
action_permissions[action_key] = request.form.get(f'action_{action_key}') == 'on'
page_permissions = {}
for endpoint_name, _ in PERMISSION_PAGE_OPTIONS:
page_permissions[endpoint_name] = request.form.get(f'page_{endpoint_name}') == 'on'
max_borrow_days = None
if is_student:
if not student_card_id:
@@ -6690,7 +6770,10 @@ def register():
last_name,
is_student=is_student,
student_card_id=student_card_id if is_student else None,
max_borrow_days=max_borrow_days
max_borrow_days=max_borrow_days,
permission_preset=permission_preset,
action_permissions=action_permissions,
page_permissions=page_permissions,
)
return redirect(url_for('home'))
return render_template(
@@ -7811,6 +7894,72 @@ def admin_update_user_permissions():
return redirect(url_for('user_del'))
@app.route('/admin_anonymize_names', methods=['POST'])
def admin_anonymize_names():
"""Anonymize already stored personal names into short aliases."""
if 'username' not in session or not us.check_admin(session['username']):
flash('Nicht autorisierter Zugriff', 'error')
return redirect(url_for('login'))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
users_col = db['users']
student_cards_col = db['student_cards']
users_updated = 0
cards_updated = 0
for user_doc in users_col.find({}, {'name': 1, 'last_name': 1, 'Username': 1, 'username': 1}):
first = str(user_doc.get('name') or '').strip()
last = str(user_doc.get('last_name') or '').strip()
fallback = str(user_doc.get('Username') or user_doc.get('username') or '').strip()
alias = us.build_name_synonym(first or fallback, last)
result = users_col.update_one(
{'_id': user_doc['_id']},
{'$set': {'name': alias, 'last_name': ''}}
)
if result.modified_count > 0:
users_updated += 1
for card_doc in student_cards_col.find({}, {'SchülerName': 1, 'Klasse': 1, 'Notizen': 1}):
decrypted = _decrypt_student_card_doc(card_doc)
alias = _name_to_alias(decrypted.get('SchülerName', ''))
class_name = sanitize_form_value(decrypted.get('Klasse', ''))
notes = sanitize_form_value(decrypted.get('Notizen', ''))
encrypted_payload = encrypt_document_fields(
{
'SchülerName': alias,
'Klasse': class_name,
'Notizen': notes,
},
STUDENT_CARD_ENCRYPTED_FIELDS,
)
result = student_cards_col.update_one(
{'_id': card_doc['_id']},
{'$set': {'Aktualisiert': datetime.datetime.now(), **encrypted_payload}}
)
if result.modified_count > 0:
cards_updated += 1
flash(
f'Anonymisierung abgeschlossen: {users_updated} Benutzer und {cards_updated} Ausweise aktualisiert.',
'success'
)
except Exception as exc:
app.logger.error(f'Error anonymizing names: {exc}')
flash('Fehler bei der Anonymisierung der Namen.', 'error')
finally:
if client:
client.close()
return redirect(url_for('user_del'))
@app.route('/logs')
def logs():
"""
+2 -2
View File
@@ -786,7 +786,7 @@
</li>
{% endif %}
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_manage_settings', True) %}
{% if 'username' in session and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}"> Hochladen</a>
</li>
@@ -907,7 +907,7 @@
</li>
{% endif %}
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) and current_permissions.pages.get('library_admin', True) %}
{% if 'username' in session and current_permissions.actions.get('can_insert', True) and current_permissions.pages.get('library_admin', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
</li>
+5 -4
View File
@@ -843,7 +843,7 @@
const favoriteIds = new Set(data.favorites || []);
window.currentFavorites = favoriteIds;
try { sessionStorage.setItem('favoritesCache', JSON.stringify(Array.from(favoriteIds))); } catch(e){}
try { sessionStorage.setItem('favoritesCache:' + ({{ session.get('username', '') | tojson }} || 'anon'), JSON.stringify(Array.from(favoriteIds))); } catch(e){}
pageItems.forEach(item => {
try {
const card = document.createElement('div');
@@ -4233,6 +4233,7 @@
<script>
let favoritesOnly = false;
let tableViewMode = false;
const favoritesCacheKey = 'favoritesCache:' + ({{ session.get('username', '') | tojson }} || 'anon');
function setViewModeState() {
document.body.classList.toggle('table-view', tableViewMode);
@@ -4263,7 +4264,7 @@ function toggleFavorite(id, btn, card){
}
if(!window.currentFavorites) window.currentFavorites = new Set();
if(isFav) window.currentFavorites.add(id); else window.currentFavorites.delete(id);
try { sessionStorage.setItem('favoritesCache', JSON.stringify(Array.from(window.currentFavorites))); } catch(e){}
try { sessionStorage.setItem(favoritesCacheKey, JSON.stringify(Array.from(window.currentFavorites))); } catch(e){}
})
.catch(err=>console.error('Netzwerkfehler Favoriten', err));
}
@@ -4271,7 +4272,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
// Initialize favorites cache set if stored
try {
if(!window.currentFavorites){
const cached = sessionStorage.getItem('favoritesCache');
const cached = sessionStorage.getItem(favoritesCacheKey);
if(cached){ window.currentFavorites = new Set(JSON.parse(cached)); }
}
} catch(e){}
@@ -4310,7 +4311,7 @@ function openItemQuick(id){
if(item && !item.error){
// ensure favorites set available
if(!window.currentFavorites){
window.currentFavorites = new Set(JSON.parse(sessionStorage.getItem('favoritesCache')||'[]'));
window.currentFavorites = new Set(JSON.parse(sessionStorage.getItem(favoritesCacheKey)||'[]'));
}
openItemModal(item);
}
+216 -9
View File
@@ -39,21 +39,31 @@
<span class="input-icon">👤</span>
<input type="text" id="username" name="username" placeholder="Geben Sie einen Benutzernamen ein" required>
</div>
<label for="name">Name</label>
<label for="name">Vorname</label>
<div class="input-container">
<span class="input-icon">👤</span>
<input type="text" id="name" name="name" placeholder="Geben Sie den Namen ein" required>
<input type="text" id="name" name="name" placeholder="Geben Sie den Vornamen ein" required>
</div>
<label for="last-name">Nachname</label>
<div class="input-container">
<span class="input-icon">👤</span>
<input type="text" id="last-name" name="last-name" placeholder="Geben Sie den Nachnamen ein" required>
</div>
<p class="anonymize-hint">Klarnamen werden nur zur Erzeugung eines Kuerzels (z.B. SimFri) verwendet und nicht als Klarname gespeichert.</p>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<a class="richtlinen">Das Password muss mindestens 6 Zeichen beinhalten mit Sonderzeichen, groß und klein Buchstaben sowie Zahlen!</a>
<div class="password-rules" id="password-rules" aria-live="polite">
<p class="password-rules-title">Passwort-Anforderungen (live):</p>
<ul>
<li id="pw-rule-length" class="pw-rule">Mindestens 12 Zeichen</li>
<li id="pw-rule-lower" class="pw-rule">Mindestens ein Kleinbuchstabe</li>
<li id="pw-rule-upper" class="pw-rule">Mindestens ein Grossbuchstabe</li>
<li id="pw-rule-digit" class="pw-rule">Mindestens eine Zahl</li>
<li id="pw-rule-symbol" class="pw-rule">Mindestens ein Sonderzeichen</li>
</ul>
</div>
<div class="input-container">
<span class="input-icon">🔒</span>
<input type="password" id="password" name="password" placeholder="Geben Sie ein sicheres Passwort ein" required>
@@ -81,6 +91,43 @@
</div>
{% endif %}
<div class="form-group">
<label for="permission-preset">Berechtigungs-Preset</label>
<select id="permission-preset" name="permission_preset" class="form-select">
{% for preset_key, preset in permission_presets.items() %}
<option value="{{ preset_key }}">{{ preset.label }}</option>
{% endfor %}
</select>
<label style="display:flex; align-items:center; gap:8px; margin-top:12px; color:#1f2937;">
<input type="checkbox" id="use-custom-permissions" name="use_custom_permissions" style="width:auto;">
Individuelle Berechtigungen statt Preset setzen
</label>
<div id="custom-permissions" style="display:none; margin-top:12px;">
<div class="permission-panels">
<div class="permission-panel">
<h4>Aktionsrechte</h4>
{% for action_key, action_label in permission_action_options %}
<label class="permission-check">
<input type="checkbox" class="permission-action-checkbox" name="action_{{ action_key }}">
<span>{{ action_label }}</span>
</label>
{% endfor %}
</div>
<div class="permission-panel">
<h4>Seitenrechte</h4>
{% for endpoint_name, endpoint_label in permission_page_options %}
<label class="permission-check">
<input type="checkbox" class="permission-page-checkbox" name="page_{{ endpoint_name }}">
<span>{{ endpoint_label }}</span>
</label>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="form-group form-actions">
<button type="submit" class="action-button register-button">Benutzer registrieren</button>
</div>
@@ -174,7 +221,8 @@ body {
}
input[type="text"],
input[type="password"] {
input[type="password"],
.form-select {
width: 100%;
padding: 0.8rem 1rem 0.8rem 3rem;
border: 1px solid #ddd;
@@ -185,12 +233,17 @@ input[type="password"] {
}
input[type="text"]:focus,
input[type="password"]:focus {
input[type="password"]:focus,
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
outline: none;
}
.form-select {
padding-left: 1rem;
}
input::placeholder {
color: #aaa;
}
@@ -308,14 +361,168 @@ input::placeholder {
}
}
.richtlinen{
color: #ec0920;
.password-rules {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
}
.password-rules-title {
margin: 0 0 8px;
font-weight: 700;
color: #1f2937;
font-size: 0.92rem;
}
.password-rules ul {
list-style: none;
margin: 0;
padding: 0;
}
.pw-rule {
position: relative;
padding-left: 22px;
margin: 5px 0;
color: #b91c1c;
font-size: 0.9rem;
}
.pw-rule::before {
content: '✗';
position: absolute;
left: 0;
top: 0;
font-weight: 700;
}
.pw-rule.ok {
color: #166534;
}
.pw-rule.ok::before {
content: '✓';
}
.anonymize-hint {
margin-top: 10px;
margin-bottom: 0;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
padding: 10px 12px;
color: #0c4a6e;
font-size: 0.92rem;
}
.permission-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.permission-panel {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
background: #f8fafc;
}
.permission-panel h4 {
margin: 0 0 8px;
font-size: 1rem;
color: #1f2937;
}
.permission-check {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
color: #1f2937;
}
</style>
{% if student_cards_module_enabled %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const permissionPresets = {{ permission_presets | tojson }};
const presetSelect = document.getElementById('permission-preset');
const useCustomPermissions = document.getElementById('use-custom-permissions');
const customPermissions = document.getElementById('custom-permissions');
function applyPresetToPermissionForm(presetKey) {
const preset = permissionPresets[presetKey] || {};
const actionDefaults = preset.actions || {};
const pageDefaults = preset.pages || {};
document.querySelectorAll('.permission-action-checkbox').forEach(function (checkbox) {
const key = checkbox.name.replace('action_', '');
checkbox.checked = !!actionDefaults[key];
});
document.querySelectorAll('.permission-page-checkbox').forEach(function (checkbox) {
const key = checkbox.name.replace('page_', '');
checkbox.checked = !!pageDefaults[key];
});
}
function toggleCustomPermissions() {
if (!useCustomPermissions || !customPermissions) {
return;
}
customPermissions.style.display = useCustomPermissions.checked ? 'block' : 'none';
}
if (presetSelect) {
presetSelect.addEventListener('change', function () {
applyPresetToPermissionForm(this.value);
});
applyPresetToPermissionForm(presetSelect.value);
}
if (useCustomPermissions) {
useCustomPermissions.addEventListener('change', toggleCustomPermissions);
toggleCustomPermissions();
}
const passwordInput = document.getElementById('password');
const passwordRules = {
length: document.getElementById('pw-rule-length'),
lower: document.getElementById('pw-rule-lower'),
upper: document.getElementById('pw-rule-upper'),
digit: document.getElementById('pw-rule-digit'),
symbol: document.getElementById('pw-rule-symbol')
};
function setRuleState(node, ok) {
if (!node) {
return;
}
node.classList.toggle('ok', !!ok);
}
function updatePasswordRules() {
if (!passwordInput) {
return;
}
const value = String(passwordInput.value || '');
setRuleState(passwordRules.length, value.length >= 12);
setRuleState(passwordRules.lower, /[a-z]/.test(value));
setRuleState(passwordRules.upper, /[A-Z]/.test(value));
setRuleState(passwordRules.digit, /[0-9]/.test(value));
setRuleState(passwordRules.symbol, /[^A-Za-z0-9]/.test(value));
}
if (passwordInput) {
passwordInput.addEventListener('input', updatePasswordRules);
passwordInput.addEventListener('blur', updatePasswordRules);
updatePasswordRules();
}
{% if student_cards_module_enabled %}
const studentCheckbox = document.getElementById('is-student');
const studentFields = document.getElementById('student-fields');
const studentCardInput = document.getElementById('student-card-id');
@@ -332,7 +539,7 @@ document.addEventListener('DOMContentLoaded', function () {
studentCheckbox.addEventListener('change', toggleStudentFields);
toggleStudentFields();
{% endif %}
});
</script>
{% endif %}
{% endblock %}
+18
View File
@@ -17,6 +17,16 @@
<div class="user-management-container">
<h2>Benutzer</h2>
<form method="POST" action="{{ url_for('admin_anonymize_names') }}" class="mb-3">
<button
type="submit"
class="btn btn-outline-danger"
onclick="return confirm('Sollen alle gespeicherten Klarnamen dauerhaft in Synonym-Kuerzel umgewandelt werden?')"
>
Gespeicherte Namen anonymisieren
</button>
</form>
<div class="filter-bar mb-3">
<div class="row g-2 align-items-end">
<div class="col-md-3">
@@ -436,5 +446,13 @@
font-weight: 700;
margin-bottom: 10px;
}
.modal-backdrop {
z-index: 1998 !important;
}
.modal {
z-index: 1999 !important;
}
</style>
{% endblock %}
+64 -5
View File
@@ -12,6 +12,7 @@ Provides methods for creating, validating, and retrieving user information.
'''
import hashlib
import copy
import re
from bson.objectid import ObjectId
import settings as cfg
from settings import MongoClient
@@ -24,6 +25,38 @@ def normalize_student_card_id(card_id):
return str(card_id).strip().upper()
def _clean_name_fragment(value):
cleaned = re.sub(r'[^A-Za-zÄÖÜäöüß]', '', str(value or '').strip())
if not cleaned:
return ''
replacements = {
'ä': 'ae',
'ö': 'oe',
'ü': 'ue',
'ß': 'ss',
'Ä': 'Ae',
'Ö': 'Oe',
'Ü': 'Ue',
}
for old_char, new_char in replacements.items():
cleaned = cleaned.replace(old_char, new_char)
return cleaned
def build_name_synonym(first_name, last_name=''):
"""Build a deterministic, non-personalized short alias like 'SimFri'."""
first = _clean_name_fragment(first_name)
last = _clean_name_fragment(last_name)
if first and last:
return (first[:3] + last[:3]).title()
combined = (first + last)
if not combined:
return 'User'
return combined[:6].title()
ACTION_PERMISSION_KEYS = (
'can_borrow',
'can_insert',
@@ -201,6 +234,10 @@ def get_effective_permissions(username):
if not user:
return build_default_permission_payload('standard_user')
# Admin users always have full access, independent of custom presets.
if bool(user.get('Admin', False)):
return build_default_permission_payload('full_access')
preset_key = user.get('PermissionPreset') or 'standard_user'
payload = build_default_permission_payload(preset_key)
payload['actions'] = _normalize_bool_map(user.get('ActionPermissions', {}), payload['actions'])
@@ -343,7 +380,18 @@ def check_nm_pwd(username, password):
return user
def add_user(username, password, name, last_name, is_student=False, student_card_id=None, max_borrow_days=None):
def add_user(
username,
password,
name='',
last_name='',
is_student=False,
student_card_id=None,
max_borrow_days=None,
permission_preset='standard_user',
action_permissions=None,
page_permissions=None,
):
"""
Add a new user to the database.
@@ -359,15 +407,25 @@ def add_user(username, password, name, last_name, is_student=False, student_card
users = db['users']
if not check_password_strength(password):
return False
permission_defaults = build_default_permission_payload('standard_user')
permission_defaults = build_default_permission_payload(permission_preset)
if isinstance(action_permissions, dict):
for key, value in action_permissions.items():
permission_defaults['actions'][str(key)] = bool(value)
if isinstance(page_permissions, dict):
for key, value in page_permissions.items():
permission_defaults['pages'][str(key)] = bool(value)
alias_first = name if str(name or '').strip() else username
alias_last = last_name if str(last_name or '').strip() else ''
name_alias = build_name_synonym(alias_first, alias_last)
user_doc = {
'Username': username,
'Password': hashing(password),
'Admin': False,
'active_ausleihung': None,
'name': name,
'last_name': last_name,
'name': name_alias,
'last_name': '',
'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'],
@@ -714,13 +772,14 @@ def update_user_name(username, name, last_name):
bool: True if updated successfully, False otherwise
"""
try:
name_alias = build_name_synonym(name, last_name)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
result = users.update_one(
{'Username': username},
{'$set': {'name': name, 'last_name': last_name}}
{'$set': {'name': name_alias, 'last_name': ''}}
)
client.close()