From 716ae7e3b22146065d5edbc4ff1e5ef19a76aa19 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Tue, 6 Oct 2026 14:32:24 -0500 Subject: [PATCH] modified: blueprints/admin.py --- blueprints/admin.py | 50 +++- blueprints/api.py | 32 ++- models/note.py | 4 + seed_admin.py | 101 -------- static/admin.html | 75 +++++- static/assets/css/admin.css | 305 +++++++++++++++++++----- static/assets/js/admin.js | 464 +++++++++++++++++++++++++++++------- static/assets/js/index.js | 12 +- static/assets/js/map.js | 6 +- 9 files changed, 780 insertions(+), 269 deletions(-) delete mode 100644 seed_admin.py diff --git a/blueprints/admin.py b/blueprints/admin.py index 21b8474..2f6934b 100644 --- a/blueprints/admin.py +++ b/blueprints/admin.py @@ -24,8 +24,14 @@ def before_request(): def get_stats(): return jsonify({ 'user_count': User.query.count(), + 'banned_user_count': User.query.filter_by(is_banned=True).count(), 'notepad_count': Notepad.query.count(), - 'note_count': Note.query.count() + 'public_notepad_count': Notepad.query.filter_by(is_public=True).count(), + 'private_notepad_count': Notepad.query.filter_by(is_public=False).count(), + 'note_count': Note.query.count(), + 'public_note_count': Note.query.filter_by(is_encrypted=False).count(), + 'encrypted_note_count': Note.query.filter_by(is_encrypted=True).count(), + 'reported_note_count': Note.query.filter_by(is_hidden=True).count() }) @admin_bp.route('/users', methods=['GET']) @@ -133,4 +139,44 @@ def ban_user_ip(user_id): db.session.add(banned) db.session.commit() - return jsonify({'message': f'IP {user.last_ip} banned'}), 200 \ No newline at end of file + return jsonify({'message': f'IP {user.last_ip} banned'}), 200 + +@admin_bp.route('/users//toggle_admin', methods=['POST']) +def toggle_admin(user_id): + curr_user_id = session.get('user_id') + if curr_user_id == user_id: + return jsonify({'error': 'Cannot change your own admin status'}), 400 + user = User.query.get_or_404(user_id) + user.is_admin = not user.is_admin + db.session.commit() + return jsonify({'message': f"Admin status set to {user.is_admin}", 'is_admin': user.is_admin}), 200 + +@admin_bp.route('/reported_notes', methods=['GET']) +def get_reported_notes(): + reported = Note.query.filter_by(is_hidden=True).all() + results = [] + for note in reported: + n_dict = note.to_dict() + n_dict['notepad_name'] = note.notepad.name if note.notepad else 'Unknown Notepad' + n_dict['author_email'] = note.author.email if note.author else 'Anonymous / Preseeded' + results.append(n_dict) + return jsonify(results) + +@admin_bp.route('/notes//restore', methods=['POST']) +def restore_note(note_id): + note = Note.query.get_or_404(note_id) + note.is_hidden = False + db.session.commit() + return jsonify({'message': 'Note restored successfully'}), 200 + +@admin_bp.route('/banned_ips', methods=['GET']) +def get_banned_ips(): + ips = BannedIP.query.order_by(BannedIP.created_at.desc()).all() + return jsonify([{'id': ip.id, 'ip_address': ip.ip_address, 'created_at': ip.created_at.isoformat()} for ip in ips]) + +@admin_bp.route('/banned_ips/', methods=['DELETE']) +def delete_banned_ip(ip_id): + banned = BannedIP.query.get_or_404(ip_id) + db.session.delete(banned) + db.session.commit() + return jsonify({'message': 'IP unbanned successfully'}), 200 \ No newline at end of file diff --git a/blueprints/api.py b/blueprints/api.py index 8493c1b..0ea4d95 100644 --- a/blueprints/api.py +++ b/blueprints/api.py @@ -41,12 +41,30 @@ def check_pad_read_permission(notepad, user_id, token=None): def check_pad_write_permission(notepad, user_id): if not user_id: return False + user = User.query.get(user_id) + if user and user.is_admin: + return True + if notepad.is_public: + return True if notepad.user_id == user_id: return True # Check for collaborator with 'write' permission collab = Collaborator.query.filter_by(user_id=user_id, notepad_id=notepad.id, permission='write').first() return collab is not None +def check_note_write_permission(note, notepad, user_id): + if not user_id: + return False + user = User.query.get(user_id) + if user and user.is_admin: + return True + if note.user_id and note.user_id == user_id: + return True + if notepad.user_id and notepad.user_id == user_id: + return True + collab = Collaborator.query.filter_by(user_id=user_id, notepad_id=notepad.id, permission='write').first() + return collab is not None + @api_bp.route('/users/lookup', methods=['GET']) def lookup_user(): user_id = session.get('user_id') @@ -107,7 +125,7 @@ def get_notepads(): if user_id: if pad.user_id == user_id: p_dict['share_token'] = get_share_token('notepad', pad.id) - p_dict['can_write'] = (pad.user_id == user_id) or (pad.id in collab_write_pad_ids) + p_dict['can_write'] = pad.is_public or (pad.user_id == user_id) or (pad.id in collab_write_pad_ids) else: p_dict['can_write'] = False results.append(p_dict) @@ -197,7 +215,7 @@ def get_note(note_id): return jsonify({'error': 'Unauthorized'}), 403 n_dict = note.to_dict() - n_dict['can_write'] = check_pad_write_permission(notepad, user_id) + n_dict['can_write'] = check_note_write_permission(note, notepad, user_id) pad_dict = notepad.to_dict() # Redact name if private and not owner @@ -287,7 +305,8 @@ def create_note(): lat=0.0, lng=0.0, is_encrypted=True, - notepad_id=notepad_id + notepad_id=notepad_id, + user_id=user_id ) else: new_note = Note( @@ -296,7 +315,8 @@ def create_note(): lat=float(data.get('lat', 0.0)), lng=float(data.get('lng', 0.0)), is_encrypted=False, - notepad_id=notepad_id + notepad_id=notepad_id, + user_id=user_id ) db.session.add(new_note) db.session.commit() @@ -309,7 +329,7 @@ def update_note(note_id): return jsonify({'error': 'Unauthorized'}), 401 note = Note.query.get_or_404(note_id) notepad = Notepad.query.get(note.notepad_id) - if not check_pad_write_permission(notepad, user_id): + if not check_note_write_permission(note, notepad, user_id): return jsonify({'error': 'Forbidden'}), 403 data = request.get_json() note.title = data.get('title', note.title) @@ -502,7 +522,7 @@ def delete_note(note_id): note = Note.query.get_or_404(note_id) notepad = Notepad.query.get(note.notepad_id) - if not check_pad_write_permission(notepad, user_id): + if not check_note_write_permission(note, notepad, user_id): return jsonify({'error': 'Forbidden'}), 403 db.session.delete(note) diff --git a/models/note.py b/models/note.py index fc0d4e4..ad8decc 100644 --- a/models/note.py +++ b/models/note.py @@ -11,6 +11,9 @@ class Note(db.Model): is_hidden = db.Column(db.Boolean, default=False) is_encrypted = db.Column(db.Boolean, default=False, nullable=False) notepad_id = db.Column(db.Integer, db.ForeignKey('notepad.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + + author = db.relationship('User', backref=db.backref('authored_notes', lazy='dynamic')) def __init__(self, **kwargs): super().__init__(**kwargs) @@ -23,5 +26,6 @@ class Note(db.Model): 'lat': self.lat, 'lng': self.lng, 'is_encrypted': self.is_encrypted, + 'user_id': self.user_id, 'created_at': self.created_at.isoformat() } \ No newline at end of file diff --git a/seed_admin.py b/seed_admin.py deleted file mode 100644 index d5ea90c..0000000 --- a/seed_admin.py +++ /dev/null @@ -1,101 +0,0 @@ -import os -import json -import base64 -from app import app, db -from models.user import User -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.ciphers.aead import AESGCM - -def int_to_b64url(val): - byte_len = (val.bit_length() + 7) // 8 - b = val.to_bytes(byte_len, 'big') - return base64.urlsafe_b64encode(b).decode('utf-8').rstrip('=') - -def seed_admin_user(email="benjimanmiller@gmail.com", password="0kl4h0m4", first_name="Benjiman", last_name="Miller"): - with app.app_context(): - # 1. Generate RSA-OAEP 2048 keypair - priv = rsa.generate_private_key(public_exponent=65537, key_size=2048) - pub = priv.public_key() - priv_nums = priv.private_numbers() - pub_nums = pub.public_numbers() - - pub_jwk = { - 'kty': 'RSA', - 'n': int_to_b64url(pub_nums.n), - 'e': int_to_b64url(pub_nums.e), - 'alg': 'RSA-OAEP-256', - 'ext': True, - 'key_ops': ['encrypt'] - } - - priv_jwk = { - 'kty': 'RSA', - 'n': int_to_b64url(pub_nums.n), - 'e': int_to_b64url(pub_nums.e), - 'd': int_to_b64url(priv_nums.d), - 'p': int_to_b64url(priv_nums.p), - 'q': int_to_b64url(priv_nums.q), - 'dp': int_to_b64url(priv_nums.dmp1), - 'dq': int_to_b64url(priv_nums.dmq1), - 'qi': int_to_b64url(priv_nums.iqmp), - 'alg': 'RSA-OAEP-256', - 'ext': True, - 'key_ops': ['decrypt'] - } - - # 2. Derive KEK via PBKDF2 - salt = os.urandom(16) - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=salt, - iterations=100000 - ) - kek = kdf.derive(password.encode('utf-8')) - - # 3. Encrypt private key via AES-GCM - iv = os.urandom(12) - aesgcm = AESGCM(kek) - ct = aesgcm.encrypt(iv, json.dumps(priv_jwk).encode('utf-8'), None) - - enc_bundle = json.dumps({ - 'iv': base64.b64encode(iv).decode('utf-8'), - 'ct': base64.b64encode(ct).decode('utf-8') - }) - salt_b64 = base64.b64encode(salt).decode('utf-8') - pub_str = json.dumps(pub_jwk) - - # 4. Check if user exists - user = User.query.filter_by(email=email).first() - if user: - print(f"Updating existing user '{email}' to admin with new keys and password...") - user.first_name = first_name - user.last_name = last_name - user.is_admin = True - user.is_banned = False - user.set_password(password) - user.public_key = pub_str - user.encrypted_private_key = enc_bundle - user.encryption_salt = salt_b64 - else: - print(f"Creating new admin user '{email}'...") - user = User( - email=email, - first_name=first_name, - last_name=last_name, - is_admin=True, - is_banned=False, - public_key=pub_str, - encrypted_private_key=enc_bundle, - encryption_salt=salt_b64 - ) - user.set_password(password) - db.session.add(user) - - db.session.commit() - print(f"Successfully seeded admin user: {email} (is_admin={user.is_admin})") - -if __name__ == '__main__': - seed_admin_user() diff --git a/static/admin.html b/static/admin.html index 21366fe..9430e60 100644 --- a/static/admin.html +++ b/static/admin.html @@ -3,42 +3,91 @@ - Admin Panel - SpotNotes.app + Admin Control Panel - SpotNotes.app -
-

Admin Panel

+
+
+
+

βš™οΈ Admin Control Panel

+

System administration, security, capacity management, and public content moderation.

+
+ πŸ—ΊοΈ Back to Map +
+ + +
+ πŸ”’ +
+ Zero-Knowledge E2EE Architecture: + Private notes and coordinates are client-side encrypted using AES-GCM-256 and RSA-OAEP keys. The server operator cannot read private note contents, decryption keys, or private GPS positions. As an administrator, you manage user security, accounts, IP bans, storage hygiene, and moderation of unencrypted public content. +
+
-
+ +

- Users β–Ό + 🚩 Moderation Queue: Reported Public Notes 0 + β–Ό

- +
+

Loading reported notes...

+
+
+ + +
+

+ πŸ‘₯ User Accounts & Access Control + β–Ό +

+
+ +

Loading users...

-
-

Notepads β–Ό

-
-

Select a user to see their notepads.

+ +
+

+ 🚫 Banned IP Addresses + β–Ό +

+
-
-

Notes β–Ό

+ +
+

+ πŸ“ Notepads Explorer + β–Ό +

+
+

Select a user above to inspect their notepads, or select System / Public Notepads.

+
+
+ + +
+

+ πŸ“ Notes Explorer + β–Ό +

-

Select a notepad to see its notes.

+

Select a notepad above to view its notes and storage details.

diff --git a/static/assets/css/admin.css b/static/assets/css/admin.css index 7ad3616..41403e2 100644 --- a/static/assets/css/admin.css +++ b/static/assets/css/admin.css @@ -1,106 +1,293 @@ /* Admin-specific styles */ -.admin-button { - background-color: #dc3545; /* Reddish for danger actions */ - color: white; - border: none; - padding: 5px 10px; - margin: 5px; - border-radius: 4px; - cursor: pointer; - min-height: 44px; - display: inline-flex; - justify-content: center; - align-items: center; +.admin-container { + max-width: 1000px; + margin: 30px auto; + padding: 0 20px; } -.admin-button:hover { - background-color: #c82333; -} - -.user-item { - padding: 10px; - border-bottom: 1px solid #eee; - cursor: pointer; +.admin-header { display: flex; justify-content: space-between; align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 15px; } +.admin-header h1 { + margin: 0; + font-size: 1.8rem; + color: var(--color-text-main, #1e293b); +} + +.admin-subtitle { + margin: 5px 0 0; + color: var(--color-text-muted, #64748b); + font-size: 0.95rem; +} + +.btn-back-map { + display: inline-flex; + align-items: center; + padding: 8px 16px; + background: var(--color-primary, #0284c7); + color: white; + text-decoration: none; + border-radius: var(--radius-md, 6px); + font-weight: 500; + font-size: 0.9rem; + transition: opacity 0.2s; +} + +.btn-back-map:hover { + opacity: 0.9; +} + +/* Privacy Guarantee Banner */ +.privacy-banner { + display: flex; + align-items: flex-start; + gap: 12px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 8px; + padding: 14px 18px; + margin-bottom: 24px; + color: #166534; +} + +.privacy-icon { + font-size: 1.5rem; + line-height: 1; +} + +.privacy-text { + font-size: 0.88rem; + line-height: 1.45; +} + +/* Stats Dashboard */ +.stats-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 15px; + margin-bottom: 25px; +} + +.stat-card { + background: #fff; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 8px; + padding: 16px; + text-align: center; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + display: flex; + flex-direction: column; + justify-content: center; +} + +.stat-card h3 { + margin: 0; + font-size: 1.75rem; + color: var(--color-primary, #0284c7); +} + +.stat-card p { + margin: 4px 0 0; + color: var(--color-text-muted, #64748b); + text-transform: uppercase; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.05em; +} + +.stat-card small { + margin-top: 4px; + color: #94a3b8; + font-size: 0.75rem; +} + +/* Section styling */ .admin-section { margin-bottom: 20px; - border: 1px solid #ddd; - border-radius: 5px; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 8px; overflow: hidden; background: #fff; + box-shadow: 0 1px 2px rgba(0,0,0,0.03); } .section-title { - background-color: #f8f9fa; - padding: 10px 15px; + background-color: #f8fafc; + padding: 12px 18px; margin: 0; - font-size: 1.1rem; + font-size: 1.05rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; + user-select: none; + border-bottom: 1px solid var(--color-border, #e2e8f0); } .section-title:hover { - background-color: #e9ecef; + background-color: #f1f5f9; } .section-content { - padding: 15px; - display: block; /* Default open */ + padding: 15px 18px; + display: block; + max-height: 60vh; + overflow-y: auto; } .section-content.collapsed { display: none; } -/* Stats Dashboard */ -.stats-container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 15px; - margin-bottom: 20px; +.search-bar-wrap { + padding: 10px 18px 0; } -.stat-card { - background: #fff; - border: 1px solid #ddd; - border-radius: 5px; - padding: 20px; - text-align: center; - box-shadow: 0 2px 4px rgba(0,0,0,0.05); +.admin-search { + width: 100%; + box-sizing: border-box; + padding: 9px 12px; + border: 1px solid var(--color-border, #cbd5e1); + border-radius: 6px; + font-size: 0.9rem; + outline: none; } -.stat-card h3 { - margin: 0; - font-size: 2rem; - color: #007bff; +.admin-search:focus { + border-color: var(--color-primary, #0284c7); + box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.2); } -.stat-card p { - margin: 5px 0 0; - color: #666; +/* Item row */ +.user-item { + padding: 12px; + border-bottom: 1px solid #f1f5f9; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + border-radius: 6px; + transition: background-color 0.15s; +} + +.user-item:hover { + background-color: #f8fafc; +} + +.user-item.selected { + background-color: #eff6ff; + border-left: 3px solid var(--color-primary, #0284c7); +} + +.item-info { + flex: 1; + min-width: 0; +} + +.item-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + align-items: center; +} + +/* Badges */ +.badge { + display: inline-block; + padding: 2px 7px; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 600; text-transform: uppercase; + letter-spacing: 0.03em; + margin-left: 6px; +} + +.badge-admin { + background: #dbeafe; + color: #1d4ed8; +} + +.badge-banned { + background: #fee2e2; + color: #b91c1c; +} + +.badge-public { + background: #e0f2fe; + color: #0369a1; +} + +.badge-encrypted { + background: #fef3c7; + color: #b45309; +} + +.badge-count { + display: inline-block; + background: #ef4444; + color: white; + font-size: 0.75rem; + font-weight: 700; + padding: 1px 7px; + border-radius: 10px; + vertical-align: middle; +} + +/* Admin action buttons */ +.admin-button { + border: none; + padding: 6px 12px; + border-radius: 5px; + cursor: pointer; font-size: 0.8rem; - font-weight: bold; + font-weight: 500; + transition: opacity 0.15s; + display: inline-flex; + align-items: center; + justify-content: center; } -/* Fix for layout clipping when content is tall */ -body.page-body { - align-items: flex-start; +.admin-button:hover { + opacity: 0.85; } -.content-container { - margin-top: 50px; - margin-bottom: 50px; +.btn-danger { + background-color: #ef4444; + color: white; } -/* Scrollable lists to prevent page from expanding too much */ -.section-content { - max-height: 60vh; - overflow-y: auto; +.btn-warning { + background-color: #f59e0b; + color: white; +} + +.btn-success { + background-color: #10b981; + color: white; +} + +.btn-secondary { + background-color: #64748b; + color: white; +} + +.btn-primary { + background-color: #0284c7; + color: white; +} + +/* Empty or info text */ +.empty-text { + color: #94a3b8; + padding: 16px; + text-align: center; + font-style: italic; } \ No newline at end of file diff --git a/static/assets/js/admin.js b/static/assets/js/admin.js index 1f0a750..37ef2df 100644 --- a/static/assets/js/admin.js +++ b/static/assets/js/admin.js @@ -1,13 +1,141 @@ document.addEventListener('DOMContentLoaded', () => { loadStats(); + loadReportedNotes(); loadUsers(); + loadBannedIps(); initCollapsibles(); initSearch(); }); let selectedUserId = null; let selectedNotepadId = null; -let allUsers = []; // Store users for client-side searching +let allUsers = []; + +async function loadStats() { + const container = document.getElementById('admin-stats'); + const reportedBadge = document.getElementById('reported-badge'); + try { + const res = await fetch('/admin/stats'); + if (res.status === 403) { + window.location.href = '/login.html'; + return; + } + const stats = await res.json(); + + container.innerHTML = ` +
+

${stats.user_count}

+

Users

+ ${stats.banned_user_count} banned +
+
+

${stats.notepad_count}

+

Notepads

+ ${stats.public_notepad_count} Public Β· ${stats.private_notepad_count} Encrypted +
+
+

${stats.note_count}

+

Total Notes

+ ${stats.public_note_count} Public Β· ${stats.encrypted_note_count} Encrypted +
+
+

${stats.reported_note_count}

+

Reported Notes

+ ${stats.reported_note_count > 0 ? 'Requires attention' : 'All clear'} +
+ `; + + if (reportedBadge) { + reportedBadge.textContent = stats.reported_note_count; + reportedBadge.style.display = stats.reported_note_count > 0 ? 'inline-block' : 'none'; + } + } catch (err) { + console.error('Error loading stats:', err); + } +} + +async function loadReportedNotes() { + const listDiv = document.getElementById('reported-list'); + listDiv.innerHTML = '

Loading reported notes...

'; + + try { + const res = await fetch('/admin/reported_notes'); + if (!res.ok) throw new Error('Failed to load reported notes'); + const notes = await res.json(); + + listDiv.innerHTML = ''; + if (notes.length === 0) { + listDiv.innerHTML = '

No notes reported. Public content is clean.

'; + return; + } + + notes.forEach(note => { + const item = document.createElement('div'); + item.className = 'user-item'; + + const info = document.createElement('div'); + info.className = 'item-info'; + info.innerHTML = ` +
+ 🚩 ${escapeHtml(note.title)} + Pad: ${escapeHtml(note.notepad_name)} +
+
+ ${escapeHtml(note.content)} +
+
+ Reported Note #${note.id} Β· Author: ${escapeHtml(note.author_email || 'Unknown')} Β· ${new Date(note.created_at).toLocaleString()} +
+ `; + + const actions = document.createElement('div'); + actions.className = 'item-actions'; + + const restoreBtn = document.createElement('button'); + restoreBtn.className = 'admin-button btn-success'; + restoreBtn.textContent = 'βœ“ Restore Note'; + restoreBtn.onclick = async () => { + if (confirm('Restore this note? It will become visible on the public map again.')) { + await restoreNote(note.id); + } + }; + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'admin-button btn-danger'; + deleteBtn.textContent = 'πŸ—‘οΈ Delete Note'; + deleteBtn.onclick = async () => { + if (confirm('Permanently delete this reported note?')) { + await deleteNote(note.id); + loadReportedNotes(); + loadStats(); + } + }; + + actions.appendChild(restoreBtn); + actions.appendChild(deleteBtn); + + item.appendChild(info); + item.appendChild(actions); + listDiv.appendChild(item); + }); + } catch (err) { + console.error(err); + listDiv.innerHTML = '

Error loading reported notes.

'; + } +} + +async function restoreNote(noteId) { + try { + const res = await fetch(`/admin/notes/${noteId}/restore`, { method: 'POST' }); + const data = await res.json(); + alert(data.message || 'Note restored'); + loadReportedNotes(); + loadStats(); + } catch (err) { + console.error(err); + alert('Error restoring note'); + } +} async function loadUsers() { const userListDiv = document.getElementById('user-list'); @@ -32,32 +160,76 @@ function renderUsers(users) { userListDiv.innerHTML = ''; if (users.length === 0) { - userListDiv.innerHTML = '

No users found.

'; + userListDiv.innerHTML = '

No users found.

'; return; } users.forEach(user => { const userDiv = document.createElement('div'); userDiv.className = 'user-item'; - - // Handle "Unowned" user display specifically or generic - const ipDisplay = user.id === 0 ? '' : `
IP: ${escapeHtml(user.last_ip || 'Unknown')}`; - + if (selectedUserId === user.id) userDiv.classList.add('selected'); + const infoDiv = document.createElement('div'); - infoDiv.innerHTML = `${escapeHtml(user.email)} (${escapeHtml(user.first_name)} ${escapeHtml(user.last_name)}) ${ipDisplay}`; + infoDiv.className = 'item-info'; + + const adminBadge = user.is_admin ? 'Admin' : ''; + const bannedBadge = user.is_banned ? 'Banned' : ''; + const ipDisplay = user.id === 0 ? '' : `
IP: ${escapeHtml(user.last_ip || 'No IP recorded')}`; + + infoDiv.innerHTML = `${escapeHtml(user.email)} ${escapeHtml(user.first_name ? `(${user.first_name} ${user.last_name})` : '')} ${adminBadge}${bannedBadge}${ipDisplay}`; const actionsDiv = document.createElement('div'); + actionsDiv.className = 'item-actions'; - // Ban IP Button (only for real users with IPs) - if (user.last_ip && user.id !== 0) { - const banIpBtn = document.createElement('button'); - banIpBtn.className = 'admin-button'; - banIpBtn.textContent = 'Ban IP'; - banIpBtn.onclick = (e) => { + if (user.id !== 0) { + // Ban / Unban User + const banUserBtn = document.createElement('button'); + banUserBtn.className = `admin-button ${user.is_banned ? 'btn-success' : 'btn-warning'}`; + banUserBtn.textContent = user.is_banned ? 'Unban Account' : 'Ban Account'; + banUserBtn.onclick = async (e) => { e.stopPropagation(); - banUserIp(user.id); + const action = user.is_banned ? 'unban' : 'ban'; + if (confirm(`Are you sure you want to ${action} user ${user.email}?`)) { + await toggleUserBan(user.id, user.is_banned); + } }; - actionsDiv.appendChild(banIpBtn); + actionsDiv.appendChild(banUserBtn); + + // Ban IP Button + if (user.last_ip) { + const banIpBtn = document.createElement('button'); + banIpBtn.className = 'admin-button btn-secondary'; + banIpBtn.textContent = 'Ban IP'; + banIpBtn.onclick = (e) => { + e.stopPropagation(); + banUserIp(user.id); + }; + actionsDiv.appendChild(banIpBtn); + } + + // Toggle Admin Button + const toggleAdminBtn = document.createElement('button'); + toggleAdminBtn.className = 'admin-button btn-primary'; + toggleAdminBtn.textContent = user.is_admin ? 'Demote Admin' : 'Make Admin'; + toggleAdminBtn.onclick = async (e) => { + e.stopPropagation(); + if (confirm(`${user.is_admin ? 'Demote' : 'Promote'} ${user.email} to Admin?`)) { + await toggleAdmin(user.id); + } + }; + actionsDiv.appendChild(toggleAdminBtn); + + // Delete User Button + const deleteUserBtn = document.createElement('button'); + deleteUserBtn.className = 'admin-button btn-danger'; + deleteUserBtn.textContent = 'Delete User'; + deleteUserBtn.onclick = async (e) => { + e.stopPropagation(); + if (confirm(`Permanently delete user ${user.email} and all their notepads/notes? This cannot be undone.`)) { + await deleteUser(user.id); + } + }; + actionsDiv.appendChild(deleteUserBtn); } userDiv.appendChild(infoDiv); @@ -65,28 +237,132 @@ function renderUsers(users) { userDiv.addEventListener('click', () => { selectedUserId = user.id; - loadNotepads(user.id); + document.querySelectorAll('#user-list .user-item').forEach(el => el.classList.remove('selected')); + userDiv.classList.add('selected'); + loadNotepads(user.id, user.email); expandSection('notepad-list'); }); + userListDiv.appendChild(userDiv); }); } -function initSearch() { - const searchInput = document.getElementById('user-search'); - searchInput.addEventListener('input', (e) => { - const term = e.target.value.toLowerCase(); - const filtered = allUsers.filter(user => - (user.email && user.email.toLowerCase().includes(term)) || - (user.first_name && user.first_name.toLowerCase().includes(term)) || - (user.last_name && user.last_name.toLowerCase().includes(term)) - ); - renderUsers(filtered); - }); +async function toggleUserBan(userId, isCurrentlyBanned) { + try { + const endpoint = isCurrentlyBanned ? `/admin/users/${userId}/unban` : `/admin/users/${userId}/ban`; + const res = await fetch(endpoint, { method: 'POST' }); + const data = await res.json(); + alert(data.message || 'User status updated'); + loadUsers(); + loadStats(); + } catch (err) { + console.error(err); + alert('Error updating user ban status'); + } } -async function loadNotepads(userId) { +async function toggleAdmin(userId) { + try { + const res = await fetch(`/admin/users/${userId}/toggle_admin`, { method: 'POST' }); + const data = await res.json(); + if (res.ok) { + alert(data.message); + loadUsers(); + } else { + alert(data.error || 'Failed to update admin status'); + } + } catch (err) { + console.error(err); + alert('Error updating admin status'); + } +} + +async function deleteUser(userId) { + try { + const res = await fetch(`/admin/users/${userId}`, { method: 'DELETE' }); + const data = await res.json(); + alert(data.message || 'User deleted'); + selectedUserId = null; + loadUsers(); + loadStats(); + document.getElementById('notepad-list').innerHTML = '

Select a user above to see their notepads.

'; + document.getElementById('note-list').innerHTML = '

Select a notepad to see its notes.

'; + } catch (err) { + console.error(err); + alert('Error deleting user'); + } +} + +async function loadBannedIps() { + const listDiv = document.getElementById('banned-ip-list'); + try { + const res = await fetch('/admin/banned_ips'); + const ips = await res.json(); + + listDiv.innerHTML = ''; + if (ips.length === 0) { + listDiv.innerHTML = '

No IP addresses currently banned.

'; + return; + } + + ips.forEach(item => { + const row = document.createElement('div'); + row.className = 'user-item'; + + const info = document.createElement('div'); + info.className = 'item-info'; + info.innerHTML = `🚫 ${escapeHtml(item.ip_address)} · Banned ${new Date(item.created_at).toLocaleString()}`; + + const unbanBtn = document.createElement('button'); + unbanBtn.className = 'admin-button btn-success'; + unbanBtn.textContent = 'Unban IP'; + unbanBtn.onclick = async () => { + if (confirm(`Unban IP address ${item.ip_address}?`)) { + await unbanIp(item.id); + } + }; + + row.appendChild(info); + row.appendChild(unbanBtn); + listDiv.appendChild(row); + }); + } catch (err) { + console.error(err); + listDiv.innerHTML = '

Error loading banned IPs.

'; + } +} + +async function unbanIp(ipId) { + try { + const res = await fetch(`/admin/banned_ips/${ipId}`, { method: 'DELETE' }); + const data = await res.json(); + alert(data.message || 'IP unbanned'); + loadBannedIps(); + } catch (err) { + console.error(err); + alert('Error unbanning IP'); + } +} + +async function banUserIp(userId) { + if(!confirm("Are you sure you want to ban this IP address? This will block registration and login from this IP.")) return; + try { + const res = await fetch(`/admin/users/${userId}/ban_ip`, { method: 'POST' }); + const data = await res.json(); + alert(data.message || data.error); + loadBannedIps(); + } catch (err) { + console.error(err); + alert("Error banning IP"); + } +} + +async function loadNotepads(userId, userEmail) { const notepadListDiv = document.getElementById('notepad-list'); + const headerTitle = document.getElementById('notepads-header-title'); + if (headerTitle) { + headerTitle.textContent = `πŸ“ Notepads for: ${userEmail}`; + } notepadListDiv.innerHTML = '

Loading notepads...

'; try { @@ -94,21 +370,33 @@ async function loadNotepads(userId) { const notepads = await res.json(); notepadListDiv.innerHTML = ''; + if (notepads.length === 0) { + notepadListDiv.innerHTML = '

No notepads found for this user.

'; + return; + } + notepads.forEach(notepad => { const notepadDiv = document.createElement('div'); notepadDiv.className = 'user-item'; + if (selectedNotepadId === notepad.id) notepadDiv.classList.add('selected'); + const badge = notepad.is_public ? + '🌐 Public' : + 'πŸ”’ Zero-Knowledge Encrypted'; + const infoDiv = document.createElement('div'); - infoDiv.innerHTML = `${escapeHtml(notepad.name)} (Public: ${notepad.is_public})`; + infoDiv.className = 'item-info'; + infoDiv.innerHTML = `${escapeHtml(notepad.name)} ${badge}
Pad ID: #${notepad.id} Β· Notes: ${notepad.note_count || 0}`; const deleteBtn = document.createElement('button'); - deleteBtn.className = 'admin-button'; + deleteBtn.className = 'admin-button btn-danger'; deleteBtn.textContent = 'Delete Pad'; deleteBtn.onclick = async (e) => { e.stopPropagation(); - if(confirm('Delete this entire notepad and all its notes?')) { + if(confirm(`Delete entire notepad "${notepad.name}" and all its notes?`)) { await deleteNotepad(notepad.id); - loadNotepads(userId); + loadNotepads(userId, userEmail); + loadStats(); } }; @@ -117,7 +405,9 @@ async function loadNotepads(userId) { notepadDiv.addEventListener('click', () => { selectedNotepadId = notepad.id; - loadNotes(notepad.id); + document.querySelectorAll('#notepad-list .user-item').forEach(el => el.classList.remove('selected')); + notepadDiv.classList.add('selected'); + loadNotes(notepad.id, notepad.name, notepad.is_public); expandSection('note-list'); }); notepadListDiv.appendChild(notepadDiv); @@ -128,8 +418,12 @@ async function loadNotepads(userId) { } } -async function loadNotes(notepadId) { +async function loadNotes(notepadId, notepadName, isPublic) { const noteListDiv = document.getElementById('note-list'); + const headerTitle = document.getElementById('notes-header-title'); + if (headerTitle) { + headerTitle.textContent = `πŸ“ Notes in: ${notepadName}`; + } noteListDiv.innerHTML = '

Loading notes...

'; try { @@ -137,28 +431,43 @@ async function loadNotes(notepadId) { const notes = await res.json(); noteListDiv.innerHTML = ''; + if (notes.length === 0) { + noteListDiv.innerHTML = '

This notepad is empty.

'; + return; + } + notes.forEach(note => { const noteDiv = document.createElement('div'); noteDiv.className = 'user-item'; const infoDiv = document.createElement('div'); + infoDiv.className = 'item-info'; + if (note.is_encrypted) { - const preview = note.content ? (note.content.length > 40 ? note.content.substring(0, 40) + '...' : note.content) : ''; - infoDiv.innerHTML = `πŸ”’ [Encrypted Private Note]: [Zero-Knowledge at Rest: ${escapeHtml(preview)}]`; + const preview = note.content ? (note.content.length > 50 ? note.content.substring(0, 50) + '...' : note.content) : ''; + infoDiv.innerHTML = ` +
πŸ”’ [Encrypted Private Note #${note.id}] E2EE Protected
+
Zero-Knowledge Ciphertext: ${escapeHtml(preview)}
+ Created: ${new Date(note.created_at).toLocaleString()} Β· Lat/Lng at Rest: Redacted [0, 0] + `; } else { - infoDiv.innerHTML = `${escapeHtml(note.title)}: ${escapeHtml(note.content)}`; + infoDiv.innerHTML = ` +
🌐 ${escapeHtml(note.title)} Public Note #${note.id}
+
${escapeHtml(note.content)}
+ Location: (${note.lat}, ${note.lng}) Β· Created: ${new Date(note.created_at).toLocaleString()} + `; } noteDiv.appendChild(infoDiv); - // Add delete button for each note const deleteButton = document.createElement('button'); - deleteButton.className = 'admin-button'; + deleteButton.className = 'admin-button btn-danger'; deleteButton.textContent = 'Delete Note'; deleteButton.addEventListener('click', async (e) => { - e.stopPropagation(); // Prevent selecting the note - if (confirm('Are you sure you want to delete this note?')) { + e.stopPropagation(); + if (confirm('Permanently delete this note?')) { await deleteNote(note.id); - loadNotes(notepadId); // Refresh notes after deletion + loadNotes(notepadId, notepadName, isPublic); + loadStats(); } }); noteDiv.appendChild(deleteButton); @@ -173,10 +482,7 @@ async function loadNotes(notepadId) { async function deleteNote(noteId) { try { - const res = await fetch(`/admin/notes/${noteId}`, { - method: 'DELETE' - }); - + const res = await fetch(`/admin/notes/${noteId}`, { method: 'DELETE' }); if (res.ok) { alert('Note deleted successfully.'); } else { @@ -190,11 +496,10 @@ async function deleteNote(noteId) { async function deleteNotepad(notepadId) { try { - const res = await fetch(`/admin/notepads/${notepadId}`, { - method: 'DELETE' - }); + const res = await fetch(`/admin/notepads/${notepadId}`, { method: 'DELETE' }); if (res.ok) { alert('Notepad deleted.'); + document.getElementById('note-list').innerHTML = '

Select a notepad to see its notes.

'; } else { alert('Failed to delete notepad.'); } @@ -203,52 +508,45 @@ async function deleteNotepad(notepadId) { } } -async function banUserIp(userId) { - if(!confirm("Are you sure you want to ban this IP address? This will block all future access from this IP.")) return; - try { - const res = await fetch(`/admin/users/${userId}/ban_ip`, { method: 'POST' }); - const data = await res.json(); - alert(data.message || data.error); - } catch (err) { - console.error(err); - alert("Error banning IP"); - } -} - -async function loadStats() { - const container = document.getElementById('admin-stats'); - try { - const res = await fetch('/admin/stats'); - if (res.status === 403) { - window.location.href = '/login.html'; - return; - } - const stats = await res.json(); - - container.innerHTML = ` -

${stats.user_count}

Users

-

${stats.notepad_count}

Notepads

-

${stats.note_count}

Notes

- `; - } catch (err) { - console.error(err); - container.innerHTML = ''; - } +function initSearch() { + const searchInput = document.getElementById('user-search'); + if (!searchInput) return; + searchInput.addEventListener('input', (e) => { + const term = e.target.value.toLowerCase().trim(); + const filtered = allUsers.filter(user => + (user.email && user.email.toLowerCase().includes(term)) || + (user.first_name && user.first_name.toLowerCase().includes(term)) || + (user.last_name && user.last_name.toLowerCase().includes(term)) || + (user.last_ip && user.last_ip.toLowerCase().includes(term)) + ); + renderUsers(filtered); + }); } function initCollapsibles() { const headers = document.querySelectorAll('.section-title'); headers.forEach(header => { header.addEventListener('click', () => { - const content = header.nextElementSibling; - content.classList.toggle('collapsed'); + const section = header.parentElement; + const content = section.querySelector('.section-content'); + if (content) { + content.classList.toggle('collapsed'); + const icon = header.querySelector('.toggle-icon'); + if (icon) { + icon.textContent = content.classList.contains('collapsed') ? 'β–Ά' : 'β–Ό'; + } + } }); }); } function expandSection(id) { const el = document.getElementById(id); - if(el) el.classList.remove('collapsed'); + if (el) { + el.classList.remove('collapsed'); + const header = el.parentElement.querySelector('.section-title .toggle-icon'); + if (header) header.textContent = 'β–Ό'; + } } function escapeHtml(text) { diff --git a/static/assets/js/index.js b/static/assets/js/index.js index d77a065..b9ff6cf 100644 --- a/static/assets/js/index.js +++ b/static/assets/js/index.js @@ -325,7 +325,7 @@ window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, owne if (canWrite !== undefined) { window.SpotNotes.activePadCanWrite = canWrite; } else if (padName !== undefined) { - window.SpotNotes.activePadCanWrite = (window.SpotNotes.currentUser && ownerId === window.SpotNotes.currentUser.id); + window.SpotNotes.activePadCanWrite = (window.SpotNotes.currentUser && (isPublic || ownerId === window.SpotNotes.currentUser.id)); } // Check if URL hash has #key=... for link-shared private notes/pads @@ -353,7 +353,8 @@ window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, owne // Update Sidebar Header const header = document.querySelector('.sidebar-header'); if (header) { - const addNoteBtn = window.SpotNotes.activePadCanWrite ? `` : ''; + const canAddNote = window.SpotNotes.activePadCanWrite || (window.SpotNotes.currentUser && isPublic); + const addNoteBtn = canAddNote ? `` : ''; const manageShareBtn = isOwner ? `` : ''; const exportBtn = ``; const shareBtn = ``; @@ -912,8 +913,13 @@ window.SpotNotes.renderActiveNotesList = function(filterText = '') { const item = document.createElement('div'); item.className = 'note-card'; + const isAuthor = window.SpotNotes.currentUser && (note.user_id && note.user_id === window.SpotNotes.currentUser.id); + const isPadOwner = window.SpotNotes.currentUser && (window.SpotNotes.activePadOwnerId === window.SpotNotes.currentUser.id); + const isAdmin = window.SpotNotes.currentUser && window.SpotNotes.currentUser.is_admin; + const canModify = isAuthor || isPadOwner || isAdmin || (window.SpotNotes.activePadCanWrite && !window.SpotNotes.activePadIsPublic); + let actionBtns = ''; - if (window.SpotNotes.activePadCanWrite) { + if (canModify) { const escapedTitle = window.SpotNotes.escapeHtml(note.title).replace(/'/g, "\\'").replace(/"/g, '"'); const escapedContent = window.SpotNotes.escapeHtml(note.content).replace(/'/g, "\\'").replace(/"/g, '"').replace(/\n/g, '\\n'); actionBtns = ` diff --git a/static/assets/js/map.js b/static/assets/js/map.js index 5ac481d..728af60 100644 --- a/static/assets/js/map.js +++ b/static/assets/js/map.js @@ -101,11 +101,13 @@ window.SpotNotes.renderMapMarkers = function() { let actionBtns = ''; // Determine permissions + const isAuthor = window.SpotNotes.currentUser && (note.user_id && note.user_id === window.SpotNotes.currentUser.id); const isOwner = window.SpotNotes.currentUser && (note.owner_id ? note.owner_id === window.SpotNotes.currentUser.id : window.SpotNotes.activePadOwnerId === window.SpotNotes.currentUser.id); + const isAdmin = window.SpotNotes.currentUser && window.SpotNotes.currentUser.is_admin; const isPublic = note.is_public !== undefined ? note.is_public : window.SpotNotes.activePadIsPublic; - const canWrite = (note.notepad && note.notepad.can_write !== undefined) ? note.notepad.can_write : window.SpotNotes.activePadCanWrite; + const canModify = isAuthor || isOwner || isAdmin || ((note.notepad && note.notepad.can_write !== undefined ? note.notepad.can_write : window.SpotNotes.activePadCanWrite) && !isPublic); - if (canWrite) { + if (canModify) { const escapedTitle = window.SpotNotes.escapeHtml(note.title).replace(/'/g, "\\'").replace(/"/g, '"'); const escapedContent = window.SpotNotes.escapeHtml(note.content).replace(/'/g, "\\'").replace(/"/g, '"').replace(/\n/g, '\\n'); actionBtns = `
`;