Harshit Ghosh commited on
Commit
3754bec
Β·
1 Parent(s): 04c7cc4

Add full profile management features (avatar, inline edit, delete account)

Browse files
.gitignore CHANGED
@@ -45,6 +45,7 @@ download_imp/*.pt
45
  download_imp/*.pkl
46
  download_imp/*.onnx
47
 
 
48
  # download_imp/*
49
  # Local downloaded artifacts
50
  download/
 
45
  download_imp/*.pkl
46
  download_imp/*.onnx
47
 
48
+ push_to_hf.sh
49
  # download_imp/*
50
  # Local downloaded artifacts
51
  download/
auth_routes.py CHANGED
@@ -524,3 +524,162 @@ def change_password():
524
  log_audit('password_change_error', user_id=current_user.id,
525
  status='failure', details=str(e))
526
  return jsonify({'error': 'Password change failed'}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  log_audit('password_change_error', user_id=current_user.id,
525
  status='failure', details=str(e))
526
  return jsonify({'error': 'Password change failed'}), 500
527
+
528
+ # ── Profile Management Routes ───────────────────────────────────────────────
529
+
530
+ @auth_bp.route('/profile/update-name', methods=['POST'])
531
+ @login_required
532
+ def update_name():
533
+ if not request.is_json:
534
+ return jsonify({'error': 'JSON required'}), 400
535
+ full_name = request.json.get('full_name', '').strip()
536
+ if not full_name:
537
+ return jsonify({'error': 'Name cannot be empty'}), 400
538
+
539
+ current_user.full_name = full_name
540
+ db.session.commit()
541
+ return jsonify({'message': 'Name updated successfully', 'full_name': full_name})
542
+
543
+
544
+ @auth_bp.route('/profile/request-username-change', methods=['POST'])
545
+ @login_required
546
+ def request_username_change():
547
+ if not request.is_json:
548
+ return jsonify({'error': 'JSON required'}), 400
549
+ new_username = request.json.get('new_username', '').strip()
550
+ valid, msg = validate_username(new_username)
551
+ if not valid:
552
+ return jsonify({'error': msg}), 400
553
+ if User.query.filter_by(username=new_username).first():
554
+ return jsonify({'error': 'Username already taken'}), 400
555
+
556
+ code, token = _store_otp(email=current_user.email, purpose="change_username",
557
+ user_id=current_user.id, pending_value=new_username)
558
+ sent = _send_email(current_user.email, "Verify username change", _otp_body(code, "change_username"))
559
+ if not sent:
560
+ return jsonify({'error': 'Failed to send OTP email'}), 500
561
+ return jsonify({'message': 'OTP sent to your current email', 'otp_token': token})
562
+
563
+
564
+ @auth_bp.route('/profile/request-email-change', methods=['POST'])
565
+ @login_required
566
+ def request_email_change():
567
+ if not request.is_json:
568
+ return jsonify({'error': 'JSON required'}), 400
569
+ new_email = request.json.get('new_email', '').strip().lower()
570
+ valid, msg = validate_email(new_email)
571
+ if not valid:
572
+ return jsonify({'error': msg}), 400
573
+ if User.query.filter_by(email=new_email).first():
574
+ return jsonify({'error': 'Email already registered'}), 400
575
+
576
+ code, token = _store_otp(email=new_email, purpose="change_email",
577
+ user_id=current_user.id, pending_value=new_email)
578
+ sent = _send_email(new_email, "Verify your new email address", _otp_body(code, "change_email"))
579
+ if not sent:
580
+ return jsonify({'error': 'Failed to send OTP to new email'}), 500
581
+ return jsonify({'message': 'OTP sent to your NEW email address', 'otp_token': token})
582
+
583
+
584
+ @auth_bp.route('/profile/confirm-change', methods=['POST'])
585
+ @login_required
586
+ def confirm_profile_change():
587
+ if not request.is_json:
588
+ return jsonify({'error': 'JSON required'}), 400
589
+
590
+ otp = request.json.get('otp', '').strip()
591
+ otp_token = request.json.get('otp_token', '').strip()
592
+ purpose = request.json.get('purpose', '').strip()
593
+
594
+ if purpose not in ("change_username", "change_email"):
595
+ return jsonify({'error': 'Invalid purpose'}), 400
596
+
597
+ ok, msg, row = _validate_otp(otp, purpose, otp_token)
598
+ if not ok:
599
+ return jsonify({'error': msg}), 400
600
+
601
+ if not row.pending_value:
602
+ _clear_otp_row(row)
603
+ return jsonify({'error': 'No pending value found in OTP session'}), 400
604
+
605
+ if purpose == "change_username":
606
+ if User.query.filter_by(username=row.pending_value).first():
607
+ _clear_otp_row(row)
608
+ return jsonify({'error': 'Username was taken in the meantime'}), 400
609
+ current_user.username = row.pending_value
610
+
611
+ elif purpose == "change_email":
612
+ if User.query.filter_by(email=row.pending_value).first():
613
+ _clear_otp_row(row)
614
+ return jsonify({'error': 'Email was taken in the meantime'}), 400
615
+ current_user.email = row.pending_value
616
+
617
+ db.session.commit()
618
+ _clear_otp_row(row)
619
+ log_audit(purpose, user_id=current_user.id, status='success')
620
+ return jsonify({'message': 'Profile updated successfully'})
621
+
622
+
623
+ @auth_bp.route('/profile/upload-avatar', methods=['POST'])
624
+ @login_required
625
+ def upload_avatar():
626
+ if 'avatar' not in request.files:
627
+ return jsonify({'error': 'No file uploaded'}), 400
628
+ file = request.files['avatar']
629
+ if not file.filename:
630
+ return jsonify({'error': 'No selected file'}), 400
631
+
632
+ try:
633
+ import cloudinary
634
+ import cloudinary.uploader
635
+ res = cloudinary.uploader.upload(file, folder="ich_avatars",
636
+ transformation=[{'width': 256, 'height': 256, 'crop': 'fill', 'gravity': 'face'}])
637
+
638
+ # Delete old avatar if exists
639
+ if current_user.avatar_public_id:
640
+ try:
641
+ cloudinary.uploader.destroy(current_user.avatar_public_id)
642
+ except Exception as exc:
643
+ logger.warning("Failed to destroy old avatar: %s", exc)
644
+
645
+ current_user.avatar_url = res.get('secure_url')
646
+ current_user.avatar_public_id = res.get('public_id')
647
+ db.session.commit()
648
+ return jsonify({'message': 'Avatar updated', 'avatar_url': current_user.avatar_url})
649
+ except ImportError:
650
+ return jsonify({'error': 'Cloudinary package not installed'}), 500
651
+ except Exception as exc:
652
+ logger.error("Avatar upload failed: %s", exc)
653
+ return jsonify({'error': 'Failed to upload image'}), 500
654
+
655
+
656
+ @auth_bp.route('/delete-account', methods=['POST'])
657
+ @login_required
658
+ def delete_account():
659
+ password = request.form.get('password', '')
660
+ if not current_user.check_password(password):
661
+ flash('Incorrect password. Account deletion cancelled.', 'error')
662
+ return redirect(url_for('auth.profile'))
663
+
664
+ try:
665
+ user_id = current_user.id
666
+
667
+ # Cloudinary cleanup: delete avatar if exists
668
+ if current_user.avatar_public_id:
669
+ try:
670
+ import cloudinary.uploader
671
+ cloudinary.uploader.destroy(current_user.avatar_public_id)
672
+ except Exception as exc:
673
+ logger.warning("Failed to delete avatar during account deletion: %s", exc)
674
+
675
+ db.session.delete(current_user)
676
+ db.session.commit()
677
+ log_audit('account_deleted', user_id=user_id, status='success')
678
+ logout_user()
679
+ flash('Your account has been successfully deleted.', 'success')
680
+ return redirect(url_for('home'))
681
+ except Exception as exc:
682
+ db.session.rollback()
683
+ logger.error("Error deleting account: %s", exc)
684
+ flash('An error occurred while deleting your account.', 'error')
685
+ return redirect(url_for('auth.profile'))
start.sh CHANGED
@@ -2,7 +2,7 @@
2
 
3
  # Start Celery worker in background
4
  # We use concurrency=2 to avoid memory overload on the 16GB free tier
5
- celery -A tasks worker --loglevel=info --concurrency=2 &
6
  CELERY_PID=$!
7
 
8
  # Trap SIGTERM and SIGINT for graceful shutdown
 
2
 
3
  # Start Celery worker in background
4
  # We use concurrency=2 to avoid memory overload on the 16GB free tier
5
+ celery -A tasks worker --loglevel=info --concurrency=2 -B &
6
  CELERY_PID=$!
7
 
8
  # Trap SIGTERM and SIGINT for graceful shutdown
static/css/auth.css CHANGED
@@ -377,3 +377,102 @@
377
  .auth-form-panel { padding:32px 18px; }
378
  .auth-card-header h2 { font-size:1.45rem; }
379
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  .auth-form-panel { padding:32px 18px; }
378
  .auth-card-header h2 { font-size:1.45rem; }
379
  }
380
+
381
+ /* ── Profile Modals & Inline Edit ── */
382
+
383
+ .profile-avatar-wrapper {
384
+ position: relative;
385
+ display: inline-block;
386
+ margin-right: 20px;
387
+ }
388
+ .profile-avatar-img {
389
+ width: 72px;
390
+ height: 72px;
391
+ border-radius: 50%;
392
+ object-fit: cover;
393
+ border: 2px solid #334155;
394
+ background: #0f172a;
395
+ }
396
+ .avatar-upload-btn {
397
+ position: absolute;
398
+ bottom: 0;
399
+ right: -4px;
400
+ width: 28px;
401
+ height: 28px;
402
+ border-radius: 50%;
403
+ background: #3b82f6;
404
+ border: 2px solid #1e293b;
405
+ color: #fff;
406
+ display: flex;
407
+ align-items: center;
408
+ justify-content: center;
409
+ cursor: pointer;
410
+ transition: all 0.2s;
411
+ }
412
+ .avatar-upload-btn:hover {
413
+ background: #2563eb;
414
+ transform: scale(1.1);
415
+ }
416
+
417
+ .btn-inline-edit {
418
+ background: transparent;
419
+ border: 1px solid #334155;
420
+ color: #94a3b8;
421
+ padding: 4px 12px;
422
+ border-radius: 4px;
423
+ font-size: 13px;
424
+ font-weight: 500;
425
+ cursor: pointer;
426
+ transition: all 0.2s;
427
+ margin-left: auto;
428
+ }
429
+ .btn-inline-edit:hover {
430
+ background: #1e293b;
431
+ color: #f8fafc;
432
+ border-color: #475569;
433
+ }
434
+
435
+ .profile-row {
436
+ display: flex;
437
+ align-items: center;
438
+ padding: 12px 0;
439
+ border-bottom: 1px solid #1e293b;
440
+ }
441
+ .profile-row:last-child {
442
+ border-bottom: none;
443
+ }
444
+ .pr-label {
445
+ width: 140px;
446
+ color: #64748b;
447
+ font-size: 14px;
448
+ }
449
+ .pr-value {
450
+ color: #f8fafc;
451
+ font-size: 15px;
452
+ font-weight: 500;
453
+ }
454
+
455
+ .modal-overlay {
456
+ position: fixed;
457
+ top: 0; left: 0; right: 0; bottom: 0;
458
+ background: rgba(2, 6, 23, 0.8);
459
+ backdrop-filter: blur(4px);
460
+ z-index: 1000;
461
+ display: flex;
462
+ align-items: center;
463
+ justify-content: center;
464
+ padding: 20px;
465
+ }
466
+ .modal-content {
467
+ width: 100%;
468
+ max-width: 400px;
469
+ animation: modal-pop 0.3s cubic-bezier(0.16, 1, 0.3, 1);
470
+ }
471
+ @keyframes modal-pop {
472
+ 0% { transform: scale(0.95) translateY(10px); opacity: 0; }
473
+ 100% { transform: scale(1) translateY(0); opacity: 1; }
474
+ }
475
+
476
+ .profile-message-container {
477
+ margin-bottom: 24px;
478
+ }
static/js/profile-actions.js ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Profile Actions - Handle inline editing, modals, and avatar upload
3
+ */
4
+
5
+ // Avatar Upload
6
+ document.getElementById('avatarUpload')?.addEventListener('change', async function(e) {
7
+ const file = e.target.files[0];
8
+ if (!file) return;
9
+
10
+ const btn = document.querySelector('.avatar-upload-btn');
11
+ const originalHtml = btn.innerHTML;
12
+ btn.innerHTML = '<span style="width:14px;height:14px;border:2px solid transparent;border-top-color:#fff;border-radius:50%;animation:spin 1s linear infinite;display:inline-block;"></span>';
13
+ btn.disabled = true;
14
+
15
+ const formData = new FormData();
16
+ formData.append('avatar', file);
17
+
18
+ try {
19
+ const response = await fetch('/auth/profile/upload-avatar', {
20
+ method: 'POST',
21
+ body: formData
22
+ });
23
+
24
+ const data = await response.json();
25
+ if (response.ok) {
26
+ // Reload to show new avatar everywhere
27
+ window.location.reload();
28
+ } else {
29
+ showProfileMessage(data.error || 'Failed to upload avatar', 'error');
30
+ btn.innerHTML = originalHtml;
31
+ btn.disabled = false;
32
+ }
33
+ } catch (err) {
34
+ showProfileMessage('Network error occurred', 'error');
35
+ btn.innerHTML = originalHtml;
36
+ btn.disabled = false;
37
+ }
38
+ });
39
+
40
+ // Modals
41
+ function openEditModal(field, currentValue) {
42
+ document.getElementById('editFieldType').value = field;
43
+ document.getElementById('editFieldValue').value = currentValue;
44
+
45
+ let label = 'New Value';
46
+ let title = 'Edit Field';
47
+ if (field === 'username') { label = 'New Username'; title = 'Change Username'; }
48
+ if (field === 'email') { label = 'New Email Address'; title = 'Change Email'; }
49
+ if (field === 'full_name') { label = 'New Full Name'; title = 'Update Name'; }
50
+
51
+ document.getElementById('editFieldLabel').innerText = label;
52
+ document.getElementById('editModalTitle').innerText = title;
53
+
54
+ document.getElementById('editStep1').style.display = 'block';
55
+ document.getElementById('editStep2').style.display = 'none';
56
+ document.getElementById('editFieldOtpToken').value = '';
57
+ document.getElementById('editFieldOtp').value = '';
58
+
59
+ document.getElementById('editModal').style.display = 'flex';
60
+ }
61
+
62
+ function closeEditModal() {
63
+ document.getElementById('editModal').style.display = 'none';
64
+ }
65
+
66
+ function openDeleteModal() {
67
+ document.getElementById('deleteModal').style.display = 'flex';
68
+ }
69
+
70
+ function closeDeleteModal() {
71
+ document.getElementById('deleteModal').style.display = 'none';
72
+ }
73
+
74
+ // Edit Form Submission
75
+ document.getElementById('editFieldForm')?.addEventListener('submit', async function(e) {
76
+ e.preventDefault();
77
+
78
+ const field = document.getElementById('editFieldType').value;
79
+ const value = document.getElementById('editFieldValue').value.trim();
80
+ const btn = document.getElementById('btnRequestChange');
81
+
82
+ if (!value) return;
83
+
84
+ const originalText = btn.innerText;
85
+ btn.innerText = 'Saving...';
86
+ btn.disabled = true;
87
+
88
+ let endpoint = '';
89
+ let payload = {};
90
+
91
+ if (field === 'full_name') {
92
+ endpoint = '/auth/profile/update-name';
93
+ payload = { full_name: value };
94
+ } else if (field === 'username') {
95
+ endpoint = '/auth/profile/request-username-change';
96
+ payload = { new_username: value };
97
+ } else if (field === 'email') {
98
+ endpoint = '/auth/profile/request-email-change';
99
+ payload = { new_email: value };
100
+ }
101
+
102
+ try {
103
+ const res = await fetch(endpoint, {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify(payload)
107
+ });
108
+ const data = await res.json();
109
+
110
+ if (res.ok) {
111
+ if (field === 'full_name') {
112
+ document.getElementById('val-full_name').innerText = data.full_name;
113
+ closeEditModal();
114
+ showProfileMessage('Name updated successfully', 'success');
115
+ } else {
116
+ // Show OTP step
117
+ document.getElementById('editStep1').style.display = 'none';
118
+ document.getElementById('editStep2').style.display = 'block';
119
+ document.getElementById('editFieldOtpToken').value = data.otp_token;
120
+ }
121
+ } else {
122
+ showProfileMessage(data.error || 'Failed to request change', 'error');
123
+ }
124
+ } catch (err) {
125
+ showProfileMessage('Network error occurred', 'error');
126
+ } finally {
127
+ btn.innerText = originalText;
128
+ btn.disabled = false;
129
+ }
130
+ });
131
+
132
+ // Confirm OTP (Username/Email)
133
+ document.getElementById('btnConfirmChange')?.addEventListener('click', async function() {
134
+ const otp = document.getElementById('editFieldOtp').value.trim();
135
+ const token = document.getElementById('editFieldOtpToken').value;
136
+ const field = document.getElementById('editFieldType').value;
137
+ const btn = this;
138
+
139
+ if (otp.length !== 6) return;
140
+
141
+ const originalText = btn.innerText;
142
+ btn.innerText = 'Verifying...';
143
+ btn.disabled = true;
144
+
145
+ const purpose = field === 'username' ? 'change_username' : 'change_email';
146
+
147
+ try {
148
+ const res = await fetch('/auth/profile/confirm-change', {
149
+ method: 'POST',
150
+ headers: { 'Content-Type': 'application/json' },
151
+ body: JSON.stringify({ otp: otp, otp_token: token, purpose: purpose })
152
+ });
153
+ const data = await res.json();
154
+
155
+ if (res.ok) {
156
+ // Reload to reflect changes
157
+ window.location.reload();
158
+ } else {
159
+ showProfileMessage(data.error || 'Failed to verify OTP', 'error');
160
+ }
161
+ } catch (err) {
162
+ showProfileMessage('Network error occurred', 'error');
163
+ } finally {
164
+ btn.innerText = originalText;
165
+ btn.disabled = false;
166
+ }
167
+ });
168
+
169
+ function showProfileMessage(msg, type) {
170
+ const container = document.getElementById('profileMessage');
171
+ if (!container) return;
172
+ container.innerHTML = `<div class="alert alert-${type}">${msg}</div>`;
173
+ setTimeout(() => { container.innerHTML = ''; }, 5000);
174
+ }
tasks.py CHANGED
@@ -450,3 +450,26 @@ def process_dicom_batch(
450
  def health_check() -> str:
451
  """Simple health check task for monitoring."""
452
  return "Celery worker is healthy"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  def health_check() -> str:
451
  """Simple health check task for monitoring."""
452
  return "Celery worker is healthy"
453
+
454
+ @celery_app.task
455
+ def cleanup_expired_otps():
456
+ """Periodic task to delete expired OTPs from the database."""
457
+ from app_new import app
458
+ from models import db, PendingOtp, now_ist
459
+
460
+ with app.app_context():
461
+ try:
462
+ deleted = PendingOtp.query.filter(PendingOtp.expires_at < now_ist()).delete()
463
+ db.session.commit()
464
+ if deleted > 0:
465
+ logger.info("Cleaned up %d expired OTP rows.", deleted)
466
+ except Exception as exc:
467
+ db.session.rollback()
468
+ logger.error("Error cleaning up OTPs: %s", exc)
469
+
470
+ celery_app.conf.beat_schedule = {
471
+ 'cleanup-expired-otps-every-15-mins': {
472
+ 'task': 'tasks.cleanup_expired_otps',
473
+ 'schedule': 900.0, # 15 minutes in seconds
474
+ },
475
+ }
templates/auth/profile.html CHANGED
@@ -7,8 +7,18 @@
7
 
8
  <!-- ── Profile hero ── -->
9
  <div class="profile-hero">
10
- <div class="profile-avatar" aria-label="User avatar">
11
- {{ user.username[0].upper() }}
 
 
 
 
 
 
 
 
 
 
12
  </div>
13
  <div class="profile-identity">
14
  <h2>{{ user.full_name or user.username }}</h2>
@@ -20,26 +30,33 @@
20
  </div>
21
  </div>
22
 
 
 
23
  <!-- ── Account info ── -->
24
  <div class="profile-section">
25
  <h3>
26
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
27
  Account Information
28
  </h3>
 
29
  <div class="profile-row">
30
  <span class="pr-label">Username</span>
31
- <span class="pr-value">{{ user.username }}</span>
 
32
  </div>
 
33
  <div class="profile-row">
34
  <span class="pr-label">Email</span>
35
- <span class="pr-value">{{ user.email }}</span>
 
36
  </div>
37
- {% if user.full_name %}
38
  <div class="profile-row">
39
  <span class="pr-label">Full Name</span>
40
- <span class="pr-value">{{ user.full_name }}</span>
 
41
  </div>
42
- {% endif %}
43
  <div class="profile-row">
44
  <span class="pr-label">Account Status</span>
45
  <span class="pr-value" style="color:#34d399;">
@@ -47,6 +64,7 @@
47
  Active
48
  </span>
49
  </div>
 
50
  <div class="profile-row">
51
  <span class="pr-label">Member Since</span>
52
  <span class="pr-value">{{ user.created_at.strftime('%B %d, %Y') }}</span>
@@ -118,20 +136,94 @@
118
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
119
  Account Actions
120
  </h3>
121
- <form method="POST" action="{{ url_for('auth.logout') }}">
122
- <button type="submit" class="btn-logout-danger">
123
  <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
124
  Sign Out
125
  </button>
126
  </form>
 
 
 
 
 
127
  </div>
128
 
129
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  {% endblock %}
131
 
132
  {% block scripts %}
133
  <script src="{{ url_for('static', filename='js/auth-shared.js') }}" defer></script>
134
  <script src="{{ url_for('static', filename='js/profile.js') }}" defer></script>
135
  <script src="{{ url_for('static', filename='js/profile-page.js') }}" defer></script>
 
136
  {% endblock %}
137
-
 
7
 
8
  <!-- ── Profile hero ── -->
9
  <div class="profile-hero">
10
+ <div class="profile-avatar-wrapper">
11
+ {% if user.avatar_url %}
12
+ <img src="{{ user.avatar_url }}" alt="User avatar" class="profile-avatar-img">
13
+ {% else %}
14
+ <div class="profile-avatar" aria-label="User avatar">
15
+ {{ user.username[0].upper() }}
16
+ </div>
17
+ {% endif %}
18
+ <button class="avatar-upload-btn" onclick="document.getElementById('avatarUpload').click()" title="Change Avatar">
19
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
20
+ </button>
21
+ <input type="file" id="avatarUpload" accept="image/*" style="display: none;">
22
  </div>
23
  <div class="profile-identity">
24
  <h2>{{ user.full_name or user.username }}</h2>
 
30
  </div>
31
  </div>
32
 
33
+ <div id="profileMessage" class="profile-message-container"></div>
34
+
35
  <!-- ── Account info ── -->
36
  <div class="profile-section">
37
  <h3>
38
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
39
  Account Information
40
  </h3>
41
+
42
  <div class="profile-row">
43
  <span class="pr-label">Username</span>
44
+ <span class="pr-value" id="val-username">{{ user.username }}</span>
45
+ <button class="btn-inline-edit" onclick="openEditModal('username', '{{ user.username }}')">Edit</button>
46
  </div>
47
+
48
  <div class="profile-row">
49
  <span class="pr-label">Email</span>
50
+ <span class="pr-value" id="val-email">{{ user.email }}</span>
51
+ <button class="btn-inline-edit" onclick="openEditModal('email', '{{ user.email }}')">Edit</button>
52
  </div>
53
+
54
  <div class="profile-row">
55
  <span class="pr-label">Full Name</span>
56
+ <span class="pr-value" id="val-full_name">{{ user.full_name or '' }}</span>
57
+ <button class="btn-inline-edit" onclick="openEditModal('full_name', '{{ user.full_name or '' }}')">Edit</button>
58
  </div>
59
+
60
  <div class="profile-row">
61
  <span class="pr-label">Account Status</span>
62
  <span class="pr-value" style="color:#34d399;">
 
64
  Active
65
  </span>
66
  </div>
67
+
68
  <div class="profile-row">
69
  <span class="pr-label">Member Since</span>
70
  <span class="pr-value">{{ user.created_at.strftime('%B %d, %Y') }}</span>
 
136
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
137
  Account Actions
138
  </h3>
139
+ <form method="POST" action="{{ url_for('auth.logout') }}" style="display:inline-block; margin-right: 12px;">
140
+ <button type="submit" class="btn-logout-danger" style="background:#1e293b; border-color:#334155; color:#f8fafc;">
141
  <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
142
  Sign Out
143
  </button>
144
  </form>
145
+
146
+ <button class="btn-logout-danger" onclick="openDeleteModal()" style="display:inline-block;">
147
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
148
+ Delete Account
149
+ </button>
150
  </div>
151
 
152
  </div>
153
+
154
+ <!-- ── Modals ── -->
155
+
156
+ <!-- Edit Profile Field Modal -->
157
+ <div class="modal-overlay" id="editModal" style="display: none;">
158
+ <div class="modal-content auth-card">
159
+ <div class="auth-card-header" style="margin-bottom:20px;">
160
+ <h2 id="editModalTitle">Edit Field</h2>
161
+ </div>
162
+
163
+ <form id="editFieldForm" class="auth-form">
164
+ <input type="hidden" id="editFieldType">
165
+
166
+ <!-- Step 1: Request Change -->
167
+ <div id="editStep1">
168
+ <div class="form-group">
169
+ <label id="editFieldLabel">New Value</label>
170
+ <div class="input-wrap">
171
+ <input type="text" id="editFieldValue" required>
172
+ </div>
173
+ </div>
174
+ <div class="pw-action-row" style="margin-top:20px;">
175
+ <button type="submit" class="btn-save-pw" id="btnRequestChange">Save Changes</button>
176
+ <button type="button" class="btn-cancel-pw" onclick="closeEditModal()">Cancel</button>
177
+ </div>
178
+ </div>
179
+
180
+ <!-- Step 2: Confirm OTP (only for email/username) -->
181
+ <div id="editStep2" style="display: none;">
182
+ <p style="color:#94a3b8;font-size:14px;margin-bottom:16px;">We've sent a verification code to confirm this change.</p>
183
+ <div class="form-group">
184
+ <label>6-Digit Code</label>
185
+ <div class="input-wrap">
186
+ <input type="text" id="editFieldOtp" maxlength="6" inputmode="numeric" placeholder="123456">
187
+ </div>
188
+ </div>
189
+ <input type="hidden" id="editFieldOtpToken">
190
+ <div class="pw-action-row" style="margin-top:20px;">
191
+ <button type="button" class="btn-save-pw" id="btnConfirmChange">Verify & Update</button>
192
+ <button type="button" class="btn-cancel-pw" onclick="closeEditModal()">Cancel</button>
193
+ </div>
194
+ </div>
195
+ </form>
196
+ </div>
197
+ </div>
198
+
199
+ <!-- Delete Account Modal -->
200
+ <div class="modal-overlay" id="deleteModal" style="display: none;">
201
+ <div class="modal-content auth-card">
202
+ <div class="auth-card-header" style="margin-bottom:20px;">
203
+ <h2 style="color:#ef4444;">Delete Account</h2>
204
+ <p style="color:#fca5a5; font-size:13px; margin-top:8px;">This action is permanent and cannot be undone.</p>
205
+ </div>
206
+ <form method="POST" action="{{ url_for('auth.delete_account') }}" class="auth-form">
207
+ <div class="form-group">
208
+ <label>Enter your password to confirm</label>
209
+ <div class="input-wrap">
210
+ <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
211
+ <input type="password" name="password" required>
212
+ </div>
213
+ </div>
214
+ <div class="pw-action-row" style="margin-top:20px;">
215
+ <button type="submit" class="btn-save-pw" style="background:#dc2626; border-color:#b91c1c;">Permanently Delete</button>
216
+ <button type="button" class="btn-cancel-pw" onclick="closeDeleteModal()">Cancel</button>
217
+ </div>
218
+ </form>
219
+ </div>
220
+ </div>
221
+
222
  {% endblock %}
223
 
224
  {% block scripts %}
225
  <script src="{{ url_for('static', filename='js/auth-shared.js') }}" defer></script>
226
  <script src="{{ url_for('static', filename='js/profile.js') }}" defer></script>
227
  <script src="{{ url_for('static', filename='js/profile-page.js') }}" defer></script>
228
+ <script src="{{ url_for('static', filename='js/profile-actions.js') }}" defer></script>
229
  {% endblock %}