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

@@ -18,7 +18,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
# Create directories for runtime data # Create directories for runtime data
RUN mkdir -p /tmp/flask_session && \ RUN mkdir -p /tmp/flask_session /app/data && \
chmod 777 /tmp/flask_session chmod 777 /tmp/flask_session
# Create non-root user for security # Create non-root user for security

View File

@@ -1,5 +1,6 @@
from flask import Blueprint, request, jsonify, session from flask import Blueprint, request, jsonify, session
from app.services.nextcloud import NextcloudClient from app.services.nextcloud import NextcloudClient
from app.services import tech_users
from config import Config from config import Config
import base64 import base64
@@ -21,6 +22,8 @@ def _check_admin():
return False return False
return True return True
# ── Nextcloud user endpoints (existing) ──────────────────────────────────
@bp.route('/users', methods=['GET']) @bp.route('/users', methods=['GET'])
def list_users(): def list_users():
"""List all Nextcloud users (admin only).""" """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({'error': result.get('error', 'Failed to delete user')}), 500
return jsonify(result), 200 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

View File

@@ -1,5 +1,6 @@
from flask import Blueprint, request, jsonify, session from flask import Blueprint, request, jsonify, session
from app.services.nextcloud import NextcloudClient from app.services.nextcloud import NextcloudClient
from app.services import tech_users
from config import Config from config import Config
import base64 import base64
@@ -15,7 +16,7 @@ def _decrypt_password(encrypted: str) -> str:
@bp.route('/login', methods=['POST']) @bp.route('/login', methods=['POST'])
def login(): def login():
"""Authenticate user against Nextcloud.""" """Authenticate admin user against Nextcloud."""
data = request.get_json() data = request.get_json()
if not data or 'username' not in data or 'password' not in data: 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 return jsonify({'error': 'Username and password cannot be empty'}), 400
try: try:
# Validate credentials by attempting to connect to Nextcloud
nc_client = NextcloudClient(Config.NEXTCLOUD_URL, username, password) nc_client = NextcloudClient(Config.NEXTCLOUD_URL, username, password)
if not nc_client.verify_credentials(): if not nc_client.verify_credentials():
return jsonify({'error': 'Invalid username or password'}), 401 return jsonify({'error': 'Invalid username or password'}), 401
# Check if user is admin
is_admin = nc_client.check_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['username'] = username
session['password'] = _encrypt_password(password) session['password'] = _encrypt_password(password)
session['is_admin'] = is_admin session['is_admin'] = True
session['user_type'] = 'admin'
return jsonify({ return jsonify({
'success': True, 'success': True,
'username': username, 'username': username,
'is_admin': is_admin 'is_admin': True,
'user_type': 'admin',
}), 200 }), 200
except Exception as e: except Exception as e:
return jsonify({'error': f'Authentication failed: {str(e)}'}), 500 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']) @bp.route('/logout', methods=['POST'])
def logout(): def logout():
"""Clear user session.""" """Clear user session."""
@@ -64,7 +97,8 @@ def status():
return jsonify({ return jsonify({
'authenticated': True, 'authenticated': True,
'username': session['username'], '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 }), 200
else: else:
return jsonify({ return jsonify({

View File

@@ -82,7 +82,7 @@ def admin():
if 'username' not in session: if 'username' not in session:
return redirect(url_for('views.login_page')) return redirect(url_for('views.login_page'))
if not session.get('is_admin', False): if not session.get('is_admin', False):
return "Access denied: Admin privileges required", 403 return redirect(url_for('views.capture'))
return render_template( return render_template(
'admin.html', 'admin.html',
username=session['username'], username=session['username'],

View File

@@ -352,3 +352,21 @@ class NextcloudClient:
return {'success': True} return {'success': True}
except Exception as e: except Exception as e:
return {'success': False, 'error': str(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)

View File

@@ -2,104 +2,321 @@
'use strict'; 'use strict';
const Admin = { const Admin = {
users: [], techUsers: [],
_activeUser: null,
async init() { async init() {
await this.loadUsers();
this.setupEventListeners(); this.setupEventListeners();
await this.loadTechUsers();
}, },
setupEventListeners() { setupEventListeners() {
document.getElementById('add-user-form').addEventListener('submit', (e) => { document.getElementById('add-tech-user-form').addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
this.createUser(); this.createTechUser();
}); });
document.getElementById('refresh-btn').addEventListener('click', () => { // User detail modal buttons
this.loadUsers(); 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() { // ── Load & Render ────────────────────────────────────────────────────
const userList = document.getElementById('user-list');
const loadingMsg = document.getElementById('loading-msg');
const errorMsg = document.getElementById('error-msg');
loadingMsg.style.display = 'block'; async loadTechUsers() {
errorMsg.style.display = 'none'; const list = document.getElementById('tech-user-list');
userList.innerHTML = ''; 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 { try {
const response = await fetch('/api/admin/users'); const response = await fetch('/api/admin/tech-users');
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const err = await response.json();
throw new Error(error.error || 'Failed to load users'); throw new Error(err.error || 'Failed to load tech users');
} }
const data = await response.json(); const data = await response.json();
this.users = data.users || []; this.techUsers = data.tech_users || [];
loading.style.display = 'none';
loadingMsg.style.display = 'none'; if (this.techUsers.length === 0) {
list.innerHTML = '<div class="user-list-empty">No tech users yet</div>';
if (this.users.length === 0) {
userList.innerHTML = '<tr><td colspan="5" class="empty-state">No users found</td></tr>';
return; return;
} }
this.users.forEach(user => { this.techUsers.forEach(user => {
const row = this.createUserRow(user); list.appendChild(this._createUserItem(user));
userList.appendChild(row);
}); });
} catch (error) { } catch (err) {
console.error('Error loading users:', error); console.error('Error loading tech users:', err);
loadingMsg.style.display = 'none'; loading.style.display = 'none';
errorMsg.textContent = error.message; error.textContent = err.message;
errorMsg.style.display = 'block'; error.style.display = 'block';
} }
}, },
createUserRow(user) { _createUserItem(user) {
const row = document.createElement('tr'); const item = document.createElement('div');
row.innerHTML = ` item.className = 'user-list-item';
<td>${this.escapeHtml(user.id)}</td> const u = this.escapeHtml(user.username);
<td>${this.escapeHtml(user.displayname || '-')}</td> const dn = user.display_name && user.display_name !== user.username
<td>${this.escapeHtml(user.email || '-')}</td> ? this.escapeHtml(user.display_name) : '';
<td>
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'}"> <span class="badge ${user.enabled ? 'badge-success' : 'badge-danger'}">
${user.enabled ? 'Enabled' : 'Disabled'} ${user.enabled ? 'Enabled' : 'Disabled'}
</span> </span>
</td> <span class="chevron"></span>
<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>
</div> </div>
</td>
`; `;
return row; item.addEventListener('click', () => this.showUserModal(user.username));
return item;
}, },
async createUser() { // ── User Detail Modal ────────────────────────────────────────────────
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(); showUserModal(username) {
const password = document.getElementById('new-password').value; const user = this.techUsers.find(u => u.username === username);
const email = document.getElementById('new-email').value.trim(); if (!user) return;
const displayName = document.getElementById('new-displayname').value.trim(); 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'; formError.style.display = 'none';
formSuccess.style.display = 'none'; formSuccess.style.display = 'none';
if (!username || !password) { if (!username || !pin) {
formError.textContent = 'Username and password are required'; 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'; formError.style.display = 'block';
return; return;
} }
@@ -108,121 +325,35 @@ const Admin = {
submitBtn.textContent = 'Creating...'; submitBtn.textContent = 'Creating...';
try { try {
const response = await fetch('/api/admin/users', { const response = await fetch('/api/admin/tech-users', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ username, display_name: displayName, pin })
username: username,
password: password,
email: email || null,
displayName: displayName || null
})
}); });
const result = await response.json(); const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Failed to create tech user');
if (!response.ok) { formSuccess.textContent = `Tech user "${username}" created!`;
throw new Error(result.error || 'Failed to create user');
}
formSuccess.textContent = `User "${username}" created successfully!`;
formSuccess.style.display = 'block'; formSuccess.style.display = 'block';
form.reset(); document.getElementById('add-tech-user-form').reset();
this.loadTechUsers();
setTimeout(() => { } catch (err) {
this.loadUsers(); console.error('Error creating tech user:', err);
}, 1000); formError.textContent = err.message;
} catch (error) {
console.error('Error creating user:', error);
formError.textContent = error.message;
formError.style.display = 'block'; formError.style.display = 'block';
} finally { } finally {
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.textContent = 'Create User'; submitBtn.textContent = 'Create Tech User';
} }
}, },
async enableUser(username) { // ── Helpers ──────────────────────────────────────────────────────────
if (!confirm(`Enable user "${username}"?`)) return;
try { closeModal(id) {
const response = await fetch(`/api/admin/users/${username}/enable`, { document.getElementById(id).style.display = 'none';
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');
}
}, },
showToast(message, type) { showToast(message, type) {
@@ -230,10 +361,7 @@ const Admin = {
toast.textContent = message; toast.textContent = message;
toast.className = `toast ${type}`; toast.className = `toast ${type}`;
toast.style.display = 'block'; toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 3000);
setTimeout(() => {
toast.style.display = 'none';
}, 3000);
}, },
escapeHtml(text) { escapeHtml(text) {

View File

@@ -2,130 +2,166 @@
'use strict'; 'use strict';
const Auth = { const Auth = {
currentTab: 'tech',
init() { init() {
const loginForm = document.getElementById('login-form'); // Tab switching
if (loginForm) { document.querySelectorAll('.login-tab').forEach(tab => {
loginForm.addEventListener('submit', this.handleLogin.bind(this)); tab.addEventListener('click', () => this.switchTab(tab.dataset.tab));
});
// Add input validation // Tech login form
const usernameInput = document.getElementById('username'); const techForm = document.getElementById('tech-login-form');
const passwordInput = document.getElementById('password'); if (techForm) {
techForm.addEventListener('submit', (e) => this.handleTechLogin(e));
if (usernameInput) {
usernameInput.addEventListener('blur', () => this.validateUsername());
usernameInput.addEventListener('input', () => this.clearFieldError('username'));
} }
if (passwordInput) { // Admin login form
passwordInput.addEventListener('input', () => this.clearFieldError('password')); 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() { switchTab(tab) {
const username = document.getElementById('username').value.trim(); this.currentTab = tab;
const usernameError = document.getElementById('username-error');
const usernameInput = document.getElementById('username');
if (!username) { // Update tab buttons
usernameError.textContent = 'Username is required'; document.querySelectorAll('.login-tab').forEach(t => {
usernameInput.classList.add('error'); t.classList.toggle('active', t.dataset.tab === tab);
return false; });
// 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) { // Clear errors on tab switch
usernameError.textContent = 'Username must be at least 2 characters'; document.querySelectorAll('.error-message').forEach(el => el.classList.add('hidden'));
usernameInput.classList.add('error'); document.querySelectorAll('.field-error').forEach(el => el.textContent = '');
return false; document.querySelectorAll('.form-input').forEach(el => el.classList.remove('error'));
}
usernameError.textContent = '';
usernameInput.classList.remove('error');
return true;
}, },
validatePassword() { _setLoading(btn, loading) {
const password = document.getElementById('password').value; const text = btn.querySelector('.btn-text');
const passwordError = document.getElementById('password-error'); const spinner = btn.querySelector('.btn-loading');
const passwordInput = document.getElementById('password'); btn.disabled = loading;
text.classList.toggle('hidden', loading);
if (!password) { spinner.classList.toggle('hidden', !loading);
passwordError.textContent = 'Password is required';
passwordInput.classList.add('error');
return false;
}
passwordError.textContent = '';
passwordInput.classList.remove('error');
return true;
}, },
clearFieldError(field) { async handleTechLogin(event) {
const errorElement = document.getElementById(`${field}-error`);
const inputElement = document.getElementById(field);
if (errorElement) errorElement.textContent = '';
if (inputElement) inputElement.classList.remove('error');
},
async handleLogin(event) {
event.preventDefault(); event.preventDefault();
// Validate fields const username = document.getElementById('tech-username').value.trim();
const usernameValid = this.validateUsername(); const pin = document.getElementById('tech-pin').value;
const passwordValid = this.validatePassword(); 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; 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.classList.add('hidden');
errorMessage.textContent = ''; this._setLoading(btn, true);
// Disable button and show loading state
loginBtn.disabled = true;
loginBtnText.classList.add('hidden');
loginBtnLoading.classList.remove('hidden');
try { try {
const response = await fetch('/api/auth/login', { const response = await fetch('/api/auth/login/tech', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json' body: JSON.stringify({ username, pin })
},
body: JSON.stringify({ username, password })
}); });
const data = await response.json(); const data = await response.json();
if (response.ok && data.success) { if (response.ok && data.success) {
// Login successful - redirect to capture page
window.location.href = '/capture'; window.location.href = '/capture';
} else { } else {
// Login failed - show error errorMessage.textContent = data.error || 'Login failed. Please check your credentials.';
const errorText = data.error || 'Login failed. Please check your credentials and try again.';
errorMessage.textContent = errorText;
errorMessage.classList.remove('hidden'); errorMessage.classList.remove('hidden');
// Focus back on username for retry
document.getElementById('username').focus();
} }
} catch (error) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);
errorMessage.textContent = 'Network error. Please check your connection and try again.'; errorMessage.textContent = 'Network error. Please check your connection and try again.';
errorMessage.classList.remove('hidden'); errorMessage.classList.remove('hidden');
} finally { } finally {
// Re-enable button and restore normal state this._setLoading(btn, false);
loginBtn.disabled = false; }
loginBtnText.classList.remove('hidden'); },
loginBtnLoading.classList.add('hidden');
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 = '/'; window.location.href = '/';
} catch (error) { } catch (error) {
console.error('Logout error:', error); console.error('Logout error:', error);
// Redirect anyway
window.location.href = '/'; window.location.href = '/';
} }
} }

View File

@@ -1,9 +1,9 @@
// NextSnap Service Worker // NextSnap Service Worker
// Provides offline-first caching for the app shell // Provides offline-first caching for the app shell
const CACHE_VERSION = 'nextsnap-v27'; const CACHE_VERSION = 'nextsnap-v33';
const APP_SHELL_CACHE = 'nextsnap-shell-v23'; const APP_SHELL_CACHE = 'nextsnap-shell-v29';
const RUNTIME_CACHE = 'nextsnap-runtime-v23'; const RUNTIME_CACHE = 'nextsnap-runtime-v29';
// Offline fallback page with bottom nav bar so user can navigate to cached pages // Offline fallback page with bottom nav bar so user can navigate to cached pages
const OFFLINE_PAGE = `<!DOCTYPE html> const OFFLINE_PAGE = `<!DOCTYPE html>

View File

@@ -6,75 +6,107 @@
<div class="container"> <div class="container">
<div class="admin-header"> <div class="admin-header">
<h2>Admin Panel</h2> <h2>Admin Panel</h2>
<button class="btn btn-secondary btn-small" id="refresh-btn">
<span>🔄</span> Refresh
</button>
</div> </div>
<!-- Add User Form --> <!-- Add Tech User Form -->
<div class="admin-section"> <div class="admin-section">
<h3>Add New User</h3> <h3>Add Tech User</h3>
<form id="add-user-form" class="user-form"> <form id="add-tech-user-form" class="user-form">
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="new-username">Username *</label> <label for="new-tech-username">Username *</label>
<input type="text" id="new-username" required placeholder="username"> <input type="text" id="new-tech-username" required placeholder="jsmith" autocapitalize="none" autocorrect="off">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new-password">Password *</label> <label for="new-tech-displayname">Display Name</label>
<input type="password" id="new-password" required placeholder="••••••••"> <input type="text" id="new-tech-displayname" placeholder="John Smith">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="new-email">Email</label> <label for="new-tech-pin">PIN * (min 4 digits)</label>
<input type="email" id="new-email" placeholder="user@example.com"> <input type="text" id="new-tech-pin" required placeholder="••••" inputmode="numeric">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="new-displayname">Display Name</label> <label for="new-tech-pin-confirm">Confirm PIN *</label>
<input type="text" id="new-displayname" placeholder="John Doe"> <input type="text" id="new-tech-pin-confirm" required placeholder="••••" inputmode="numeric">
</div> </div>
</div> </div>
<div class="form-error" id="form-error" style="display: none;"></div> <div class="form-error" id="tech-form-error" style="display: none;"></div>
<div class="form-success" id="form-success" style="display: none;"></div> <div class="form-success" id="tech-form-success" style="display: none;"></div>
<button type="submit" class="btn btn-primary" id="submit-btn">Create User</button> <button type="submit" class="btn btn-primary" id="tech-submit-btn">Create Tech User</button>
</form> </form>
</div> </div>
<!-- User List --> <!-- Tech User List -->
<div class="admin-section"> <div class="admin-section">
<h3>Nextcloud Users</h3> <h3>Tech Users</h3>
<div class="loading-msg" id="loading-msg" style="display: none;">Loading users...</div> <div class="loading-msg" id="tech-loading-msg" style="display: none;">Loading tech users...</div>
<div class="error-msg" id="error-msg" style="display: none;"></div> <div class="error-msg" id="tech-error-msg" style="display: none;"></div>
<div id="tech-user-list" class="user-list"></div>
</div>
</div>
<div class="table-container"> <!-- User Detail Modal -->
<table class="user-table"> <div class="modal" id="user-modal" style="display: none;">
<thead> <div class="modal-content">
<tr> <h3 id="user-modal-title"></h3>
<th>Username</th> <p id="user-modal-displayname" class="modal-subtitle"></p>
<th>Display Name</th>
<th>Email</th> <div class="modal-detail">
<th>Status</th> <label>NC Password</label>
<th>Actions</th> <div class="nc-password-row">
</tr> <span class="pw-text" id="user-modal-pw" data-masked="true"></span>
</thead> <button class="btn-small-action" id="user-modal-pw-toggle">Show</button>
<tbody id="user-list"> <button class="btn-small-action" id="user-modal-pw-copy">Copy</button>
<tr><td colspan="5" class="empty-state">Loading...</td></tr> </div>
</tbody> </div>
</table>
<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" id="pin-modal-cancel">Cancel</button>
<button class="btn btn-primary" id="confirm-pin-reset">Reset PIN</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Delete Confirmation Modal --> <!-- Confirm Modal (generic) -->
<div class="modal" id="delete-modal" style="display: none;"> <div class="modal" id="confirm-modal" style="display: none;">
<div class="modal-content"> <div class="modal-content">
<h3>Delete User?</h3> <h3 id="confirm-modal-title"></h3>
<p>Are you sure you want to delete user <strong id="delete-username"></strong>?</p> <p id="confirm-modal-msg"></p>
<p class="warning-text">⚠️ This action cannot be undone. All user data will be permanently deleted.</p> <div class="form-error" id="confirm-modal-error" style="display: none;"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="Admin.hideDeleteModal()">Cancel</button> <button class="btn btn-secondary" id="confirm-modal-cancel">Cancel</button>
<button class="btn btn-danger" id="confirm-delete">Delete User</button> <button class="btn btn-danger" id="confirm-modal-ok">Confirm</button>
</div> </div>
</div> </div>
</div> </div>
@@ -119,7 +151,7 @@
.form-row { .form-row {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem; gap: 1rem;
} }
@@ -134,16 +166,19 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.form-group input { .form-group input,
.form-group-input {
padding: 0.75rem; padding: 0.75rem;
background: var(--bg-primary); background: var(--bg-primary);
border: 1px solid var(--bg-tertiary); border: 1px solid var(--bg-tertiary);
border-radius: 6px; border-radius: 6px;
color: var(--text-primary); color: var(--text-primary);
font-size: 1rem; font-size: 1rem;
width: 100%;
} }
.form-group input:focus { .form-group input:focus,
.form-group-input:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
} }
@@ -177,47 +212,72 @@
border-radius: 6px; border-radius: 6px;
} }
.table-container { /* User list (tappable rows) */
overflow-x: auto; .user-list {
display: flex;
flex-direction: column;
gap: 0;
} }
.user-table { .user-list-empty {
width: 100%; text-align: center;
border-collapse: collapse; color: var(--text-secondary);
margin-top: 1rem; padding: 2rem;
} }
.user-table thead { .user-list-item {
background: var(--bg-tertiary); 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 { .user-list-item:first-child {
padding: 0.75rem;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
}
.user-table td {
padding: 0.75rem;
border-top: 1px solid var(--bg-tertiary); border-top: 1px solid var(--bg-tertiary);
} }
.user-table tr:hover { .user-list-item:active {
background: var(--bg-tertiary); background: var(--bg-tertiary);
} }
.empty-state { .user-list-item .user-info {
text-align: center; 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); 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 { .badge {
display: inline-block; display: inline-block;
padding: 0.25rem 0.75rem; padding: 0.2rem 0.6rem;
border-radius: 12px; border-radius: 12px;
font-size: 0.85rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
} }
@@ -231,41 +291,7 @@
color: white; color: white;
} }
.action-buttons { /* Modal */
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; position: fixed;
top: 0; top: 0;
@@ -290,6 +316,12 @@
.modal-content h3 { .modal-content h3 {
margin-top: 0; margin-top: 0;
margin-bottom: 0.25rem;
}
.modal-subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -298,9 +330,73 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.warning-text { .modal-detail {
color: var(--warning) !important; margin-bottom: 1rem;
}
.modal-detail label {
display: block;
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: 600; 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 { .modal-actions {
@@ -340,15 +436,6 @@
.form-row { .form-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.user-table {
font-size: 0.85rem;
}
.user-table th,
.user-table td {
padding: 0.5rem;
}
} }
</style> </style>
{% endblock %} {% endblock %}
@@ -359,11 +446,11 @@
<script> <script>
Admin.init(); Admin.init();
// Close delete modal on outside click // Close modals on outside click
document.getElementById('delete-modal').addEventListener('click', (e) => { ['user-modal', 'pin-modal', 'confirm-modal'].forEach(id => {
if (e.target.id === 'delete-modal') { document.getElementById(id).addEventListener('click', (e) => {
Admin.hideDeleteModal(); if (e.target.id === id) Admin.closeModal(id);
} });
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -11,12 +11,19 @@
<p class="login-subtitle">Offline-first photo capture for Nextcloud</p> <p class="login-subtitle">Offline-first photo capture for Nextcloud</p>
</div> </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"> <div class="form-group">
<label for="username">Nextcloud Username</label> <label for="tech-username">Username</label>
<input <input
type="text" type="text"
id="username" id="tech-username"
name="username" name="username"
class="form-input" class="form-input"
placeholder="Enter your username" placeholder="Enter your username"
@@ -26,36 +33,79 @@
autofocus autofocus
required required
> >
<span class="field-error" id="username-error"></span> <span class="field-error" id="tech-username-error"></span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="tech-pin">PIN</label>
<input <input
type="password" 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" name="password"
class="form-input" class="form-input"
placeholder="Enter your password" placeholder="Enter your password"
autocomplete="current-password" autocomplete="current-password"
required required
> >
<span class="field-error" id="password-error"></span> <span class="field-error" id="admin-password-error"></span>
</div> </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"> <button type="submit" class="btn btn-primary btn-login" id="admin-login-btn">
<span id="login-btn-text">Login</span> <span class="btn-text">Login</span>
<span id="login-btn-loading" class="hidden"> <span class="btn-loading hidden">
<span class="spinner"></span> Logging in... <span class="spinner"></span> Logging in...
</span> </span>
</button> </button>
</form> </form>
<div class="login-footer"> <div class="login-footer">
<p class="help-text"> <p class="help-text" id="login-help">
<strong>Tip:</strong> Use your Nextcloud credentials to login <strong>Tip:</strong> Use your username and PIN to login
</p> </p>
</div> </div>
</div> </div>
@@ -98,6 +148,32 @@ body {
font-size: 0.9rem; 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 { .login-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -5,6 +5,9 @@ class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
NEXTCLOUD_URL = os.environ.get('NEXTCLOUD_URL', 'https://nextcloud.sdanywhere.com') 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 configuration
SESSION_TYPE = 'filesystem' SESSION_TYPE = 'filesystem'
SESSION_FILE_DIR = '/tmp/flask_session' SESSION_FILE_DIR = '/tmp/flask_session'

View File

@@ -15,6 +15,7 @@ services:
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}
volumes: volumes:
- flask_sessions:/tmp/flask_session - flask_sessions:/tmp/flask_session
- app_data:/app/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
@@ -33,6 +34,8 @@ services:
volumes: volumes:
flask_sessions: flask_sessions:
driver: local driver: local
app_data:
driver: local
networks: networks:
nextsnap-network: nextsnap-network: