devpi-admin 1.1.0__tar.gz → 1.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/PKG-INFO +1 -1
  2. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/src/devpi_admin/_version.py +2 -2
  3. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/src/devpi_admin/static/css/style.css +39 -2
  4. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/src/devpi_admin/static/js/api.js +7 -0
  5. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/src/devpi_admin/static/js/app.js +168 -83
  6. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/.gitignore +0 -0
  7. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/LICENSE +0 -0
  8. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/README.md +0 -0
  9. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/pyproject.toml +0 -0
  10. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/src/devpi_admin/__init__.py +0 -0
  11. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/src/devpi_admin/main.py +0 -0
  12. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/src/devpi_admin/static/index.html +0 -0
  13. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/src/devpi_admin/static/js/marked.min.js +0 -0
  14. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/src/devpi_admin/static/js/theme.js +0 -0
  15. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/tests/__init__.py +0 -0
  16. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/tests/test_cached_versions.py +0 -0
  17. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/tests/test_helpers.py +0 -0
  18. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/tests/test_hooks.py +0 -0
  19. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/tests/test_json_safe.py +0 -0
  20. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/tests/test_package.py +0 -0
  21. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/tests/test_tween.py +0 -0
  22. {devpi_admin-1.1.0 → devpi_admin-1.1.1}/tests/test_wants_html.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devpi-admin
3
- Version: 1.1.0
3
+ Version: 1.1.1
4
4
  Summary: Modern web UI plugin for devpi-server — drop-in replacement for devpi-web
5
5
  Author-email: Pavel Revak <pavelrevak@gmail.com>
6
6
  License: MIT
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.1.0'
22
- __version_tuple__ = version_tuple = (1, 1, 0)
21
+ __version__ = version = '1.1.1'
22
+ __version_tuple__ = version_tuple = (1, 1, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -198,15 +198,27 @@ body {
198
198
  font-weight: 600;
199
199
  color: var(--text);
200
200
  background: var(--bg);
201
+ cursor: pointer;
202
+ }
203
+
204
+ .user-btn-name:hover {
205
+ background: var(--bg-alt);
206
+ color: var(--accent);
207
+ }
208
+
209
+ .user-btn-sep {
210
+ padding: 4px 0;
211
+ color: var(--border);
212
+ font-size: 0.85em;
213
+ user-select: none;
201
214
  }
202
215
 
203
216
  .user-btn-action {
204
217
  padding: 4px 10px;
205
218
  color: var(--text-muted);
206
- border-left: 1px solid var(--border);
207
219
  }
208
220
 
209
- .user-btn:hover .user-btn-action {
221
+ .user-btn-action:hover {
210
222
  background: var(--error);
211
223
  color: #fff;
212
224
  }
@@ -351,6 +363,31 @@ body {
351
363
  flex-shrink: 0;
352
364
  }
353
365
 
366
+ /* --- User cards --- */
367
+
368
+ .user-card {
369
+ border-left-color: var(--text-faint);
370
+ }
371
+
372
+ .user-card .kebab-menu {
373
+ margin-left: auto;
374
+ }
375
+
376
+ .user-card-indexes {
377
+ display: flex;
378
+ flex-wrap: wrap;
379
+ gap: 4px;
380
+ }
381
+
382
+ .user-card-indexes .tag {
383
+ text-decoration: none;
384
+ cursor: pointer;
385
+ }
386
+
387
+ .user-card-indexes .tag:hover {
388
+ opacity: 0.8;
389
+ }
390
+
354
391
  /* --- Index cards --- */
355
392
 
356
393
  .index-grid {
@@ -26,6 +26,13 @@ var Api = (function () {
26
26
  }
27
27
  return fetch(url, opts).then(function (res) {
28
28
  if (res.status === 204) return null;
29
+ if (res.status === 401 && _user) {
30
+ logout();
31
+ if (typeof onSessionExpired === 'function') onSessionExpired();
32
+ var err = new Error('Session expired. Please log in again.');
33
+ err.status = 401;
34
+ throw err;
35
+ }
29
36
  return res.json().then(function (json) {
30
37
  if (!res.ok) {
31
38
  var msg = json.message || json.error || 'Request failed';
@@ -405,13 +405,7 @@
405
405
  }
406
406
 
407
407
  function handleApiError(err) {
408
- if (err && err.status === 401) {
409
- Api.logout();
410
- updateAuthUI();
411
- showError(new Error('Session expired. Please log in again.'));
412
- } else {
413
- showError(err);
414
- }
408
+ showError(err);
415
409
  }
416
410
 
417
411
  function updateNav() {
@@ -462,11 +456,14 @@
462
456
  var user = Api.getUser();
463
457
  if (user) {
464
458
  clear(logoutBtn);
465
- logoutBtn.appendChild(el('span', {className: 'user-btn-name', textContent: user}));
459
+ var nameSpan = el('span', {className: 'user-btn-name', textContent: user});
460
+ nameSpan.title = 'Change password';
461
+ logoutBtn.appendChild(nameSpan);
462
+ logoutBtn.appendChild(el('span', {className: 'user-btn-sep', textContent: '|'}));
466
463
  logoutBtn.appendChild(el('span', {className: 'user-btn-action', textContent: 'Logout'}));
467
464
  loginBtn.hidden = true;
468
465
  logoutBtn.hidden = false;
469
- navUsers.hidden = false;
466
+ navUsers.hidden = user !== 'root';
470
467
  document.body.classList.add('authenticated');
471
468
  } else {
472
469
  loginBtn.hidden = false;
@@ -515,6 +512,7 @@
515
512
  closeModal();
516
513
  updateAuthUI();
517
514
  navigate();
515
+ _triggerPasswordSave(user, pass);
518
516
  })
519
517
  .catch(showModalError);
520
518
  }
@@ -545,7 +543,19 @@
545
543
  });
546
544
  }
547
545
 
548
- logoutBtn.addEventListener('click', function () {
546
+ logoutBtn.addEventListener('click', function (e) {
547
+ // Clicking the username part opens change-password modal
548
+ if (e.target.classList.contains('user-btn-name')) {
549
+ var user = Api.getUser();
550
+ if (user) {
551
+ fetchRoot().then(function (result) {
552
+ showUserModal(user, result[user] || {});
553
+ }).catch(function () {
554
+ showUserModal(user, {});
555
+ });
556
+ }
557
+ return;
558
+ }
549
559
  Api.logout();
550
560
  updateAuthUI();
551
561
  window.location.hash = '#';
@@ -581,7 +591,7 @@
581
591
  } else if ((m = path.match(/^package\/([^/]+\/[^/]+)\/(.+)$/))) {
582
592
  loadPackageDetail(m[1], m[2], query.version);
583
593
  } else if (path === 'users') {
584
- if (!Api.getUser()) {
594
+ if (!Api.getUser() || Api.getUser() !== 'root') {
585
595
  loadStatus();
586
596
  return;
587
597
  }
@@ -603,73 +613,82 @@
603
613
  showLoading();
604
614
  fetchRoot().then(function (result) {
605
615
  clear(content);
606
- var header = el('div', {className: 'view-header'}, [
607
- el('h2', {textContent: 'Users'}),
608
- el('button', {
616
+ var headerChildren = [el('h2', {textContent: 'Users'})];
617
+ if (Api.getUser() === 'root') {
618
+ headerChildren.push(el('button', {
609
619
  className: 'btn btn-primary',
610
620
  textContent: '+ New User',
611
621
  onclick: function () { showUserModal(null, null); },
612
- }),
613
- ]);
622
+ }));
623
+ }
624
+ var header = el('div', {className: 'view-header'}, headerChildren);
614
625
  content.appendChild(header);
615
626
 
616
627
  var userNames = getAllUserNames(result);
617
- var table = el('table', {className: 'data-table'});
618
- var thead = el('thead');
619
- thead.appendChild(el('tr', null, [
620
- el('th', {textContent: 'User'}),
621
- el('th', {textContent: 'Email'}),
622
- el('th', {textContent: 'Indexes'}),
623
- el('th', {textContent: 'Actions'}),
624
- ]));
625
- table.appendChild(thead);
626
- var tbody = el('tbody');
628
+ var grid = el('div', {className: 'index-grid'});
627
629
  for (var i = 0; i < userNames.length; i++) {
628
630
  (function (name) {
629
631
  var info = result[name];
630
632
  var indexes = info.indexes || {};
631
- var indexNames = Object.keys(indexes);
632
- var indexCell = el('td');
633
- var indexList = el('div', {className: 'index-list'});
634
- for (var j = 0; j < indexNames.length; j++) {
635
- var idx = indexes[indexNames[j]];
636
- var tagClass = 'tag';
637
- if (idx.type === 'mirror') tagClass += ' tag-mirror';
638
- if (idx.volatile) tagClass += ' tag-volatile';
639
- indexList.appendChild(
640
- el('span', {
633
+ var indexNames = Object.keys(indexes).sort();
634
+ var currentUser = Api.getUser();
635
+ var canEdit = currentUser === name || currentUser === 'root';
636
+
637
+ var card = el('div', {className: 'index-card user-card'});
638
+
639
+ // Card head: username + kebab menu
640
+ var cardHead = el('div', {className: 'index-card-head'});
641
+ cardHead.appendChild(el('a', {
642
+ href: '#indexes/' + name,
643
+ className: 'index-card-name',
644
+ textContent: name,
645
+ }));
646
+ var menuItems = [];
647
+ if (canEdit) {
648
+ menuItems.push({label: 'Edit', onclick: function () { closeAllKebabs(); showUserModal(name, info); }});
649
+ }
650
+ if (currentUser === 'root' && name !== 'root') {
651
+ menuItems.push({label: 'Delete', danger: true, onclick: function () { closeAllKebabs(); deleteUser(name); }});
652
+ }
653
+ if (menuItems.length) {
654
+ cardHead.appendChild(buildKebabMenu(menuItems));
655
+ }
656
+ card.appendChild(cardHead);
657
+
658
+ // Details
659
+ var details = el('div', {className: 'index-card-details'});
660
+ if (info.email) {
661
+ details.appendChild(el('div', {className: 'index-card-row'}, [
662
+ el('span', {className: 'index-card-label', textContent: 'Email'}),
663
+ el('span', {textContent: info.email}),
664
+ ]));
665
+ }
666
+ if (indexNames.length) {
667
+ var tagsWrap = el('div', {className: 'index-card-row'});
668
+ tagsWrap.appendChild(el('span', {className: 'index-card-label', textContent: 'Indexes'}));
669
+ var tagsGroup = el('div', {className: 'user-card-indexes'});
670
+ for (var j = 0; j < indexNames.length; j++) {
671
+ var idx = indexes[indexNames[j]];
672
+ var tagClass = 'tag';
673
+ if (idx.type === 'mirror') tagClass += ' tag-mirror';
674
+ else if (idx.volatile) tagClass += ' tag-volatile';
675
+ tagsGroup.appendChild(el('a', {
676
+ href: '#packages/' + name + '/' + indexNames[j],
641
677
  className: tagClass,
642
678
  textContent: indexNames[j],
643
- title: idx.type +
644
- (idx.volatile ? ', volatile' : '') +
645
- (idx.bases ? ', bases: ' + idx.bases.join(', ') : ''),
646
- })
647
- );
679
+ title: idx.type + (idx.volatile ? ', volatile' : '') +
680
+ (idx.bases && idx.bases.length ? ', bases: ' + idx.bases.join(', ') : ''),
681
+ }));
682
+ }
683
+ tagsWrap.appendChild(tagsGroup);
684
+ details.appendChild(tagsWrap);
648
685
  }
649
- indexCell.appendChild(indexList);
650
- var actions = el('td', {className: 'actions'}, [
651
- el('button', {
652
- className: 'btn btn-small',
653
- textContent: 'Edit',
654
- onclick: function () { showUserModal(name, info); },
655
- }),
656
- el('button', {
657
- className: 'btn btn-small btn-danger',
658
- textContent: 'Delete',
659
- onclick: function () { deleteUser(name); },
660
- }),
661
- ]);
662
- var tr = el('tr', null, [
663
- el('td', {textContent: name}),
664
- el('td', {textContent: info.email || ''}),
665
- indexCell,
666
- actions,
667
- ]);
668
- tbody.appendChild(tr);
686
+ card.appendChild(details);
687
+
688
+ grid.appendChild(card);
669
689
  })(userNames[i]);
670
690
  }
671
- table.appendChild(tbody);
672
- content.appendChild(table);
691
+ content.appendChild(grid);
673
692
  }).catch(handleApiError);
674
693
  }
675
694
 
@@ -686,9 +705,30 @@
686
705
  id: 'form-email',
687
706
  value: (editInfo && editInfo.email) || '',
688
707
  })));
708
+ var isSelf = isEdit && editName === Api.getUser();
709
+ // Hidden username input so browser associates saved password correctly
710
+ if (isSelf) {
711
+ var hiddenUser = el('input', {type: 'text', id: 'form-hidden-username'});
712
+ hiddenUser.setAttribute('autocomplete', 'username');
713
+ hiddenUser.setAttribute('aria-hidden', 'true');
714
+ hiddenUser.style.display = 'none';
715
+ hiddenUser.value = editName;
716
+ body.appendChild(hiddenUser);
717
+ }
718
+ var pwInput;
719
+ if (isSelf) {
720
+ // Own password: type="password" + autocomplete so browser offers to save
721
+ pwInput = el('input', {type: 'password', id: 'form-password'});
722
+ pwInput.setAttribute('autocomplete', 'new-password');
723
+ } else {
724
+ // Other user: plain text — Safari won't offer to save text fields
725
+ pwInput = el('input', {type: 'text', id: 'form-password'});
726
+ pwInput.setAttribute('autocomplete', 'off');
727
+ pwInput.setAttribute('spellcheck', 'false');
728
+ }
689
729
  body.appendChild(formGroup(
690
730
  isEdit ? 'New Password (leave empty to keep)' : 'Password',
691
- el('input', {type: 'password', id: 'form-password'})
731
+ pwInput
692
732
  ));
693
733
  },
694
734
  [
@@ -722,7 +762,8 @@
722
762
  }
723
763
  }
724
764
  var email = document.getElementById('form-email').value.trim();
725
- var password = document.getElementById('form-password').value;
765
+ var passwordEl = document.getElementById('form-password');
766
+ var password = passwordEl ? passwordEl.value : null;
726
767
 
727
768
  if (email) data.email = email;
728
769
  if (password) data.password = password;
@@ -732,12 +773,43 @@
732
773
  var method = isEdit ? Api.patch : Api.put;
733
774
  method(url, data)
734
775
  .then(function () {
735
- closeModal();
776
+ if (password && isEdit && editName === Api.getUser()) {
777
+ // Own password — trigger "Save Password" before closing modal
778
+ closeModal();
779
+ _triggerPasswordSave(editName, password, 'users');
780
+ } else {
781
+ // Other user — wipe field value before close so browser has nothing to save
782
+ if (passwordEl) passwordEl.value = '';
783
+ closeModal();
784
+ }
736
785
  loadUsers();
737
786
  })
738
787
  .catch(showModalError);
739
788
  }
740
789
 
790
+ function _triggerPasswordSave(user, password, hash) {
791
+ var form = document.createElement('form');
792
+ form.method = 'post';
793
+ form.action = '/+admin' + (hash ? '#' + hash : ''); // devpi 302-redirects to /+admin/ — PRG pattern, Ctrl+R won't resubmit
794
+ form.style.cssText = 'display:none';
795
+ var u = document.createElement('input');
796
+ u.type = 'text';
797
+ u.name = 'username';
798
+ u.setAttribute('autocomplete', 'username');
799
+ u.value = user;
800
+ var p = document.createElement('input');
801
+ p.type = 'password';
802
+ p.name = 'password';
803
+ p.setAttribute('autocomplete', 'current-password');
804
+ p.value = password;
805
+ form.appendChild(u);
806
+ form.appendChild(p);
807
+ form.addEventListener('submit', function (e) { e.preventDefault(); });
808
+ document.body.appendChild(form);
809
+ form.submit();
810
+ document.body.removeChild(form);
811
+ }
812
+
741
813
  function deleteUser(name) {
742
814
  if (!confirm('Delete user "' + name + '"? This will also delete all their indexes.')) {
743
815
  return;
@@ -774,16 +846,17 @@
774
846
  headingChildren.push(el('a', {href: '#indexes/' + _filterUser, textContent: _filterUser}));
775
847
  }
776
848
  var heading = el('h2', {className: 'page-heading'}, headingChildren);
777
- headerContainer.appendChild(el('div', {className: 'view-header'}, [
778
- heading,
779
- el('button', {
780
- className: 'btn btn-primary auth-only',
849
+ var viewHeaderChildren = [heading];
850
+ if (Api.getUser() === 'root') {
851
+ viewHeaderChildren.push(el('button', {
852
+ className: 'btn btn-primary',
781
853
  textContent: '+ New Index',
782
854
  onclick: function () {
783
855
  showIndexModal(null, result, _filterUser);
784
856
  },
785
- }),
786
- ]));
857
+ }));
858
+ }
859
+ headerContainer.appendChild(el('div', {className: 'view-header'}, viewHeaderChildren));
787
860
 
788
861
  var container = document.getElementById('indexes-content');
789
862
  clear(container);
@@ -872,15 +945,19 @@
872
945
  ]));
873
946
  }
874
947
  }
948
+
875
949
  card.appendChild(details);
876
950
 
877
951
  card.appendChild(buildIndexPipBlock(idx._full));
878
952
 
879
- // Kebab menu (top-right)
880
- cardHead.appendChild(buildKebabMenu([
881
- {label: 'Edit', onclick: function () { closeAllKebabs(); showIndexModal(idx, result); }},
882
- {label: 'Delete', danger: true, onclick: function () { closeAllKebabs(); deleteIndex(idx._full); }},
883
- ]));
953
+ // Kebab menu — only for root or the index owner
954
+ var loggedIn = Api.getUser();
955
+ if (loggedIn === 'root' || loggedIn === idx._user) {
956
+ cardHead.appendChild(buildKebabMenu([
957
+ {label: 'Edit', onclick: function () { closeAllKebabs(); showIndexModal(idx, result); }},
958
+ {label: 'Delete', danger: true, onclick: function () { closeAllKebabs(); deleteIndex(idx._full); }},
959
+ ]));
960
+ }
884
961
 
885
962
  grid.appendChild(card);
886
963
  })(indexes[i]);
@@ -940,14 +1017,16 @@
940
1017
  isEdit ? 'Edit Index: ' + editIdx._full : 'New Index',
941
1018
  function (body) {
942
1019
  if (!isEdit) {
1020
+ var currentUser = Api.getUser();
1021
+ var owners = currentUser === 'root' ? userNames : [currentUser];
943
1022
  var ownerSelect = el('select', {id: 'form-owner'});
944
- for (var u = 0; u < userNames.length; u++) {
1023
+ for (var u = 0; u < owners.length; u++) {
945
1024
  ownerSelect.appendChild(el('option', {
946
- value: userNames[u],
947
- textContent: userNames[u],
1025
+ value: owners[u],
1026
+ textContent: owners[u],
948
1027
  }));
949
1028
  }
950
- ownerSelect.value = preOwner || Api.getUser();
1029
+ ownerSelect.value = preOwner || currentUser;
951
1030
  body.appendChild(formGroup('Owner', ownerSelect));
952
1031
  body.appendChild(formGroup('Index Name', el('input', {type: 'text', id: 'form-index-name'})));
953
1032
  }
@@ -992,10 +1071,10 @@
992
1071
  ]),
993
1072
  ]));
994
1073
 
995
- var aclInitial = isEdit ? (editIdx.acl_upload || []) : [Api.getUser()];
1074
+ var aclUploadInitial = isEdit ? (editIdx.acl_upload || []) : [Api.getUser()];
996
1075
  stageFields.appendChild(el('div', {className: 'form-group'}, [
997
1076
  el('label', {textContent: 'ACL Upload'}),
998
- buildTagPicker('form-acl-upload', aclInitial, userNames, [':ANONYMOUS:']),
1077
+ buildTagPicker('form-acl-upload', aclUploadInitial, userNames, [':ANONYMOUS:']),
999
1078
  ]));
1000
1079
 
1001
1080
  mirrorFields.appendChild(formGroup('Mirror URL', el('input', {
@@ -1892,6 +1971,12 @@
1892
1971
 
1893
1972
  // --- Init ---
1894
1973
 
1974
+ window.onSessionExpired = function () {
1975
+ updateAuthUI();
1976
+ closeModal();
1977
+ showError(new Error('Session expired. Please log in again.'));
1978
+ };
1979
+
1895
1980
  Api.restore();
1896
1981
  updateAuthUI();
1897
1982
  updateNav();
File without changes
File without changes
File without changes
File without changes