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:
@@ -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)}
|
||||
|
||||
115
app/services/tech_users.py
Normal file
115
app/services/tech_users.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user