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 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<username>/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/<username>/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/<username>', 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/<username>', 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/<username>/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/<username>', 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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user