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 . .
# 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

View File

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

View File

@@ -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({

View File

@@ -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'],

View File

@@ -352,3 +352,21 @@ class NextcloudClient:
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
def ocs_set_password(self, username: str, password: str) -> Dict[str, Any]:
"""Set a user's password via OCS API."""
url = f"{self.ocs_root}/cloud/users/{username}"
params = {"format": "json"}
data = {"key": "password", "value": password}
try:
response = self._make_request("PUT", url, params=params, data=data, headers=self._ocs_headers)
result = response.json()
meta = result.get("ocs", {}).get("meta", {})
if meta.get("statuscode") != 100:
return {"success": False, "error": meta.get("message", "Failed to set password")}
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}

115
app/services/tech_users.py Normal file
View File

@@ -0,0 +1,115 @@
"""Tech user management - JSON file CRUD with PIN auth."""
import json
import os
import secrets
import string
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from config import Config
TECH_USERS_FILE = os.environ.get('TECH_USERS_FILE', '/app/data/tech_users.json')
def _load():
"""Load tech users from JSON file."""
if not os.path.exists(TECH_USERS_FILE):
return {}
with open(TECH_USERS_FILE, 'r') as f:
return json.load(f)
def _save(data):
"""Save tech users to JSON file."""
os.makedirs(os.path.dirname(TECH_USERS_FILE), exist_ok=True)
with open(TECH_USERS_FILE, 'w') as f:
json.dump(data, f, indent=2)
def generate_nc_password(length=16):
"""Generate a random password for Nextcloud account."""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
def create(username, display_name, pin, created_by):
"""Create a new tech user. Returns the record (with nc_password) or raises."""
data = _load()
if username in data:
raise ValueError(f'Tech user "{username}" already exists')
nc_password = generate_nc_password()
data[username] = {
'display_name': display_name,
'pin_hash': generate_password_hash(pin),
'nc_password': nc_password,
'created_at': datetime.utcnow().isoformat(),
'created_by': created_by,
'enabled': True,
}
_save(data)
return data[username]
def verify_pin(username, pin):
"""Verify a tech user's PIN. Returns the user record or None."""
data = _load()
user = data.get(username)
if not user:
return None
if not user.get('enabled', True):
return None
if not check_password_hash(user['pin_hash'], pin):
return None
return user
def list_all():
"""Return all tech users (with nc_password visible)."""
return _load()
def get(username):
"""Get a single tech user record."""
return _load().get(username)
def update(username, **fields):
"""Update a tech user. Supported fields: display_name, pin, enabled."""
data = _load()
user = data.get(username)
if not user:
raise ValueError(f'Tech user "{username}" not found')
if 'display_name' in fields:
user['display_name'] = fields['display_name']
if 'pin' in fields:
user['pin_hash'] = generate_password_hash(fields['pin'])
if 'enabled' in fields:
user['enabled'] = fields['enabled']
data[username] = user
_save(data)
return user
def reset_nc_password(username):
"""Generate a new NC password and save it. Returns the new password."""
data = _load()
user = data.get(username)
if not user:
raise ValueError(f'Tech user "{username}" not found')
new_password = generate_nc_password()
user['nc_password'] = new_password
data[username] = user
_save(data)
return new_password
def delete(username):
"""Delete a tech user from JSON."""
data = _load()
if username not in data:
raise ValueError(f'Tech user "{username}" not found')
del data[username]
_save(data)

View File

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

View File

@@ -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 = '/';
}
}

View File

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

View File

@@ -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 %}

View File

@@ -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;

View File

@@ -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'

View File

@@ -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: