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:
2026-02-08 00:17:26 -06:00
parent ca03f6e143
commit 99fb5ff7e7
13 changed files with 1143 additions and 493 deletions

View File

@@ -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
View 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)