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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +22,8 @@ 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)."""
|
||||
@@ -129,3 +132,151 @@ def delete_user(username):
|
||||
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,7 +16,7 @@ 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:
|
||||
@@ -28,29 +29,61 @@ def login():
|
||||
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()
|
||||
if not is_admin:
|
||||
return jsonify({'error': 'Admin credentials required. Tech users must use the Tech login.'}), 403
|
||||
|
||||
# Store credentials in session
|
||||
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'],
|
||||
|
||||
@@ -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)
|
||||
@@ -2,104 +2,321 @@
|
||||
'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();
|
||||
this.createTechUser();
|
||||
});
|
||||
|
||||
document.getElementById('refresh-btn').addEventListener('click', () => {
|
||||
this.loadUsers();
|
||||
});
|
||||
// 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');
|
||||
// ── Load & Render ────────────────────────────────────────────────────
|
||||
|
||||
loadingMsg.style.display = 'block';
|
||||
errorMsg.style.display = 'none';
|
||||
userList.innerHTML = '';
|
||||
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 || [];
|
||||
this.techUsers = data.tech_users || [];
|
||||
loading.style.display = 'none';
|
||||
|
||||
loadingMsg.style.display = 'none';
|
||||
|
||||
if (this.users.length === 0) {
|
||||
userList.innerHTML = '<tr><td colspan="5" class="empty-state">No users found</td></tr>';
|
||||
if (this.techUsers.length === 0) {
|
||||
list.innerHTML = '<div class="user-list-empty">No tech users yet</div>';
|
||||
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 = `
|
||||
<td>${this.escapeHtml(user.id)}</td>
|
||||
<td>${this.escapeHtml(user.displayname || '-')}</td>
|
||||
<td>${this.escapeHtml(user.email || '-')}</td>
|
||||
<td>
|
||||
_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 = `
|
||||
<div class="user-info">
|
||||
<span class="user-id">${u}</span>
|
||||
${dn ? `<span class="user-displayname">${dn}</span>` : ''}
|
||||
</div>
|
||||
<div class="user-right">
|
||||
<span class="badge ${user.enabled ? 'badge-success' : 'badge-danger'}">
|
||||
${user.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
${user.enabled ?
|
||||
`<button class="btn-action btn-warning" onclick="Admin.disableUser('${user.id}')">Disable</button>` :
|
||||
`<button class="btn-action btn-success" onclick="Admin.enableUser('${user.id}')">Enable</button>`
|
||||
}
|
||||
<button class="btn-action btn-danger" onclick="Admin.confirmDeleteUser('${user.id}')">Delete</button>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
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');
|
||||
// ── User Detail Modal ────────────────────────────────────────────────
|
||||
|
||||
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();
|
||||
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;
|
||||
}
|
||||
@@ -108,121 +325,35 @@ const Admin = {
|
||||
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 tech user');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to create user');
|
||||
}
|
||||
|
||||
formSuccess.textContent = `User "${username}" created successfully!`;
|
||||
formSuccess.textContent = `Tech user "${username}" created!`;
|
||||
formSuccess.style.display = 'block';
|
||||
|
||||
form.reset();
|
||||
document.getElementById('add-tech-user-form').reset();
|
||||
this.loadTechUsers();
|
||||
|
||||
setTimeout(() => {
|
||||
this.loadUsers();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
formError.textContent = error.message;
|
||||
} 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;
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
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');
|
||||
}
|
||||
},
|
||||
|
||||
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');
|
||||
}
|
||||
closeModal(id) {
|
||||
document.getElementById(id).style.display = 'none';
|
||||
},
|
||||
|
||||
showToast(message, type) {
|
||||
@@ -230,10 +361,7 @@ const Admin = {
|
||||
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) {
|
||||
|
||||
@@ -2,130 +2,166 @@
|
||||
'use strict';
|
||||
|
||||
const Auth = {
|
||||
currentTab: 'tech',
|
||||
|
||||
init() {
|
||||
const loginForm = document.getElementById('login-form');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', this.handleLogin.bind(this));
|
||||
// Tab switching
|
||||
document.querySelectorAll('.login-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => this.switchTab(tab.dataset.tab));
|
||||
});
|
||||
|
||||
// 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'));
|
||||
// Tech login form
|
||||
const techForm = document.getElementById('tech-login-form');
|
||||
if (techForm) {
|
||||
techForm.addEventListener('submit', (e) => this.handleTechLogin(e));
|
||||
}
|
||||
|
||||
if (passwordInput) {
|
||||
passwordInput.addEventListener('input', () => this.clearFieldError('password'));
|
||||
}
|
||||
// Admin login form
|
||||
const adminForm = document.getElementById('admin-login-form');
|
||||
if (adminForm) {
|
||||
adminForm.addEventListener('submit', (e) => this.handleAdminLogin(e));
|
||||
}
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
validateUsername() {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const usernameError = document.getElementById('username-error');
|
||||
const usernameInput = document.getElementById('username');
|
||||
switchTab(tab) {
|
||||
this.currentTab = tab;
|
||||
|
||||
if (!username) {
|
||||
usernameError.textContent = 'Username is required';
|
||||
usernameInput.classList.add('error');
|
||||
return false;
|
||||
// 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 = '<strong>Tip:</strong> 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 = '<strong>Tip:</strong> Use your Nextcloud admin credentials';
|
||||
const field = document.getElementById('admin-username');
|
||||
if (field) field.focus();
|
||||
}
|
||||
|
||||
if (username.length < 2) {
|
||||
usernameError.textContent = 'Username must be at least 2 characters';
|
||||
usernameInput.classList.add('error');
|
||||
return false;
|
||||
}
|
||||
|
||||
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();
|
||||
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 (!usernameValid || !passwordValid) {
|
||||
if (!username) {
|
||||
document.getElementById('tech-username-error').textContent = 'Username is required';
|
||||
document.getElementById('tech-username').classList.add('error');
|
||||
return;
|
||||
}
|
||||
if (!pin) {
|
||||
document.getElementById('tech-pin-error').textContent = 'PIN is required';
|
||||
document.getElementById('tech-pin').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
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -146,7 +182,6 @@ const Auth = {
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// Redirect anyway
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = `<!DOCTYPE html>
|
||||
|
||||
@@ -6,75 +6,107 @@
|
||||
<div class="container">
|
||||
<div class="admin-header">
|
||||
<h2>Admin Panel</h2>
|
||||
<button class="btn btn-secondary btn-small" id="refresh-btn">
|
||||
<span>🔄</span> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add User Form -->
|
||||
<!-- Add Tech User Form -->
|
||||
<div class="admin-section">
|
||||
<h3>Add New User</h3>
|
||||
<form id="add-user-form" class="user-form">
|
||||
<h3>Add Tech User</h3>
|
||||
<form id="add-tech-user-form" class="user-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="new-username">Username *</label>
|
||||
<input type="text" id="new-username" required placeholder="username">
|
||||
<label for="new-tech-username">Username *</label>
|
||||
<input type="text" id="new-tech-username" required placeholder="jsmith" autocapitalize="none" autocorrect="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-password">Password *</label>
|
||||
<input type="password" id="new-password" required placeholder="••••••••">
|
||||
<label for="new-tech-displayname">Display Name</label>
|
||||
<input type="text" id="new-tech-displayname" placeholder="John Smith">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="new-email">Email</label>
|
||||
<input type="email" id="new-email" placeholder="user@example.com">
|
||||
<label for="new-tech-pin">PIN * (min 4 digits)</label>
|
||||
<input type="text" id="new-tech-pin" required placeholder="••••" inputmode="numeric">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-displayname">Display Name</label>
|
||||
<input type="text" id="new-displayname" placeholder="John Doe">
|
||||
<label for="new-tech-pin-confirm">Confirm PIN *</label>
|
||||
<input type="text" id="new-tech-pin-confirm" required placeholder="••••" inputmode="numeric">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-error" id="form-error" style="display: none;"></div>
|
||||
<div class="form-success" id="form-success" style="display: none;"></div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">Create User</button>
|
||||
<div class="form-error" id="tech-form-error" style="display: none;"></div>
|
||||
<div class="form-success" id="tech-form-success" style="display: none;"></div>
|
||||
<button type="submit" class="btn btn-primary" id="tech-submit-btn">Create Tech User</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- User List -->
|
||||
<!-- Tech User List -->
|
||||
<div class="admin-section">
|
||||
<h3>Nextcloud Users</h3>
|
||||
<div class="loading-msg" id="loading-msg" style="display: none;">Loading users...</div>
|
||||
<div class="error-msg" id="error-msg" style="display: none;"></div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Display Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-list">
|
||||
<tr><td colspan="5" class="empty-state">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h3>Tech Users</h3>
|
||||
<div class="loading-msg" id="tech-loading-msg" style="display: none;">Loading tech users...</div>
|
||||
<div class="error-msg" id="tech-error-msg" style="display: none;"></div>
|
||||
<div id="tech-user-list" class="user-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal" id="delete-modal" style="display: none;">
|
||||
<!-- User Detail Modal -->
|
||||
<div class="modal" id="user-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<h3>Delete User?</h3>
|
||||
<p>Are you sure you want to delete user <strong id="delete-username"></strong>?</p>
|
||||
<p class="warning-text">⚠️ This action cannot be undone. All user data will be permanently deleted.</p>
|
||||
<h3 id="user-modal-title"></h3>
|
||||
<p id="user-modal-displayname" class="modal-subtitle"></p>
|
||||
|
||||
<div class="modal-detail">
|
||||
<label>NC Password</label>
|
||||
<div class="nc-password-row">
|
||||
<span class="pw-text" id="user-modal-pw" data-masked="true"></span>
|
||||
<button class="btn-small-action" id="user-modal-pw-toggle">Show</button>
|
||||
<button class="btn-small-action" id="user-modal-pw-copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-detail">
|
||||
<label>Status</label>
|
||||
<span id="user-modal-status" class="badge"></span>
|
||||
</div>
|
||||
|
||||
<div class="modal-action-list">
|
||||
<button class="btn btn-block" id="user-modal-toggle-btn"></button>
|
||||
<button class="btn btn-block btn-secondary" id="user-modal-pin-btn">Reset PIN</button>
|
||||
<button class="btn btn-block btn-secondary" id="user-modal-resetpw-btn">Reset NC Password</button>
|
||||
<button class="btn btn-block btn-danger" id="user-modal-delete-btn">Delete User</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-block btn-tertiary" id="user-modal-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset PIN Modal -->
|
||||
<div class="modal" id="pin-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<h3>Reset PIN for <span id="pin-modal-username"></span></h3>
|
||||
<div class="form-group" style="margin-top: 1rem;">
|
||||
<label for="reset-pin">New PIN (min 4 digits)</label>
|
||||
<input type="text" id="reset-pin" placeholder="••••" inputmode="numeric" class="form-group-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="reset-pin-confirm">Confirm PIN</label>
|
||||
<input type="text" id="reset-pin-confirm" placeholder="••••" inputmode="numeric" class="form-group-input">
|
||||
</div>
|
||||
<div class="form-error" id="pin-modal-error" style="display: none;"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="Admin.hideDeleteModal()">Cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-delete">Delete User</button>
|
||||
<button class="btn btn-secondary" id="pin-modal-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="confirm-pin-reset">Reset PIN</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Modal (generic) -->
|
||||
<div class="modal" id="confirm-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<h3 id="confirm-modal-title"></h3>
|
||||
<p id="confirm-modal-msg"></p>
|
||||
<div class="form-error" id="confirm-modal-error" style="display: none;"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="confirm-modal-cancel">Cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-modal-ok">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,7 +151,7 @@
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -134,16 +166,19 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
.form-group input,
|
||||
.form-group-input {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
.form-group input:focus,
|
||||
.form-group-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
@@ -177,47 +212,72 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
/* User list (tappable rows) */
|
||||
.user-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
.user-list-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.user-table thead {
|
||||
background: var(--bg-tertiary);
|
||||
.user-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 0.75rem;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.user-table th {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.user-table td {
|
||||
padding: 0.75rem;
|
||||
.user-list-item:first-child {
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.user-table tr:hover {
|
||||
.user-list-item:active {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
.user-list-item .user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-list-item .user-id {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.user-list-item .user-displayname {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem !important;
|
||||
}
|
||||
|
||||
.user-list-item .user-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-list-item .chevron {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -231,41 +291,7 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-action:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--warning);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -290,6 +316,12 @@
|
||||
|
||||
.modal-content h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@@ -298,9 +330,73 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: var(--warning) !important;
|
||||
.modal-detail {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-detail label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nc-password-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nc-password-row .pw-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.btn-small-action {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-small-action:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.modal-action-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-tertiary {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.btn-tertiary:active {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
@@ -340,15 +436,6 @@
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.user-table th,
|
||||
.user-table td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -359,11 +446,11 @@
|
||||
<script>
|
||||
Admin.init();
|
||||
|
||||
// Close delete modal on outside click
|
||||
document.getElementById('delete-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'delete-modal') {
|
||||
Admin.hideDeleteModal();
|
||||
}
|
||||
// Close modals on outside click
|
||||
['user-modal', 'pin-modal', 'confirm-modal'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('click', (e) => {
|
||||
if (e.target.id === id) Admin.closeModal(id);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,12 +11,19 @@
|
||||
<p class="login-subtitle">Offline-first photo capture for Nextcloud</p>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="login-form">
|
||||
<!-- Login Tabs -->
|
||||
<div class="login-tabs">
|
||||
<button class="login-tab active" data-tab="tech">Tech Login</button>
|
||||
<button class="login-tab" data-tab="admin">Admin Login</button>
|
||||
</div>
|
||||
|
||||
<!-- Tech Login Form -->
|
||||
<form id="tech-login-form" class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Nextcloud Username</label>
|
||||
<label for="tech-username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
id="tech-username"
|
||||
name="username"
|
||||
class="form-input"
|
||||
placeholder="Enter your username"
|
||||
@@ -26,36 +33,79 @@
|
||||
autofocus
|
||||
required
|
||||
>
|
||||
<span class="field-error" id="username-error"></span>
|
||||
<span class="field-error" id="tech-username-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<label for="tech-pin">PIN</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
id="tech-pin"
|
||||
name="pin"
|
||||
class="form-input"
|
||||
placeholder="Enter your PIN"
|
||||
inputmode="numeric"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
>
|
||||
<span class="field-error" id="tech-pin-error"></span>
|
||||
</div>
|
||||
|
||||
<div id="tech-error-message" class="error-message hidden"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-login" id="tech-login-btn">
|
||||
<span class="btn-text">Login</span>
|
||||
<span class="btn-loading hidden">
|
||||
<span class="spinner"></span> Logging in...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Admin Login Form -->
|
||||
<form id="admin-login-form" class="login-form" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label for="admin-username">Nextcloud Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="admin-username"
|
||||
name="username"
|
||||
class="form-input"
|
||||
placeholder="Enter your username"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
required
|
||||
>
|
||||
<span class="field-error" id="admin-username-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="admin-password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="admin-password"
|
||||
name="password"
|
||||
class="form-input"
|
||||
placeholder="Enter your password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
>
|
||||
<span class="field-error" id="password-error"></span>
|
||||
<span class="field-error" id="admin-password-error"></span>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="error-message hidden"></div>
|
||||
<div id="admin-error-message" class="error-message hidden"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-login" id="login-btn">
|
||||
<span id="login-btn-text">Login</span>
|
||||
<span id="login-btn-loading" class="hidden">
|
||||
<button type="submit" class="btn btn-primary btn-login" id="admin-login-btn">
|
||||
<span class="btn-text">Login</span>
|
||||
<span class="btn-loading hidden">
|
||||
<span class="spinner"></span> Logging in...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<p class="help-text">
|
||||
<strong>Tip:</strong> Use your Nextcloud credentials to login
|
||||
<p class="help-text" id="login-help">
|
||||
<strong>Tip:</strong> Use your username and PIN to login
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,9 @@ class Config:
|
||||
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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user