modified: blueprints/admin.py

This commit is contained in:
Ben Miller 2026-10-06 14:32:24 -05:00
parent 97ca1925d2
commit 716ae7e3b2
9 changed files with 780 additions and 269 deletions

View file

@ -24,8 +24,14 @@ def before_request():
def get_stats(): def get_stats():
return jsonify({ return jsonify({
'user_count': User.query.count(), 'user_count': User.query.count(),
'banned_user_count': User.query.filter_by(is_banned=True).count(),
'notepad_count': Notepad.query.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']) @admin_bp.route('/users', methods=['GET'])
@ -133,4 +139,44 @@ def ban_user_ip(user_id):
db.session.add(banned) db.session.add(banned)
db.session.commit() db.session.commit()
return jsonify({'message': f'IP {user.last_ip} banned'}), 200 return jsonify({'message': f'IP {user.last_ip} banned'}), 200
@admin_bp.route('/users/<int:user_id>/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/<int:note_id>/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/<int:ip_id>', 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

View file

@ -41,12 +41,30 @@ def check_pad_read_permission(notepad, user_id, token=None):
def check_pad_write_permission(notepad, user_id): def check_pad_write_permission(notepad, user_id):
if not user_id: if not user_id:
return False 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: if notepad.user_id == user_id:
return True return True
# Check for collaborator with 'write' permission # Check for collaborator with 'write' permission
collab = Collaborator.query.filter_by(user_id=user_id, notepad_id=notepad.id, permission='write').first() collab = Collaborator.query.filter_by(user_id=user_id, notepad_id=notepad.id, permission='write').first()
return collab is not None 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']) @api_bp.route('/users/lookup', methods=['GET'])
def lookup_user(): def lookup_user():
user_id = session.get('user_id') user_id = session.get('user_id')
@ -107,7 +125,7 @@ def get_notepads():
if user_id: if user_id:
if pad.user_id == user_id: if pad.user_id == user_id:
p_dict['share_token'] = get_share_token('notepad', pad.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: else:
p_dict['can_write'] = False p_dict['can_write'] = False
results.append(p_dict) results.append(p_dict)
@ -197,7 +215,7 @@ def get_note(note_id):
return jsonify({'error': 'Unauthorized'}), 403 return jsonify({'error': 'Unauthorized'}), 403
n_dict = note.to_dict() 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() pad_dict = notepad.to_dict()
# Redact name if private and not owner # Redact name if private and not owner
@ -287,7 +305,8 @@ def create_note():
lat=0.0, lat=0.0,
lng=0.0, lng=0.0,
is_encrypted=True, is_encrypted=True,
notepad_id=notepad_id notepad_id=notepad_id,
user_id=user_id
) )
else: else:
new_note = Note( new_note = Note(
@ -296,7 +315,8 @@ def create_note():
lat=float(data.get('lat', 0.0)), lat=float(data.get('lat', 0.0)),
lng=float(data.get('lng', 0.0)), lng=float(data.get('lng', 0.0)),
is_encrypted=False, is_encrypted=False,
notepad_id=notepad_id notepad_id=notepad_id,
user_id=user_id
) )
db.session.add(new_note) db.session.add(new_note)
db.session.commit() db.session.commit()
@ -309,7 +329,7 @@ def update_note(note_id):
return jsonify({'error': 'Unauthorized'}), 401 return jsonify({'error': 'Unauthorized'}), 401
note = Note.query.get_or_404(note_id) note = Note.query.get_or_404(note_id)
notepad = Notepad.query.get(note.notepad_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 return jsonify({'error': 'Forbidden'}), 403
data = request.get_json() data = request.get_json()
note.title = data.get('title', note.title) note.title = data.get('title', note.title)
@ -502,7 +522,7 @@ def delete_note(note_id):
note = Note.query.get_or_404(note_id) note = Note.query.get_or_404(note_id)
notepad = Notepad.query.get(note.notepad_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 return jsonify({'error': 'Forbidden'}), 403
db.session.delete(note) db.session.delete(note)

View file

@ -11,6 +11,9 @@ class Note(db.Model):
is_hidden = db.Column(db.Boolean, default=False) is_hidden = db.Column(db.Boolean, default=False)
is_encrypted = db.Column(db.Boolean, default=False, nullable=False) is_encrypted = db.Column(db.Boolean, default=False, nullable=False)
notepad_id = db.Column(db.Integer, db.ForeignKey('notepad.id'), 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): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
@ -23,5 +26,6 @@ class Note(db.Model):
'lat': self.lat, 'lat': self.lat,
'lng': self.lng, 'lng': self.lng,
'is_encrypted': self.is_encrypted, 'is_encrypted': self.is_encrypted,
'user_id': self.user_id,
'created_at': self.created_at.isoformat() 'created_at': self.created_at.isoformat()
} }

View file

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

View file

@ -3,42 +3,91 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel - SpotNotes.app</title> <title>Admin Control Panel - SpotNotes.app</title>
<link rel="stylesheet" href="/assets/css/base.css"> <link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="/assets/css/auth.css"> <link rel="stylesheet" href="/assets/css/auth.css">
<link rel="stylesheet" href="/assets/css/admin.css"> <link rel="stylesheet" href="/assets/css/admin.css">
<link rel="icon" href="favicon.ico" type="image/x-icon" /> <link rel="icon" href="favicon.ico" type="image/x-icon" />
</head> </head>
<body class="page-body"> <body class="page-body">
<div class="content-container"> <div class="content-container admin-container">
<h1>Admin Panel</h1> <div class="admin-header">
<div>
<h1>⚙️ Admin Control Panel</h1>
<p class="admin-subtitle">System administration, security, capacity management, and public content moderation.</p>
</div>
<a href="/" class="btn-back-map">🗺️ Back to Map</a>
</div>
<!-- Zero-Knowledge Notice -->
<div class="privacy-banner">
<span class="privacy-icon">🔒</span>
<div class="privacy-text">
<strong>Zero-Knowledge E2EE Architecture:</strong>
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.
</div>
</div>
<!-- Stats Dashboard --> <!-- Stats Dashboard -->
<div id="admin-stats" class="stats-container"> <div id="admin-stats" class="stats-container">
<!-- Stats injected by JS --> <!-- Stats injected by JS -->
</div> </div>
<div class="admin-section"> <!-- Moderation Queue -->
<div class="admin-section" id="section-reported">
<h2 class="section-title"> <h2 class="section-title">
Users <span class="toggle-icon">▼</span> <span>🚩 Moderation Queue: Reported Public Notes <span id="reported-badge" class="badge-count">0</span></span>
<span class="toggle-icon">▼</span>
</h2> </h2>
<input type="text" id="user-search" class="admin-search" placeholder="Search users by email or name..." style="margin: 10px; width: 95%; padding: 8px;"> <div id="reported-list" class="section-content">
<p>Loading reported notes...</p>
</div>
</div>
<!-- User Management -->
<div class="admin-section" id="section-users">
<h2 class="section-title">
<span>👥 User Accounts & Access Control</span>
<span class="toggle-icon">▼</span>
</h2>
<div class="search-bar-wrap">
<input type="text" id="user-search" class="admin-search" placeholder="Search users by email, name, or IP...">
</div>
<div id="user-list" class="section-content"> <div id="user-list" class="section-content">
<p>Loading users...</p> <p>Loading users...</p>
</div> </div>
</div> </div>
<div class="admin-section"> <!-- Banned IPs -->
<h2 class="section-title">Notepads <span class="toggle-icon">▼</span></h2> <div class="admin-section" id="section-banned-ips">
<div id="notepad-list" class="section-content"> <h2 class="section-title">
<p>Select a user to see their notepads.</p> <span>🚫 Banned IP Addresses</span>
<span class="toggle-icon">▼</span>
</h2>
<div id="banned-ip-list" class="section-content collapsed">
<p>Loading banned IPs...</p>
</div> </div>
</div> </div>
<div class="admin-section"> <!-- Notepads Explorer -->
<h2 class="section-title">Notes <span class="toggle-icon">▼</span></h2> <div class="admin-section" id="section-notepads">
<h2 class="section-title">
<span id="notepads-header-title">📁 Notepads Explorer</span>
<span class="toggle-icon">▼</span>
</h2>
<div id="notepad-list" class="section-content">
<p>Select a user above to inspect their notepads, or select <em>System / Public Notepads</em>.</p>
</div>
</div>
<!-- Notes Explorer -->
<div class="admin-section" id="section-notes">
<h2 class="section-title">
<span id="notes-header-title">📝 Notes Explorer</span>
<span class="toggle-icon">▼</span>
</h2>
<div id="note-list" class="section-content"> <div id="note-list" class="section-content">
<p>Select a notepad to see its notes.</p> <p>Select a notepad above to view its notes and storage details.</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,106 +1,293 @@
/* Admin-specific styles */ /* Admin-specific styles */
.admin-button { .admin-container {
background-color: #dc3545; /* Reddish for danger actions */ max-width: 1000px;
color: white; margin: 30px auto;
border: none; padding: 0 20px;
padding: 5px 10px;
margin: 5px;
border-radius: 4px;
cursor: pointer;
min-height: 44px;
display: inline-flex;
justify-content: center;
align-items: center;
} }
.admin-button:hover { .admin-header {
background-color: #c82333;
}
.user-item {
padding: 10px;
border-bottom: 1px solid #eee;
cursor: pointer;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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 { .admin-section {
margin-bottom: 20px; margin-bottom: 20px;
border: 1px solid #ddd; border: 1px solid var(--color-border, #e2e8f0);
border-radius: 5px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
} }
.section-title { .section-title {
background-color: #f8f9fa; background-color: #f8fafc;
padding: 10px 15px; padding: 12px 18px;
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1.05rem;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
user-select: none;
border-bottom: 1px solid var(--color-border, #e2e8f0);
} }
.section-title:hover { .section-title:hover {
background-color: #e9ecef; background-color: #f1f5f9;
} }
.section-content { .section-content {
padding: 15px; padding: 15px 18px;
display: block; /* Default open */ display: block;
max-height: 60vh;
overflow-y: auto;
} }
.section-content.collapsed { .section-content.collapsed {
display: none; display: none;
} }
/* Stats Dashboard */ .search-bar-wrap {
.stats-container { padding: 10px 18px 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 20px;
} }
.stat-card { .admin-search {
background: #fff; width: 100%;
border: 1px solid #ddd; box-sizing: border-box;
border-radius: 5px; padding: 9px 12px;
padding: 20px; border: 1px solid var(--color-border, #cbd5e1);
text-align: center; border-radius: 6px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05); font-size: 0.9rem;
outline: none;
} }
.stat-card h3 { .admin-search:focus {
margin: 0; border-color: var(--color-primary, #0284c7);
font-size: 2rem; box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.2);
color: #007bff;
} }
.stat-card p { /* Item row */
margin: 5px 0 0; .user-item {
color: #666; 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; 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-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 */ .admin-button:hover {
body.page-body { opacity: 0.85;
align-items: flex-start;
} }
.content-container { .btn-danger {
margin-top: 50px; background-color: #ef4444;
margin-bottom: 50px; color: white;
} }
/* Scrollable lists to prevent page from expanding too much */ .btn-warning {
.section-content { background-color: #f59e0b;
max-height: 60vh; color: white;
overflow-y: auto; }
.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;
} }

View file

@ -1,13 +1,141 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadStats(); loadStats();
loadReportedNotes();
loadUsers(); loadUsers();
loadBannedIps();
initCollapsibles(); initCollapsibles();
initSearch(); initSearch();
}); });
let selectedUserId = null; let selectedUserId = null;
let selectedNotepadId = 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 = `
<div class="stat-card">
<h3>${stats.user_count}</h3>
<p>Users</p>
<small>${stats.banned_user_count} banned</small>
</div>
<div class="stat-card">
<h3>${stats.notepad_count}</h3>
<p>Notepads</p>
<small>${stats.public_notepad_count} Public · ${stats.private_notepad_count} Encrypted</small>
</div>
<div class="stat-card">
<h3>${stats.note_count}</h3>
<p>Total Notes</p>
<small>${stats.public_note_count} Public · ${stats.encrypted_note_count} Encrypted</small>
</div>
<div class="stat-card">
<h3 style="color:${stats.reported_note_count > 0 ? '#ef4444' : '#10b981'};">${stats.reported_note_count}</h3>
<p>Reported Notes</p>
<small>${stats.reported_note_count > 0 ? 'Requires attention' : 'All clear'}</small>
</div>
`;
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 = '<p>Loading reported notes...</p>';
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 = '<p class="empty-text">No notes reported. Public content is clean.</p>';
return;
}
notes.forEach(note => {
const item = document.createElement('div');
item.className = 'user-item';
const info = document.createElement('div');
info.className = 'item-info';
info.innerHTML = `
<div>
<strong>🚩 ${escapeHtml(note.title)}</strong>
<span class="badge badge-public">Pad: ${escapeHtml(note.notepad_name)}</span>
</div>
<div style="font-size:0.88rem; color:#475569; margin:4px 0;">
${escapeHtml(note.content)}
</div>
<div style="font-size:0.75rem; color:#94a3b8;">
Reported Note #${note.id} · Author: ${escapeHtml(note.author_email || 'Unknown')} · ${new Date(note.created_at).toLocaleString()}
</div>
`;
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 = '<p style="color:red;">Error loading reported notes.</p>';
}
}
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() { async function loadUsers() {
const userListDiv = document.getElementById('user-list'); const userListDiv = document.getElementById('user-list');
@ -32,32 +160,76 @@ function renderUsers(users) {
userListDiv.innerHTML = ''; userListDiv.innerHTML = '';
if (users.length === 0) { if (users.length === 0) {
userListDiv.innerHTML = '<p style="padding:10px;">No users found.</p>'; userListDiv.innerHTML = '<p class="empty-text">No users found.</p>';
return; return;
} }
users.forEach(user => { users.forEach(user => {
const userDiv = document.createElement('div'); const userDiv = document.createElement('div');
userDiv.className = 'user-item'; userDiv.className = 'user-item';
if (selectedUserId === user.id) userDiv.classList.add('selected');
// Handle "Unowned" user display specifically or generic
const ipDisplay = user.id === 0 ? '' : `<br> <small>IP: ${escapeHtml(user.last_ip || 'Unknown')}</small>`;
const infoDiv = document.createElement('div'); const infoDiv = document.createElement('div');
infoDiv.innerHTML = `<strong>${escapeHtml(user.email)}</strong> (${escapeHtml(user.first_name)} ${escapeHtml(user.last_name)}) ${ipDisplay}`; infoDiv.className = 'item-info';
const adminBadge = user.is_admin ? '<span class="badge badge-admin">Admin</span>' : '';
const bannedBadge = user.is_banned ? '<span class="badge badge-banned">Banned</span>' : '';
const ipDisplay = user.id === 0 ? '' : `<br><small style="color:#64748b;">IP: ${escapeHtml(user.last_ip || 'No IP recorded')}</small>`;
infoDiv.innerHTML = `<strong>${escapeHtml(user.email)}</strong> ${escapeHtml(user.first_name ? `(${user.first_name} ${user.last_name})` : '')} ${adminBadge}${bannedBadge}${ipDisplay}`;
const actionsDiv = document.createElement('div'); const actionsDiv = document.createElement('div');
actionsDiv.className = 'item-actions';
// Ban IP Button (only for real users with IPs) if (user.id !== 0) {
if (user.last_ip && user.id !== 0) { // Ban / Unban User
const banIpBtn = document.createElement('button'); const banUserBtn = document.createElement('button');
banIpBtn.className = 'admin-button'; banUserBtn.className = `admin-button ${user.is_banned ? 'btn-success' : 'btn-warning'}`;
banIpBtn.textContent = 'Ban IP'; banUserBtn.textContent = user.is_banned ? 'Unban Account' : 'Ban Account';
banIpBtn.onclick = (e) => { banUserBtn.onclick = async (e) => {
e.stopPropagation(); 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); userDiv.appendChild(infoDiv);
@ -65,28 +237,132 @@ function renderUsers(users) {
userDiv.addEventListener('click', () => { userDiv.addEventListener('click', () => {
selectedUserId = user.id; 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'); expandSection('notepad-list');
}); });
userListDiv.appendChild(userDiv); userListDiv.appendChild(userDiv);
}); });
} }
function initSearch() { async function toggleUserBan(userId, isCurrentlyBanned) {
const searchInput = document.getElementById('user-search'); try {
searchInput.addEventListener('input', (e) => { const endpoint = isCurrentlyBanned ? `/admin/users/${userId}/unban` : `/admin/users/${userId}/ban`;
const term = e.target.value.toLowerCase(); const res = await fetch(endpoint, { method: 'POST' });
const filtered = allUsers.filter(user => const data = await res.json();
(user.email && user.email.toLowerCase().includes(term)) || alert(data.message || 'User status updated');
(user.first_name && user.first_name.toLowerCase().includes(term)) || loadUsers();
(user.last_name && user.last_name.toLowerCase().includes(term)) loadStats();
); } catch (err) {
renderUsers(filtered); 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 = '<p class="empty-text">Select a user above to see their notepads.</p>';
document.getElementById('note-list').innerHTML = '<p class="empty-text">Select a notepad to see its notes.</p>';
} 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 = '<p class="empty-text">No IP addresses currently banned.</p>';
return;
}
ips.forEach(item => {
const row = document.createElement('div');
row.className = 'user-item';
const info = document.createElement('div');
info.className = 'item-info';
info.innerHTML = `<strong>🚫 ${escapeHtml(item.ip_address)}</strong> <span style="font-size:0.8rem; color:#94a3b8;">· Banned ${new Date(item.created_at).toLocaleString()}</span>`;
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 = '<p style="color:red;">Error loading banned IPs.</p>';
}
}
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 notepadListDiv = document.getElementById('notepad-list');
const headerTitle = document.getElementById('notepads-header-title');
if (headerTitle) {
headerTitle.textContent = `📁 Notepads for: ${userEmail}`;
}
notepadListDiv.innerHTML = '<p>Loading notepads...</p>'; notepadListDiv.innerHTML = '<p>Loading notepads...</p>';
try { try {
@ -94,21 +370,33 @@ async function loadNotepads(userId) {
const notepads = await res.json(); const notepads = await res.json();
notepadListDiv.innerHTML = ''; notepadListDiv.innerHTML = '';
if (notepads.length === 0) {
notepadListDiv.innerHTML = '<p class="empty-text">No notepads found for this user.</p>';
return;
}
notepads.forEach(notepad => { notepads.forEach(notepad => {
const notepadDiv = document.createElement('div'); const notepadDiv = document.createElement('div');
notepadDiv.className = 'user-item'; notepadDiv.className = 'user-item';
if (selectedNotepadId === notepad.id) notepadDiv.classList.add('selected');
const badge = notepad.is_public ?
'<span class="badge badge-public">🌐 Public</span>' :
'<span class="badge badge-encrypted">🔒 Zero-Knowledge Encrypted</span>';
const infoDiv = document.createElement('div'); const infoDiv = document.createElement('div');
infoDiv.innerHTML = `<strong>${escapeHtml(notepad.name)}</strong> (Public: ${notepad.is_public})`; infoDiv.className = 'item-info';
infoDiv.innerHTML = `<strong>${escapeHtml(notepad.name)}</strong> ${badge} <br><small style="color:#64748b;">Pad ID: #${notepad.id} · Notes: ${notepad.note_count || 0}</small>`;
const deleteBtn = document.createElement('button'); const deleteBtn = document.createElement('button');
deleteBtn.className = 'admin-button'; deleteBtn.className = 'admin-button btn-danger';
deleteBtn.textContent = 'Delete Pad'; deleteBtn.textContent = 'Delete Pad';
deleteBtn.onclick = async (e) => { deleteBtn.onclick = async (e) => {
e.stopPropagation(); 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); await deleteNotepad(notepad.id);
loadNotepads(userId); loadNotepads(userId, userEmail);
loadStats();
} }
}; };
@ -117,7 +405,9 @@ async function loadNotepads(userId) {
notepadDiv.addEventListener('click', () => { notepadDiv.addEventListener('click', () => {
selectedNotepadId = notepad.id; 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'); expandSection('note-list');
}); });
notepadListDiv.appendChild(notepadDiv); 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 noteListDiv = document.getElementById('note-list');
const headerTitle = document.getElementById('notes-header-title');
if (headerTitle) {
headerTitle.textContent = `📝 Notes in: ${notepadName}`;
}
noteListDiv.innerHTML = '<p>Loading notes...</p>'; noteListDiv.innerHTML = '<p>Loading notes...</p>';
try { try {
@ -137,28 +431,43 @@ async function loadNotes(notepadId) {
const notes = await res.json(); const notes = await res.json();
noteListDiv.innerHTML = ''; noteListDiv.innerHTML = '';
if (notes.length === 0) {
noteListDiv.innerHTML = '<p class="empty-text">This notepad is empty.</p>';
return;
}
notes.forEach(note => { notes.forEach(note => {
const noteDiv = document.createElement('div'); const noteDiv = document.createElement('div');
noteDiv.className = 'user-item'; noteDiv.className = 'user-item';
const infoDiv = document.createElement('div'); const infoDiv = document.createElement('div');
infoDiv.className = 'item-info';
if (note.is_encrypted) { if (note.is_encrypted) {
const preview = note.content ? (note.content.length > 40 ? note.content.substring(0, 40) + '...' : note.content) : ''; const preview = note.content ? (note.content.length > 50 ? note.content.substring(0, 50) + '...' : note.content) : '';
infoDiv.innerHTML = `<strong>🔒 [Encrypted Private Note]</strong>: <span style="font-family:monospace; color:#888; font-size:0.8rem;">[Zero-Knowledge at Rest: ${escapeHtml(preview)}]</span>`; infoDiv.innerHTML = `
<div><strong>🔒 [Encrypted Private Note #${note.id}]</strong> <span class="badge badge-encrypted">E2EE Protected</span></div>
<div style="font-family:monospace; color:#64748b; font-size:0.8rem; margin:4px 0;">Zero-Knowledge Ciphertext: ${escapeHtml(preview)}</div>
<small style="color:#94a3b8;">Created: ${new Date(note.created_at).toLocaleString()} · Lat/Lng at Rest: Redacted [0, 0]</small>
`;
} else { } else {
infoDiv.innerHTML = `<strong>${escapeHtml(note.title)}</strong>: ${escapeHtml(note.content)}`; infoDiv.innerHTML = `
<div><strong>🌐 ${escapeHtml(note.title)}</strong> <span class="badge badge-public">Public Note #${note.id}</span></div>
<div style="font-size:0.88rem; color:#334155; margin:4px 0;">${escapeHtml(note.content)}</div>
<small style="color:#94a3b8;">Location: (${note.lat}, ${note.lng}) · Created: ${new Date(note.created_at).toLocaleString()}</small>
`;
} }
noteDiv.appendChild(infoDiv); noteDiv.appendChild(infoDiv);
// Add delete button for each note
const deleteButton = document.createElement('button'); const deleteButton = document.createElement('button');
deleteButton.className = 'admin-button'; deleteButton.className = 'admin-button btn-danger';
deleteButton.textContent = 'Delete Note'; deleteButton.textContent = 'Delete Note';
deleteButton.addEventListener('click', async (e) => { deleteButton.addEventListener('click', async (e) => {
e.stopPropagation(); // Prevent selecting the note e.stopPropagation();
if (confirm('Are you sure you want to delete this note?')) { if (confirm('Permanently delete this note?')) {
await deleteNote(note.id); await deleteNote(note.id);
loadNotes(notepadId); // Refresh notes after deletion loadNotes(notepadId, notepadName, isPublic);
loadStats();
} }
}); });
noteDiv.appendChild(deleteButton); noteDiv.appendChild(deleteButton);
@ -173,10 +482,7 @@ async function loadNotes(notepadId) {
async function deleteNote(noteId) { async function deleteNote(noteId) {
try { try {
const res = await fetch(`/admin/notes/${noteId}`, { const res = await fetch(`/admin/notes/${noteId}`, { method: 'DELETE' });
method: 'DELETE'
});
if (res.ok) { if (res.ok) {
alert('Note deleted successfully.'); alert('Note deleted successfully.');
} else { } else {
@ -190,11 +496,10 @@ async function deleteNote(noteId) {
async function deleteNotepad(notepadId) { async function deleteNotepad(notepadId) {
try { try {
const res = await fetch(`/admin/notepads/${notepadId}`, { const res = await fetch(`/admin/notepads/${notepadId}`, { method: 'DELETE' });
method: 'DELETE'
});
if (res.ok) { if (res.ok) {
alert('Notepad deleted.'); alert('Notepad deleted.');
document.getElementById('note-list').innerHTML = '<p class="empty-text">Select a notepad to see its notes.</p>';
} else { } else {
alert('Failed to delete notepad.'); alert('Failed to delete notepad.');
} }
@ -203,52 +508,45 @@ async function deleteNotepad(notepadId) {
} }
} }
async function banUserIp(userId) { function initSearch() {
if(!confirm("Are you sure you want to ban this IP address? This will block all future access from this IP.")) return; const searchInput = document.getElementById('user-search');
try { if (!searchInput) return;
const res = await fetch(`/admin/users/${userId}/ban_ip`, { method: 'POST' }); searchInput.addEventListener('input', (e) => {
const data = await res.json(); const term = e.target.value.toLowerCase().trim();
alert(data.message || data.error); const filtered = allUsers.filter(user =>
} catch (err) { (user.email && user.email.toLowerCase().includes(term)) ||
console.error(err); (user.first_name && user.first_name.toLowerCase().includes(term)) ||
alert("Error banning IP"); (user.last_name && user.last_name.toLowerCase().includes(term)) ||
} (user.last_ip && user.last_ip.toLowerCase().includes(term))
} );
renderUsers(filtered);
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 = `
<div class="stat-card"><h3>${stats.user_count}</h3><p>Users</p></div>
<div class="stat-card"><h3>${stats.notepad_count}</h3><p>Notepads</p></div>
<div class="stat-card"><h3>${stats.note_count}</h3><p>Notes</p></div>
`;
} catch (err) {
console.error(err);
container.innerHTML = '';
}
} }
function initCollapsibles() { function initCollapsibles() {
const headers = document.querySelectorAll('.section-title'); const headers = document.querySelectorAll('.section-title');
headers.forEach(header => { headers.forEach(header => {
header.addEventListener('click', () => { header.addEventListener('click', () => {
const content = header.nextElementSibling; const section = header.parentElement;
content.classList.toggle('collapsed'); 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) { function expandSection(id) {
const el = document.getElementById(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) { function escapeHtml(text) {

View file

@ -325,7 +325,7 @@ window.SpotNotes.loadNotesForPad = async function(padId, padName, isPublic, owne
if (canWrite !== undefined) { if (canWrite !== undefined) {
window.SpotNotes.activePadCanWrite = canWrite; window.SpotNotes.activePadCanWrite = canWrite;
} else if (padName !== undefined) { } 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 // 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 // Update Sidebar Header
const header = document.querySelector('.sidebar-header'); const header = document.querySelector('.sidebar-header');
if (header) { if (header) {
const addNoteBtn = window.SpotNotes.activePadCanWrite ? `<button onclick="openCreateNoteModal(${padId})" class="btn-add-note">+ Add Note</button>` : ''; const canAddNote = window.SpotNotes.activePadCanWrite || (window.SpotNotes.currentUser && isPublic);
const addNoteBtn = canAddNote ? `<button onclick="openCreateNoteModal(${padId})" class="btn-add-note">+ Add Note</button>` : '';
const manageShareBtn = isOwner ? `<button class="btn-action-icon" onclick="window.SpotNotes.openShareSettingsModal(${padId}, '${window.SpotNotes.escapeHtml(currentName)}')" title="Manage Sharing">⚙️</button>` : ''; const manageShareBtn = isOwner ? `<button class="btn-action-icon" onclick="window.SpotNotes.openShareSettingsModal(${padId}, '${window.SpotNotes.escapeHtml(currentName)}')" title="Manage Sharing">⚙️</button>` : '';
const exportBtn = `<button class="btn-action-icon" onclick="window.SpotNotes.exportCurrentPadCsv(${padId}, '${window.SpotNotes.escapeHtml(currentName)}')" title="Export to CSV">⬇️</button>`; const exportBtn = `<button class="btn-action-icon" onclick="window.SpotNotes.exportCurrentPadCsv(${padId}, '${window.SpotNotes.escapeHtml(currentName)}')" title="Export to CSV">⬇️</button>`;
const shareBtn = `<button class="btn-action-icon" onclick="window.SpotNotes.shareNotepad(${padId}, '${token || ''}')" title="Share Notepad">🔗</button>`; const shareBtn = `<button class="btn-action-icon" onclick="window.SpotNotes.shareNotepad(${padId}, '${token || ''}')" title="Share Notepad">🔗</button>`;
@ -912,8 +913,13 @@ window.SpotNotes.renderActiveNotesList = function(filterText = '') {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'note-card'; 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 = ''; let actionBtns = '';
if (window.SpotNotes.activePadCanWrite) { if (canModify) {
const escapedTitle = window.SpotNotes.escapeHtml(note.title).replace(/'/g, "\\'").replace(/"/g, '&quot;'); const escapedTitle = window.SpotNotes.escapeHtml(note.title).replace(/'/g, "\\'").replace(/"/g, '&quot;');
const escapedContent = window.SpotNotes.escapeHtml(note.content).replace(/'/g, "\\'").replace(/"/g, '&quot;').replace(/\n/g, '\\n'); const escapedContent = window.SpotNotes.escapeHtml(note.content).replace(/'/g, "\\'").replace(/"/g, '&quot;').replace(/\n/g, '\\n');
actionBtns = ` actionBtns = `

View file

@ -101,11 +101,13 @@ window.SpotNotes.renderMapMarkers = function() {
let actionBtns = ''; let actionBtns = '';
// Determine permissions // 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 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 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, '&quot;'); const escapedTitle = window.SpotNotes.escapeHtml(note.title).replace(/'/g, "\\'").replace(/"/g, '&quot;');
const escapedContent = window.SpotNotes.escapeHtml(note.content).replace(/'/g, "\\'").replace(/"/g, '&quot;').replace(/\n/g, '\\n'); const escapedContent = window.SpotNotes.escapeHtml(note.content).replace(/'/g, "\\'").replace(/"/g, '&quot;').replace(/\n/g, '\\n');
actionBtns = `<br><button onclick="openEditNoteModal(${note.id}, '${escapedTitle}', '${escapedContent}')" class="btn-note-edit">✏️ Edit</button><button onclick="window.SpotNotes.deleteNote(${note.id})" class="btn-note-delete">Delete</button>`; actionBtns = `<br><button onclick="openEditNoteModal(${note.id}, '${escapedTitle}', '${escapedContent}')" class="btn-note-edit">✏️ Edit</button><button onclick="window.SpotNotes.deleteNote(${note.id})" class="btn-note-delete">Delete</button>`;