From 99fb5ff7e7b822231f10bd87f037dd08c7037e4a Mon Sep 17 00:00:00 2001 From: kamaji Date: Sun, 8 Feb 2026 00:17:26 -0600 Subject: [PATCH] Add dual user classes: admin + tech users with PIN login - Add tech user management (JSON-backed CRUD with PIN auth) - Dual login: tabbed Tech Login (username+PIN) / Admin Login (NC credentials) - Admin panel: tappable user list with detail modal (enable/disable, reset PIN, reset NC password, delete) - Auto-provision Nextcloud accounts for tech users - Admin guard: tech users redirected away from admin panel - New data volume for persistent tech_users.json storage Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- app/routes/admin.py | 205 +++++++++++++-- app/routes/auth.py | 66 +++-- app/routes/views.py | 2 +- app/services/nextcloud.py | 18 ++ app/services/tech_users.py | 115 +++++++++ app/static/js/admin.js | 504 +++++++++++++++++++++++-------------- app/static/js/auth.js | 241 ++++++++++-------- app/static/sw.js | 6 +- app/templates/admin.html | 329 +++++++++++++++--------- app/templates/login.html | 134 +++++++--- config.py | 11 +- docker-compose.yml | 3 + 13 files changed, 1143 insertions(+), 493 deletions(-) create mode 100644 app/services/tech_users.py diff --git a/Dockerfile b/Dockerfile index d1d861b..c2d55ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . # Create directories for runtime data -RUN mkdir -p /tmp/flask_session && \ +RUN mkdir -p /tmp/flask_session /app/data && \ chmod 777 /tmp/flask_session # Create non-root user for security diff --git a/app/routes/admin.py b/app/routes/admin.py index f17b561..e286453 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,5 +1,6 @@ from flask import Blueprint, request, jsonify, session from app.services.nextcloud import NextcloudClient +from app.services import tech_users from config import Config import base64 @@ -9,10 +10,10 @@ def _get_nc_client(): """Get authenticated Nextcloud client from session.""" if 'username' not in session or 'password' not in session: return None - + username = session['username'] password = base64.b64decode(session['password'].encode()).decode() - + return NextcloudClient(Config.NEXTCLOUD_URL, username, password) def _check_admin(): @@ -21,21 +22,23 @@ def _check_admin(): return False return True +# ── Nextcloud user endpoints (existing) ────────────────────────────────── + @bp.route('/users', methods=['GET']) def list_users(): """List all Nextcloud users (admin only).""" if not _check_admin(): return jsonify({'error': 'Admin privileges required'}), 403 - + nc_client = _get_nc_client() if not nc_client: return jsonify({'error': 'Not authenticated'}), 401 - + result = nc_client.ocs_get_users() - + if not result.get('success'): return jsonify({'error': result.get('error', 'Failed to list users')}), 500 - + return jsonify(result), 200 @bp.route('/users', methods=['POST']) @@ -43,25 +46,25 @@ def create_user(): """Create a new Nextcloud user (admin only).""" if not _check_admin(): return jsonify({'error': 'Admin privileges required'}), 403 - + data = request.get_json() - + if not data or 'username' not in data or 'password' not in data: return jsonify({'error': 'Username and password required'}), 400 - + username = data['username'].strip() password = data['password'] email = data.get('email', '').strip() displayname = data.get('displayName', '').strip() groups = data.get('groups', []) - + if not username or not password: return jsonify({'error': 'Username and password cannot be empty'}), 400 - + nc_client = _get_nc_client() if not nc_client: return jsonify({'error': 'Not authenticated'}), 401 - + result = nc_client.ocs_create_user( username=username, password=password, @@ -69,10 +72,10 @@ def create_user(): displayname=displayname if displayname else None, groups=groups if groups else None ) - + if not result.get('success'): return jsonify({'error': result.get('error', 'Failed to create user')}), 500 - + return jsonify(result), 201 @bp.route('/users//enable', methods=['PUT']) @@ -80,16 +83,16 @@ def enable_user(username): """Enable a user account (admin only).""" if not _check_admin(): return jsonify({'error': 'Admin privileges required'}), 403 - + nc_client = _get_nc_client() if not nc_client: return jsonify({'error': 'Not authenticated'}), 401 - + result = nc_client.ocs_enable_user(username) - + if not result.get('success'): return jsonify({'error': result.get('error', 'Failed to enable user')}), 500 - + return jsonify(result), 200 @bp.route('/users//disable', methods=['PUT']) @@ -97,16 +100,16 @@ def disable_user(username): """Disable a user account (admin only).""" if not _check_admin(): return jsonify({'error': 'Admin privileges required'}), 403 - + nc_client = _get_nc_client() if not nc_client: return jsonify({'error': 'Not authenticated'}), 401 - + result = nc_client.ocs_disable_user(username) - + if not result.get('success'): return jsonify({'error': result.get('error', 'Failed to disable user')}), 500 - + return jsonify(result), 200 @bp.route('/users/', methods=['DELETE']) @@ -114,18 +117,166 @@ def delete_user(username): """Delete a user account (admin only).""" if not _check_admin(): return jsonify({'error': 'Admin privileges required'}), 403 - + # Prevent self-deletion if username == session.get('username'): return jsonify({'error': 'Cannot delete your own account'}), 400 - + nc_client = _get_nc_client() if not nc_client: return jsonify({'error': 'Not authenticated'}), 401 - + result = nc_client.ocs_delete_user(username) - + if not result.get('success'): return jsonify({'error': result.get('error', 'Failed to delete user')}), 500 - + return jsonify(result), 200 + + +# ── Tech user endpoints ────────────────────────────────────────────────── + +@bp.route('/tech-users', methods=['GET']) +def list_tech_users(): + """List all tech users (admin only).""" + if not _check_admin(): + return jsonify({'error': 'Admin privileges required'}), 403 + + users = tech_users.list_all() + # Return as list with username included + result = [] + for username, data in users.items(): + result.append({ + 'username': username, + 'display_name': data.get('display_name', ''), + 'nc_password': data.get('nc_password', ''), + 'enabled': data.get('enabled', True), + 'created_at': data.get('created_at', ''), + 'created_by': data.get('created_by', ''), + }) + return jsonify({'success': True, 'tech_users': result}), 200 + +@bp.route('/tech-users', methods=['POST']) +def create_tech_user(): + """Create a tech user: generate NC password, hash PIN, create NC account.""" + if not _check_admin(): + return jsonify({'error': 'Admin privileges required'}), 403 + + data = request.get_json() + if not data or 'username' not in data or 'pin' not in data: + return jsonify({'error': 'Username and PIN required'}), 400 + + username = data['username'].strip() + display_name = data.get('display_name', '').strip() or username + pin = data['pin'] + + if not username or not pin: + return jsonify({'error': 'Username and PIN cannot be empty'}), 400 + + if len(pin) < 4: + return jsonify({'error': 'PIN must be at least 4 digits'}), 400 + + # Create tech user record (generates NC password) + try: + record = tech_users.create(username, display_name, pin, session['username']) + except ValueError as e: + return jsonify({'error': str(e)}), 409 + + # Create Nextcloud account with the generated password + nc_client = _get_nc_client() + if not nc_client: + # Roll back: remove from JSON + tech_users.delete(username) + return jsonify({'error': 'Not authenticated'}), 401 + + nc_result = nc_client.ocs_create_user( + username=username, + password=record['nc_password'], + displayname=display_name, + ) + + if not nc_result.get('success'): + # Roll back: remove from JSON + tech_users.delete(username) + return jsonify({'error': nc_result.get('error', 'Failed to create Nextcloud account')}), 500 + + return jsonify({ + 'success': True, + 'username': username, + 'display_name': display_name, + 'nc_password': record['nc_password'], + 'enabled': True, + }), 201 + +@bp.route('/tech-users/', methods=['PUT']) +def update_tech_user(username): + """Update tech user: display_name, pin, enabled.""" + if not _check_admin(): + return jsonify({'error': 'Admin privileges required'}), 403 + + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + fields = {} + if 'display_name' in data: + fields['display_name'] = data['display_name'] + if 'pin' in data: + if len(data['pin']) < 4: + return jsonify({'error': 'PIN must be at least 4 digits'}), 400 + fields['pin'] = data['pin'] + if 'enabled' in data: + fields['enabled'] = bool(data['enabled']) + + try: + tech_users.update(username, **fields) + except ValueError as e: + return jsonify({'error': str(e)}), 404 + + # Sync enable/disable to Nextcloud + if 'enabled' in fields: + nc_client = _get_nc_client() + if nc_client: + if fields['enabled']: + nc_client.ocs_enable_user(username) + else: + nc_client.ocs_disable_user(username) + + return jsonify({'success': True}), 200 + +@bp.route('/tech-users//reset-password', methods=['POST']) +def reset_tech_user_password(username): + """Generate a new NC password, update JSON and Nextcloud.""" + if not _check_admin(): + return jsonify({'error': 'Admin privileges required'}), 403 + + try: + new_password = tech_users.reset_nc_password(username) + except ValueError as e: + return jsonify({'error': str(e)}), 404 + + nc_client = _get_nc_client() + if nc_client: + result = nc_client.ocs_set_password(username, new_password) + if not result.get('success'): + return jsonify({'error': result.get('error', 'Failed to update Nextcloud password')}), 500 + + return jsonify({'success': True, 'nc_password': new_password}), 200 + +@bp.route('/tech-users/', methods=['DELETE']) +def delete_tech_user(username): + """Delete tech user from JSON and disable NC account.""" + if not _check_admin(): + return jsonify({'error': 'Admin privileges required'}), 403 + + try: + tech_users.delete(username) + except ValueError as e: + return jsonify({'error': str(e)}), 404 + + # Disable (not delete) the Nextcloud account + nc_client = _get_nc_client() + if nc_client: + nc_client.ocs_disable_user(username) + + return jsonify({'success': True}), 200 diff --git a/app/routes/auth.py b/app/routes/auth.py index 73ef7c4..792cece 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,5 +1,6 @@ from flask import Blueprint, request, jsonify, session from app.services.nextcloud import NextcloudClient +from app.services import tech_users from config import Config import base64 @@ -15,42 +16,74 @@ def _decrypt_password(encrypted: str) -> str: @bp.route('/login', methods=['POST']) def login(): - """Authenticate user against Nextcloud.""" + """Authenticate admin user against Nextcloud.""" data = request.get_json() - + if not data or 'username' not in data or 'password' not in data: return jsonify({'error': 'Username and password required'}), 400 - + username = data['username'].strip() password = data['password'] - + if not username or not password: return jsonify({'error': 'Username and password cannot be empty'}), 400 - + try: - # Validate credentials by attempting to connect to Nextcloud nc_client = NextcloudClient(Config.NEXTCLOUD_URL, username, password) - + if not nc_client.verify_credentials(): return jsonify({'error': 'Invalid username or password'}), 401 - - # Check if user is admin + is_admin = nc_client.check_admin() - - # Store credentials in session + if not is_admin: + return jsonify({'error': 'Admin credentials required. Tech users must use the Tech login.'}), 403 + session['username'] = username session['password'] = _encrypt_password(password) - session['is_admin'] = is_admin - + session['is_admin'] = True + session['user_type'] = 'admin' + return jsonify({ 'success': True, 'username': username, - 'is_admin': is_admin + 'is_admin': True, + 'user_type': 'admin', }), 200 - + except Exception as e: return jsonify({'error': f'Authentication failed: {str(e)}'}), 500 +@bp.route('/login/tech', methods=['POST']) +def login_tech(): + """Authenticate tech user with username + PIN.""" + data = request.get_json() + + if not data or 'username' not in data or 'pin' not in data: + return jsonify({'error': 'Username and PIN required'}), 400 + + username = data['username'].strip() + pin = data['pin'] + + if not username or not pin: + return jsonify({'error': 'Username and PIN cannot be empty'}), 400 + + user = tech_users.verify_pin(username, pin) + if not user: + return jsonify({'error': 'Invalid username or PIN'}), 401 + + # Store the generated NC password so API calls work transparently + session['username'] = username + session['password'] = _encrypt_password(user['nc_password']) + session['is_admin'] = False + session['user_type'] = 'tech' + + return jsonify({ + 'success': True, + 'username': username, + 'is_admin': False, + 'user_type': 'tech', + }), 200 + @bp.route('/logout', methods=['POST']) def logout(): """Clear user session.""" @@ -64,7 +97,8 @@ def status(): return jsonify({ 'authenticated': True, 'username': session['username'], - 'is_admin': session.get('is_admin', False) + 'is_admin': session.get('is_admin', False), + 'user_type': session.get('user_type', 'admin'), }), 200 else: return jsonify({ diff --git a/app/routes/views.py b/app/routes/views.py index 763ba56..a0162f7 100644 --- a/app/routes/views.py +++ b/app/routes/views.py @@ -82,7 +82,7 @@ def admin(): if 'username' not in session: return redirect(url_for('views.login_page')) if not session.get('is_admin', False): - return "Access denied: Admin privileges required", 403 + return redirect(url_for('views.capture')) return render_template( 'admin.html', username=session['username'], diff --git a/app/services/nextcloud.py b/app/services/nextcloud.py index d081534..5b7b847 100644 --- a/app/services/nextcloud.py +++ b/app/services/nextcloud.py @@ -352,3 +352,21 @@ class NextcloudClient: return {'success': True} except Exception as e: return {'success': False, 'error': str(e)} + + def ocs_set_password(self, username: str, password: str) -> Dict[str, Any]: + """Set a user's password via OCS API.""" + url = f"{self.ocs_root}/cloud/users/{username}" + params = {"format": "json"} + data = {"key": "password", "value": password} + + try: + response = self._make_request("PUT", url, params=params, data=data, headers=self._ocs_headers) + result = response.json() + + meta = result.get("ocs", {}).get("meta", {}) + if meta.get("statuscode") != 100: + return {"success": False, "error": meta.get("message", "Failed to set password")} + + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} diff --git a/app/services/tech_users.py b/app/services/tech_users.py new file mode 100644 index 0000000..b15aeb5 --- /dev/null +++ b/app/services/tech_users.py @@ -0,0 +1,115 @@ +"""Tech user management - JSON file CRUD with PIN auth.""" + +import json +import os +import secrets +import string +from datetime import datetime +from werkzeug.security import generate_password_hash, check_password_hash +from config import Config + +TECH_USERS_FILE = os.environ.get('TECH_USERS_FILE', '/app/data/tech_users.json') + + +def _load(): + """Load tech users from JSON file.""" + if not os.path.exists(TECH_USERS_FILE): + return {} + with open(TECH_USERS_FILE, 'r') as f: + return json.load(f) + + +def _save(data): + """Save tech users to JSON file.""" + os.makedirs(os.path.dirname(TECH_USERS_FILE), exist_ok=True) + with open(TECH_USERS_FILE, 'w') as f: + json.dump(data, f, indent=2) + + +def generate_nc_password(length=16): + """Generate a random password for Nextcloud account.""" + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(length)) + + +def create(username, display_name, pin, created_by): + """Create a new tech user. Returns the record (with nc_password) or raises.""" + data = _load() + if username in data: + raise ValueError(f'Tech user "{username}" already exists') + + nc_password = generate_nc_password() + data[username] = { + 'display_name': display_name, + 'pin_hash': generate_password_hash(pin), + 'nc_password': nc_password, + 'created_at': datetime.utcnow().isoformat(), + 'created_by': created_by, + 'enabled': True, + } + _save(data) + return data[username] + + +def verify_pin(username, pin): + """Verify a tech user's PIN. Returns the user record or None.""" + data = _load() + user = data.get(username) + if not user: + return None + if not user.get('enabled', True): + return None + if not check_password_hash(user['pin_hash'], pin): + return None + return user + + +def list_all(): + """Return all tech users (with nc_password visible).""" + return _load() + + +def get(username): + """Get a single tech user record.""" + return _load().get(username) + + +def update(username, **fields): + """Update a tech user. Supported fields: display_name, pin, enabled.""" + data = _load() + user = data.get(username) + if not user: + raise ValueError(f'Tech user "{username}" not found') + + if 'display_name' in fields: + user['display_name'] = fields['display_name'] + if 'pin' in fields: + user['pin_hash'] = generate_password_hash(fields['pin']) + if 'enabled' in fields: + user['enabled'] = fields['enabled'] + + data[username] = user + _save(data) + return user + + +def reset_nc_password(username): + """Generate a new NC password and save it. Returns the new password.""" + data = _load() + user = data.get(username) + if not user: + raise ValueError(f'Tech user "{username}" not found') + new_password = generate_nc_password() + user['nc_password'] = new_password + data[username] = user + _save(data) + return new_password + + +def delete(username): + """Delete a tech user from JSON.""" + data = _load() + if username not in data: + raise ValueError(f'Tech user "{username}" not found') + del data[username] + _save(data) diff --git a/app/static/js/admin.js b/app/static/js/admin.js index efb6766..decce05 100644 --- a/app/static/js/admin.js +++ b/app/static/js/admin.js @@ -2,240 +2,368 @@ 'use strict'; const Admin = { - users: [], - + techUsers: [], + _activeUser: null, + async init() { - await this.loadUsers(); this.setupEventListeners(); + await this.loadTechUsers(); }, - + setupEventListeners() { - document.getElementById('add-user-form').addEventListener('submit', (e) => { + document.getElementById('add-tech-user-form').addEventListener('submit', (e) => { e.preventDefault(); - this.createUser(); - }); - - document.getElementById('refresh-btn').addEventListener('click', () => { - this.loadUsers(); + this.createTechUser(); }); + + // User detail modal buttons + document.getElementById('user-modal-close').addEventListener('click', () => this.closeModal('user-modal')); + document.getElementById('user-modal-toggle-btn').addEventListener('click', () => this._handleToggle()); + document.getElementById('user-modal-pin-btn').addEventListener('click', () => this._handlePinReset()); + document.getElementById('user-modal-resetpw-btn').addEventListener('click', () => this._handleResetNCPassword()); + document.getElementById('user-modal-delete-btn').addEventListener('click', () => this._handleDelete()); + + document.getElementById('user-modal-pw-toggle').addEventListener('click', () => this._toggleModalPassword()); + document.getElementById('user-modal-pw-copy').addEventListener('click', () => this._copyModalPassword()); + + // PIN modal + document.getElementById('pin-modal-cancel').addEventListener('click', () => this.closeModal('pin-modal')); + document.getElementById('confirm-pin-reset').addEventListener('click', () => this._submitPinReset()); + + // Confirm modal + document.getElementById('confirm-modal-cancel').addEventListener('click', () => this.closeModal('confirm-modal')); }, - - async loadUsers() { - const userList = document.getElementById('user-list'); - const loadingMsg = document.getElementById('loading-msg'); - const errorMsg = document.getElementById('error-msg'); - - loadingMsg.style.display = 'block'; - errorMsg.style.display = 'none'; - userList.innerHTML = ''; - + + // ── Load & Render ──────────────────────────────────────────────────── + + async loadTechUsers() { + const list = document.getElementById('tech-user-list'); + const loading = document.getElementById('tech-loading-msg'); + const error = document.getElementById('tech-error-msg'); + + loading.style.display = 'block'; + error.style.display = 'none'; + list.innerHTML = ''; + try { - const response = await fetch('/api/admin/users'); - + const response = await fetch('/api/admin/tech-users'); if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to load users'); + const err = await response.json(); + throw new Error(err.error || 'Failed to load tech users'); } - + const data = await response.json(); - this.users = data.users || []; - - loadingMsg.style.display = 'none'; - - if (this.users.length === 0) { - userList.innerHTML = 'No users found'; + this.techUsers = data.tech_users || []; + loading.style.display = 'none'; + + if (this.techUsers.length === 0) { + list.innerHTML = '
No tech users yet
'; return; } - - this.users.forEach(user => { - const row = this.createUserRow(user); - userList.appendChild(row); + + this.techUsers.forEach(user => { + list.appendChild(this._createUserItem(user)); }); - - } catch (error) { - console.error('Error loading users:', error); - loadingMsg.style.display = 'none'; - errorMsg.textContent = error.message; - errorMsg.style.display = 'block'; + + } catch (err) { + console.error('Error loading tech users:', err); + loading.style.display = 'none'; + error.textContent = err.message; + error.style.display = 'block'; } }, - - createUserRow(user) { - const row = document.createElement('tr'); - row.innerHTML = ` - ${this.escapeHtml(user.id)} - ${this.escapeHtml(user.displayname || '-')} - ${this.escapeHtml(user.email || '-')} - + + _createUserItem(user) { + const item = document.createElement('div'); + item.className = 'user-list-item'; + const u = this.escapeHtml(user.username); + const dn = user.display_name && user.display_name !== user.username + ? this.escapeHtml(user.display_name) : ''; + + item.innerHTML = ` + +
${user.enabled ? 'Enabled' : 'Disabled'} - - -
- ${user.enabled ? - `` : - `` - } - -
- + +
`; - return row; + item.addEventListener('click', () => this.showUserModal(user.username)); + return item; }, - - async createUser() { - const form = document.getElementById('add-user-form'); - const submitBtn = document.getElementById('submit-btn'); - const formError = document.getElementById('form-error'); - const formSuccess = document.getElementById('form-success'); - - const username = document.getElementById('new-username').value.trim(); - const password = document.getElementById('new-password').value; - const email = document.getElementById('new-email').value.trim(); - const displayName = document.getElementById('new-displayname').value.trim(); - + + // ── User Detail Modal ──────────────────────────────────────────────── + + showUserModal(username) { + const user = this.techUsers.find(u => u.username === username); + if (!user) return; + this._activeUser = user; + + document.getElementById('user-modal-title').textContent = user.username; + const dn = user.display_name && user.display_name !== user.username ? user.display_name : ''; + document.getElementById('user-modal-displayname').textContent = dn; + + // Password + const pwEl = document.getElementById('user-modal-pw'); + pwEl.dataset.pw = user.nc_password; + pwEl.dataset.masked = 'true'; + pwEl.textContent = '\u2022'.repeat(12); + document.getElementById('user-modal-pw-toggle').textContent = 'Show'; + + // Status + const statusEl = document.getElementById('user-modal-status'); + statusEl.textContent = user.enabled ? 'Enabled' : 'Disabled'; + statusEl.className = 'badge ' + (user.enabled ? 'badge-success' : 'badge-danger'); + + // Toggle button + const toggleBtn = document.getElementById('user-modal-toggle-btn'); + if (user.enabled) { + toggleBtn.textContent = 'Disable User'; + toggleBtn.className = 'btn btn-block btn-warning-outline'; + } else { + toggleBtn.textContent = 'Enable User'; + toggleBtn.className = 'btn btn-block btn-success-outline'; + } + + document.getElementById('user-modal').style.display = 'flex'; + }, + + _toggleModalPassword() { + const pwEl = document.getElementById('user-modal-pw'); + const btn = document.getElementById('user-modal-pw-toggle'); + if (pwEl.dataset.masked === 'true') { + pwEl.textContent = pwEl.dataset.pw; + pwEl.dataset.masked = 'false'; + btn.textContent = 'Hide'; + } else { + pwEl.textContent = '\u2022'.repeat(12); + pwEl.dataset.masked = 'true'; + btn.textContent = 'Show'; + } + }, + + _copyModalPassword() { + const pw = document.getElementById('user-modal-pw').dataset.pw; + navigator.clipboard.writeText(pw).then(() => { + this.showToast('Password copied', 'success'); + }).catch(() => { + this.showToast('Copy failed', 'error'); + }); + }, + + // ── Actions from User Modal ────────────────────────────────────────── + + async _handleToggle() { + const user = this._activeUser; + if (!user) return; + const enable = !user.enabled; + + try { + const response = await fetch(`/api/admin/tech-users/${user.username}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: enable }) + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'Failed'); + } + this.closeModal('user-modal'); + this.showToast(`User "${user.username}" ${enable ? 'enabled' : 'disabled'}`, 'success'); + this.loadTechUsers(); + } catch (err) { + this.showToast(err.message, 'error'); + } + }, + + _handlePinReset() { + const user = this._activeUser; + if (!user) return; + this.closeModal('user-modal'); + document.getElementById('pin-modal-username').textContent = user.username; + document.getElementById('reset-pin').value = ''; + document.getElementById('reset-pin-confirm').value = ''; + document.getElementById('pin-modal-error').style.display = 'none'; + document.getElementById('pin-modal').style.display = 'flex'; + }, + + async _submitPinReset() { + const user = this._activeUser; + if (!user) return; + const pin = document.getElementById('reset-pin').value; + const pinConfirm = document.getElementById('reset-pin-confirm').value; + const errorEl = document.getElementById('pin-modal-error'); + errorEl.style.display = 'none'; + + if (!pin || pin.length < 4) { + errorEl.textContent = 'PIN must be at least 4 digits'; + errorEl.style.display = 'block'; + return; + } + if (pin !== pinConfirm) { + errorEl.textContent = 'PINs do not match'; + errorEl.style.display = 'block'; + return; + } + + try { + const response = await fetch(`/api/admin/tech-users/${user.username}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pin }) + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'Failed to reset PIN'); + } + this.closeModal('pin-modal'); + this.showToast(`PIN reset for "${user.username}"`, 'success'); + } catch (err) { + errorEl.textContent = err.message; + errorEl.style.display = 'block'; + } + }, + + _handleResetNCPassword() { + const user = this._activeUser; + if (!user) return; + this.closeModal('user-modal'); + this._showConfirm( + 'Reset NC Password?', + `Generate a new Nextcloud password for "${user.username}"? They will need the new password to sync.`, + async () => { + try { + const response = await fetch(`/api/admin/tech-users/${user.username}/reset-password`, { + method: 'POST' + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Failed'); + this.closeModal('confirm-modal'); + this.showToast(`NC password reset for "${user.username}"`, 'success'); + this.loadTechUsers(); + } catch (err) { + document.getElementById('confirm-modal-error').textContent = err.message; + document.getElementById('confirm-modal-error').style.display = 'block'; + } + } + ); + }, + + _handleDelete() { + const user = this._activeUser; + if (!user) return; + this.closeModal('user-modal'); + this._showConfirm( + 'Delete Tech User?', + `Delete "${user.username}"? Their Nextcloud account will be disabled.`, + async () => { + try { + const response = await fetch(`/api/admin/tech-users/${user.username}`, { + method: 'DELETE' + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'Failed'); + } + this.closeModal('confirm-modal'); + this.showToast(`Tech user "${user.username}" deleted`, 'success'); + this.loadTechUsers(); + } catch (err) { + document.getElementById('confirm-modal-error').textContent = err.message; + document.getElementById('confirm-modal-error').style.display = 'block'; + } + } + ); + }, + + // ── Generic Confirm Modal ──────────────────────────────────────────── + + _showConfirm(title, message, onConfirm) { + document.getElementById('confirm-modal-title').textContent = title; + document.getElementById('confirm-modal-msg').textContent = message; + document.getElementById('confirm-modal-error').style.display = 'none'; + document.getElementById('confirm-modal-ok').onclick = onConfirm; + document.getElementById('confirm-modal').style.display = 'flex'; + }, + + // ── Create Tech User ───────────────────────────────────────────────── + + async createTechUser() { + const submitBtn = document.getElementById('tech-submit-btn'); + const formError = document.getElementById('tech-form-error'); + const formSuccess = document.getElementById('tech-form-success'); + + const username = document.getElementById('new-tech-username').value.trim(); + const displayName = document.getElementById('new-tech-displayname').value.trim(); + const pin = document.getElementById('new-tech-pin').value; + const pinConfirm = document.getElementById('new-tech-pin-confirm').value; + formError.style.display = 'none'; formSuccess.style.display = 'none'; - - if (!username || !password) { - formError.textContent = 'Username and password are required'; + + if (!username || !pin) { + formError.textContent = 'Username and PIN are required'; formError.style.display = 'block'; return; } - + if (pin.length < 4) { + formError.textContent = 'PIN must be at least 4 digits'; + formError.style.display = 'block'; + return; + } + if (pin !== pinConfirm) { + formError.textContent = 'PINs do not match'; + formError.style.display = 'block'; + return; + } + submitBtn.disabled = true; submitBtn.textContent = 'Creating...'; - + try { - const response = await fetch('/api/admin/users', { + const response = await fetch('/api/admin/tech-users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: username, - password: password, - email: email || null, - displayName: displayName || null - }) + body: JSON.stringify({ username, display_name: displayName, pin }) }); - + const result = await response.json(); - - if (!response.ok) { - throw new Error(result.error || 'Failed to create user'); - } - - formSuccess.textContent = `User "${username}" created successfully!`; + if (!response.ok) throw new Error(result.error || 'Failed to create tech user'); + + formSuccess.textContent = `Tech user "${username}" created!`; formSuccess.style.display = 'block'; - - form.reset(); - - setTimeout(() => { - this.loadUsers(); - }, 1000); - - } catch (error) { - console.error('Error creating user:', error); - formError.textContent = error.message; + + document.getElementById('add-tech-user-form').reset(); + this.loadTechUsers(); + + } catch (err) { + console.error('Error creating tech user:', err); + formError.textContent = err.message; formError.style.display = 'block'; } finally { submitBtn.disabled = false; - submitBtn.textContent = 'Create User'; + submitBtn.textContent = 'Create Tech User'; } }, - - async enableUser(username) { - if (!confirm(`Enable user "${username}"?`)) return; - - try { - const response = await fetch(`/api/admin/users/${username}/enable`, { - method: 'PUT' - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to enable user'); - } - - this.showToast(`User "${username}" enabled`, 'success'); - this.loadUsers(); - - } catch (error) { - console.error('Error enabling user:', error); - this.showToast(error.message, 'error'); - } + + // ── Helpers ────────────────────────────────────────────────────────── + + closeModal(id) { + document.getElementById(id).style.display = 'none'; }, - - async disableUser(username) { - if (!confirm(`Disable user "${username}"?`)) return; - - try { - const response = await fetch(`/api/admin/users/${username}/disable`, { - method: 'PUT' - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to disable user'); - } - - this.showToast(`User "${username}" disabled`, 'success'); - this.loadUsers(); - - } catch (error) { - console.error('Error disabling user:', error); - this.showToast(error.message, 'error'); - } - }, - - confirmDeleteUser(username) { - const modal = document.getElementById('delete-modal'); - const confirmBtn = document.getElementById('confirm-delete'); - - document.getElementById('delete-username').textContent = username; - modal.style.display = 'flex'; - - confirmBtn.onclick = () => { - this.deleteUser(username); - this.hideDeleteModal(); - }; - }, - - hideDeleteModal() { - document.getElementById('delete-modal').style.display = 'none'; - }, - - async deleteUser(username) { - try { - const response = await fetch(`/api/admin/users/${username}`, { - method: 'DELETE' - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to delete user'); - } - - this.showToast(`User "${username}" deleted`, 'success'); - this.loadUsers(); - - } catch (error) { - console.error('Error deleting user:', error); - this.showToast(error.message, 'error'); - } - }, - + showToast(message, type) { const toast = document.getElementById('toast'); toast.textContent = message; toast.className = `toast ${type}`; toast.style.display = 'block'; - - setTimeout(() => { - toast.style.display = 'none'; - }, 3000); + setTimeout(() => { toast.style.display = 'none'; }, 3000); }, - + escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; diff --git a/app/static/js/auth.js b/app/static/js/auth.js index 7e0a097..54f1dc3 100644 --- a/app/static/js/auth.js +++ b/app/static/js/auth.js @@ -2,133 +2,169 @@ 'use strict'; const Auth = { + currentTab: 'tech', + init() { - const loginForm = document.getElementById('login-form'); - if (loginForm) { - loginForm.addEventListener('submit', this.handleLogin.bind(this)); - - // Add input validation - const usernameInput = document.getElementById('username'); - const passwordInput = document.getElementById('password'); - - if (usernameInput) { - usernameInput.addEventListener('blur', () => this.validateUsername()); - usernameInput.addEventListener('input', () => this.clearFieldError('username')); - } - - if (passwordInput) { - passwordInput.addEventListener('input', () => this.clearFieldError('password')); - } + // Tab switching + document.querySelectorAll('.login-tab').forEach(tab => { + tab.addEventListener('click', () => this.switchTab(tab.dataset.tab)); + }); + + // Tech login form + const techForm = document.getElementById('tech-login-form'); + if (techForm) { + techForm.addEventListener('submit', (e) => this.handleTechLogin(e)); } - }, - - validateUsername() { - const username = document.getElementById('username').value.trim(); - const usernameError = document.getElementById('username-error'); - const usernameInput = document.getElementById('username'); - - if (!username) { - usernameError.textContent = 'Username is required'; - usernameInput.classList.add('error'); - return false; + + // Admin login form + const adminForm = document.getElementById('admin-login-form'); + if (adminForm) { + adminForm.addEventListener('submit', (e) => this.handleAdminLogin(e)); } - - if (username.length < 2) { - usernameError.textContent = 'Username must be at least 2 characters'; - usernameInput.classList.add('error'); - return false; + + // Clear field errors on input + document.querySelectorAll('.form-input').forEach(input => { + input.addEventListener('input', () => { + const errorEl = document.getElementById(input.id + '-error'); + if (errorEl) errorEl.textContent = ''; + input.classList.remove('error'); + }); + }); + }, + + switchTab(tab) { + this.currentTab = tab; + + // Update tab buttons + document.querySelectorAll('.login-tab').forEach(t => { + t.classList.toggle('active', t.dataset.tab === tab); + }); + + // Show/hide forms + const techForm = document.getElementById('tech-login-form'); + const adminForm = document.getElementById('admin-login-form'); + const helpText = document.getElementById('login-help'); + + if (tab === 'tech') { + techForm.style.display = ''; + adminForm.style.display = 'none'; + helpText.innerHTML = 'Tip: Use your username and PIN to login'; + const field = document.getElementById('tech-username'); + if (field) field.focus(); + } else { + techForm.style.display = 'none'; + adminForm.style.display = ''; + helpText.innerHTML = 'Tip: Use your Nextcloud admin credentials'; + const field = document.getElementById('admin-username'); + if (field) field.focus(); } - - usernameError.textContent = ''; - usernameInput.classList.remove('error'); - return true; + + // Clear errors on tab switch + document.querySelectorAll('.error-message').forEach(el => el.classList.add('hidden')); + document.querySelectorAll('.field-error').forEach(el => el.textContent = ''); + document.querySelectorAll('.form-input').forEach(el => el.classList.remove('error')); }, - - validatePassword() { - const password = document.getElementById('password').value; - const passwordError = document.getElementById('password-error'); - const passwordInput = document.getElementById('password'); - - if (!password) { - passwordError.textContent = 'Password is required'; - passwordInput.classList.add('error'); - return false; - } - - passwordError.textContent = ''; - passwordInput.classList.remove('error'); - return true; + + _setLoading(btn, loading) { + const text = btn.querySelector('.btn-text'); + const spinner = btn.querySelector('.btn-loading'); + btn.disabled = loading; + text.classList.toggle('hidden', loading); + spinner.classList.toggle('hidden', !loading); }, - - clearFieldError(field) { - const errorElement = document.getElementById(`${field}-error`); - const inputElement = document.getElementById(field); - if (errorElement) errorElement.textContent = ''; - if (inputElement) inputElement.classList.remove('error'); - }, - - async handleLogin(event) { + + async handleTechLogin(event) { event.preventDefault(); - - // Validate fields - const usernameValid = this.validateUsername(); - const passwordValid = this.validatePassword(); - - if (!usernameValid || !passwordValid) { + + const username = document.getElementById('tech-username').value.trim(); + const pin = document.getElementById('tech-pin').value; + const errorMessage = document.getElementById('tech-error-message'); + const btn = document.getElementById('tech-login-btn'); + + if (!username) { + document.getElementById('tech-username-error').textContent = 'Username is required'; + document.getElementById('tech-username').classList.add('error'); return; } - - const username = document.getElementById('username').value.trim(); - const password = document.getElementById('password').value; - const errorMessage = document.getElementById('error-message'); - const loginBtn = document.getElementById('login-btn'); - const loginBtnText = document.getElementById('login-btn-text'); - const loginBtnLoading = document.getElementById('login-btn-loading'); - - // Clear previous error + if (!pin) { + document.getElementById('tech-pin-error').textContent = 'PIN is required'; + document.getElementById('tech-pin').classList.add('error'); + return; + } + errorMessage.classList.add('hidden'); - errorMessage.textContent = ''; - - // Disable button and show loading state - loginBtn.disabled = true; - loginBtnText.classList.add('hidden'); - loginBtnLoading.classList.remove('hidden'); - + this._setLoading(btn, true); + try { - const response = await fetch('/api/auth/login', { + const response = await fetch('/api/auth/login/tech', { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ username, password }) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, pin }) }); - + const data = await response.json(); - + if (response.ok && data.success) { - // Login successful - redirect to capture page window.location.href = '/capture'; } else { - // Login failed - show error - const errorText = data.error || 'Login failed. Please check your credentials and try again.'; - errorMessage.textContent = errorText; + errorMessage.textContent = data.error || 'Login failed. Please check your credentials.'; errorMessage.classList.remove('hidden'); - - // Focus back on username for retry - document.getElementById('username').focus(); } } catch (error) { console.error('Login error:', error); errorMessage.textContent = 'Network error. Please check your connection and try again.'; errorMessage.classList.remove('hidden'); } finally { - // Re-enable button and restore normal state - loginBtn.disabled = false; - loginBtnText.classList.remove('hidden'); - loginBtnLoading.classList.add('hidden'); + this._setLoading(btn, false); } }, - + + async handleAdminLogin(event) { + event.preventDefault(); + + const username = document.getElementById('admin-username').value.trim(); + const password = document.getElementById('admin-password').value; + const errorMessage = document.getElementById('admin-error-message'); + const btn = document.getElementById('admin-login-btn'); + + if (!username) { + document.getElementById('admin-username-error').textContent = 'Username is required'; + document.getElementById('admin-username').classList.add('error'); + return; + } + if (!password) { + document.getElementById('admin-password-error').textContent = 'Password is required'; + document.getElementById('admin-password').classList.add('error'); + return; + } + + errorMessage.classList.add('hidden'); + this._setLoading(btn, true); + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + window.location.href = '/capture'; + } else { + errorMessage.textContent = data.error || 'Login failed. Please check your credentials.'; + errorMessage.classList.remove('hidden'); + } + } catch (error) { + console.error('Login error:', error); + errorMessage.textContent = 'Network error. Please check your connection and try again.'; + errorMessage.classList.remove('hidden'); + } finally { + this._setLoading(btn, false); + } + }, + async checkStatus() { try { const response = await fetch('/api/auth/status'); @@ -139,14 +175,13 @@ const Auth = { return null; } }, - + async logout() { try { await fetch('/api/auth/logout', { method: 'POST' }); window.location.href = '/'; } catch (error) { console.error('Logout error:', error); - // Redirect anyway window.location.href = '/'; } } diff --git a/app/static/sw.js b/app/static/sw.js index ea876cd..4aada14 100644 --- a/app/static/sw.js +++ b/app/static/sw.js @@ -1,9 +1,9 @@ // NextSnap Service Worker // Provides offline-first caching for the app shell -const CACHE_VERSION = 'nextsnap-v27'; -const APP_SHELL_CACHE = 'nextsnap-shell-v23'; -const RUNTIME_CACHE = 'nextsnap-runtime-v23'; +const CACHE_VERSION = 'nextsnap-v33'; +const APP_SHELL_CACHE = 'nextsnap-shell-v29'; +const RUNTIME_CACHE = 'nextsnap-runtime-v29'; // Offline fallback page with bottom nav bar so user can navigate to cached pages const OFFLINE_PAGE = ` diff --git a/app/templates/admin.html b/app/templates/admin.html index 55a967f..e4d5908 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -6,75 +6,107 @@

Admin Panel

-
- +
-

Add New User

-
+

Add Tech User

+
- - + +
- - + +
- - + +
- - + +
- - - + + +
- +
-

Nextcloud Users

- - - -
- - - - - - - - - - - - - -
UsernameDisplay NameEmailStatusActions
Loading...
+

Tech Users

+ + +
+
+
+ + + + + + - - - - - + + + +
@@ -98,6 +148,32 @@ body { font-size: 0.9rem; } +.login-tabs { + display: flex; + gap: 0; + margin-bottom: 1.5rem; + border-radius: 8px; + overflow: hidden; + border: 2px solid var(--bg-tertiary); +} + +.login-tab { + flex: 1; + padding: 0.75rem; + border: none; + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, color 0.2s; +} + +.login-tab.active { + background: var(--accent); + color: white; +} + .login-form { display: flex; flex-direction: column; @@ -191,11 +267,11 @@ body { margin: 1rem; padding: 1.5rem; } - + .app-icon { font-size: 3rem; } - + .login-title { font-size: 1.75rem; } diff --git a/config.py b/config.py index 300a5f0..455a5e4 100644 --- a/config.py +++ b/config.py @@ -4,7 +4,10 @@ class Config: """Application configuration loaded from environment variables.""" SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') NEXTCLOUD_URL = os.environ.get('NEXTCLOUD_URL', 'https://nextcloud.sdanywhere.com') - + + # Tech users JSON file + TECH_USERS_FILE = os.environ.get('TECH_USERS_FILE', '/app/data/tech_users.json') + # Session configuration SESSION_TYPE = 'filesystem' SESSION_FILE_DIR = '/tmp/flask_session' @@ -13,10 +16,10 @@ class Config: SESSION_COOKIE_SAMESITE = 'Strict' SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SECURE = True # Set to False for local development - + # Upload configuration MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB max upload - + # Development mode DEBUG = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true' @@ -28,7 +31,7 @@ class DevelopmentConfig(Config): class ProductionConfig(Config): """Production-specific configuration.""" DEBUG = False - + # Ensure these are set in production def __init__(self): super().__init__() diff --git a/docker-compose.yml b/docker-compose.yml index bdb0422..6eb3c86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: - TZ=${TZ:-UTC} volumes: - flask_sessions:/tmp/flask_session + - app_data:/app/data restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] @@ -33,6 +34,8 @@ services: volumes: flask_sessions: driver: local + app_data: + driver: local networks: nextsnap-network: