modified: blueprints/admin.py
This commit is contained in:
parent
97ca1925d2
commit
716ae7e3b2
9 changed files with 780 additions and 269 deletions
|
|
@ -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'])
|
||||
|
|
@ -134,3 +140,43 @@ def ban_user_ip(user_id):
|
|||
db.session.commit()
|
||||
|
||||
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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
101
seed_admin.py
101
seed_admin.py
|
|
@ -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()
|
||||
|
|
@ -3,42 +3,91 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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/auth.css">
|
||||
<link rel="stylesheet" href="/assets/css/admin.css">
|
||||
<link rel="icon" href="favicon.ico" type="image/x-icon" />
|
||||
</head>
|
||||
<body class="page-body">
|
||||
<div class="content-container">
|
||||
<h1>Admin Panel</h1>
|
||||
<div class="content-container admin-container">
|
||||
<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 -->
|
||||
<div id="admin-stats" class="stats-container">
|
||||
<!-- Stats injected by JS -->
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<!-- Moderation Queue -->
|
||||
<div class="admin-section" id="section-reported">
|
||||
<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>
|
||||
<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">
|
||||
<p>Loading users...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h2 class="section-title">Notepads <span class="toggle-icon">▼</span></h2>
|
||||
<div id="notepad-list" class="section-content">
|
||||
<p>Select a user to see their notepads.</p>
|
||||
<!-- Banned IPs -->
|
||||
<div class="admin-section" id="section-banned-ips">
|
||||
<h2 class="section-title">
|
||||
<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 class="admin-section">
|
||||
<h2 class="section-title">Notes <span class="toggle-icon">▼</span></h2>
|
||||
<!-- Notepads Explorer -->
|
||||
<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">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 = `
|
||||
<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() {
|
||||
const userListDiv = document.getElementById('user-list');
|
||||
|
|
@ -32,32 +160,76 @@ function renderUsers(users) {
|
|||
userListDiv.innerHTML = '';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
users.forEach(user => {
|
||||
const userDiv = document.createElement('div');
|
||||
userDiv.className = 'user-item';
|
||||
|
||||
// Handle "Unowned" user display specifically or generic
|
||||
const ipDisplay = user.id === 0 ? '' : `<br> <small>IP: ${escapeHtml(user.last_ip || 'Unknown')}</small>`;
|
||||
if (selectedUserId === user.id) userDiv.classList.add('selected');
|
||||
|
||||
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');
|
||||
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 = '<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 headerTitle = document.getElementById('notepads-header-title');
|
||||
if (headerTitle) {
|
||||
headerTitle.textContent = `📁 Notepads for: ${userEmail}`;
|
||||
}
|
||||
notepadListDiv.innerHTML = '<p>Loading notepads...</p>';
|
||||
|
||||
try {
|
||||
|
|
@ -94,21 +370,33 @@ async function loadNotepads(userId) {
|
|||
const notepads = await res.json();
|
||||
|
||||
notepadListDiv.innerHTML = '';
|
||||
if (notepads.length === 0) {
|
||||
notepadListDiv.innerHTML = '<p class="empty-text">No notepads found for this user.</p>';
|
||||
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 ?
|
||||
'<span class="badge badge-public">🌐 Public</span>' :
|
||||
'<span class="badge badge-encrypted">🔒 Zero-Knowledge Encrypted</span>';
|
||||
|
||||
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');
|
||||
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 = '<p>Loading notes...</p>';
|
||||
|
||||
try {
|
||||
|
|
@ -137,28 +431,43 @@ async function loadNotes(notepadId) {
|
|||
const notes = await res.json();
|
||||
|
||||
noteListDiv.innerHTML = '';
|
||||
if (notes.length === 0) {
|
||||
noteListDiv.innerHTML = '<p class="empty-text">This notepad is empty.</p>';
|
||||
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 = `<strong>🔒 [Encrypted Private Note]</strong>: <span style="font-family:monospace; color:#888; font-size:0.8rem;">[Zero-Knowledge at Rest: ${escapeHtml(preview)}]</span>`;
|
||||
const preview = note.content ? (note.content.length > 50 ? note.content.substring(0, 50) + '...' : note.content) : '';
|
||||
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 {
|
||||
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);
|
||||
|
||||
// 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 = '<p class="empty-text">Select a notepad to see its notes.</p>';
|
||||
} 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 = `
|
||||
<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 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) {
|
||||
|
|
|
|||
|
|
@ -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 ? `<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 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>`;
|
||||
|
|
@ -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 = `
|
||||
|
|
|
|||
|
|
@ -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 = `<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>`;
|
||||
|
|
|
|||
Loading…
Reference in a new issue