plum-e2e 1.3.6 → 2.1.0

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 (47) hide show
  1. package/README.md +61 -3
  2. package/backend/app.js +5 -0
  3. package/backend/config/scripts/create-test.mjs +172 -0
  4. package/backend/config/scripts/generate-report.js +2 -1
  5. package/backend/middleware/jwtAuth.js +33 -0
  6. package/backend/middleware/requireAdmin.js +25 -0
  7. package/backend/package.json +2 -0
  8. package/backend/prisma/migrations/20260618000000_add_test_repository/migration.sql +133 -0
  9. package/backend/prisma/migrations/20260618000001_add_user_roles/migration.sql +3 -0
  10. package/backend/prisma/migrations/20260618000002_drop_automated_tag/migration.sql +2 -0
  11. package/backend/prisma/migrations/20260618000003_entry_assignee/migration.sql +2 -0
  12. package/backend/prisma/schema.prisma +118 -10
  13. package/backend/routes/auth.routes.js +96 -0
  14. package/backend/routes/settings.routes.js +44 -8
  15. package/backend/routes/test-cases.routes.js +80 -0
  16. package/backend/routes/test-runs.routes.js +122 -0
  17. package/backend/routes/test-suites.routes.js +92 -0
  18. package/backend/routes/users.routes.js +67 -0
  19. package/backend/scripts/create-test.js +7 -6
  20. package/backend/services/reportService.js +96 -4
  21. package/backend/services/runnerService.js +16 -1
  22. package/backend/services/settingsService.js +18 -2
  23. package/backend/services/testCaseService.js +139 -0
  24. package/backend/services/testRunService.js +203 -0
  25. package/backend/services/testSuiteService.js +191 -0
  26. package/backend/services/userService.js +114 -0
  27. package/backend/websockets/socketHandler.js +19 -6
  28. package/bin/plum.js +105 -9
  29. package/frontend/src/lib/api/auth.js +69 -0
  30. package/frontend/src/lib/api/repository.js +256 -0
  31. package/frontend/src/lib/api/users.js +52 -0
  32. package/frontend/src/lib/components/layout/Nav.svelte +116 -4
  33. package/frontend/src/lib/components/layout/RunnerPanel.svelte +243 -29
  34. package/frontend/src/lib/components/ui/Modal.svelte +8 -1
  35. package/frontend/src/lib/constants.js +2 -0
  36. package/frontend/src/lib/stores/auth.js +60 -0
  37. package/frontend/src/lib/stores/runner.js +9 -2
  38. package/frontend/src/routes/+layout.svelte +32 -4
  39. package/frontend/src/routes/+page.svelte +1 -1
  40. package/frontend/src/routes/login/+page.svelte +209 -0
  41. package/frontend/src/routes/scheduled-tests/+page.svelte +3 -1
  42. package/frontend/src/routes/settings/+page.svelte +586 -5
  43. package/frontend/src/routes/setup/+page.svelte +249 -0
  44. package/frontend/src/routes/test-repository/+page.svelte +1379 -0
  45. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
  46. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
  47. package/package.json +1 -1
@@ -18,6 +18,7 @@
18
18
  <script>
19
19
  import { onMount } from 'svelte';
20
20
  import { fly } from 'svelte/transition';
21
+ import { goto } from '$app/navigation';
21
22
  import { fetchProject, saveProject, exportBackup, importBackup } from '$lib/api/settings';
22
23
  import {
23
24
  fetchRunners,
@@ -27,19 +28,57 @@
27
28
  pingRunner,
28
29
  probeRunner
29
30
  } from '$lib/api/runners';
31
+ import { fetchPrefixes, savePrefixes, migratePrefixes } from '$lib/api/repository';
32
+ import { updateProfile, changePassword } from '$lib/api/auth';
33
+ import {
34
+ fetchUsers,
35
+ createUser as createUserApi,
36
+ deleteUser as deleteUserApi
37
+ } from '$lib/api/users';
30
38
  import { builtInEnabled } from '$lib/stores/runner';
39
+ import { auth } from '$lib/stores/auth';
31
40
  import { theme } from '$lib/stores/theme';
32
41
  import { BROWSERS, TOAST_TIMEOUT_MS } from '$lib/constants';
33
42
  import Button from '$lib/components/ui/Button.svelte';
34
43
  import Toast from '$lib/components/ui/Toast.svelte';
44
+ import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
45
+
46
+ /** @type {'project' | 'runners' | 'repository' | 'account' | 'users' | 'backup'} */
47
+ let section =
48
+ (typeof sessionStorage !== 'undefined' && sessionStorage.getItem('plum:settings:section')) ||
49
+ 'project';
35
50
 
36
- /** @type {'project' | 'runners' | 'backup'} */
37
- let section = 'project';
51
+ function setSection(s) {
52
+ section = s;
53
+ try {
54
+ sessionStorage.setItem('plum:settings:section', s);
55
+ } catch {}
56
+ }
38
57
 
39
58
  let project = { name: '', logoUrl: '' };
40
59
  let projectSaving = false;
41
60
  let toast = null;
42
61
 
62
+ let prefixes = { testCasePrefix: 'TC', testSuitePrefix: 'TS' };
63
+ let prefixesSaving = false;
64
+ let migrateForm = { testCasePrefix: '', testSuitePrefix: '' };
65
+ let migrating = false;
66
+
67
+ let profileForm = { name: '', email: '' };
68
+ let profileSaving = false;
69
+ let profileError = '';
70
+
71
+ let allUsers = [];
72
+ let userForm = { name: '', email: '', password: '', role: 'user' };
73
+ let userFormSaving = false;
74
+ let userFormError = '';
75
+ let confirmDeleteUser = null;
76
+ let confirmDeleteUserOpen = false;
77
+
78
+ let pwForm = { currentPassword: '', newPassword: '', confirmPassword: '' };
79
+ let pwSaving = false;
80
+ let pwError = '';
81
+
43
82
  let importFile = null;
44
83
  let importing = false;
45
84
  let exporting = false;
@@ -75,6 +114,21 @@
75
114
  runners = await fetchRunners();
76
115
  runnersLoaded = true;
77
116
  } catch {}
117
+ try {
118
+ prefixes = await fetchPrefixes();
119
+ migrateForm = {
120
+ testCasePrefix: prefixes.testCasePrefix,
121
+ testSuitePrefix: prefixes.testSuitePrefix
122
+ };
123
+ } catch {}
124
+ if ($auth.user) {
125
+ profileForm = { name: $auth.user.name, email: $auth.user.email };
126
+ }
127
+ if ($auth.user?.role === 'admin') {
128
+ try {
129
+ allUsers = await fetchUsers();
130
+ } catch {}
131
+ }
78
132
  });
79
133
 
80
134
  $: if (section === 'runners' && runnersLoaded) pingAll();
@@ -235,9 +289,117 @@
235
289
  importFile = e.target.files[0] ?? null;
236
290
  }
237
291
 
238
- const navItems = [
292
+ async function handleSavePrefixes() {
293
+ prefixesSaving = true;
294
+ try {
295
+ prefixes = await savePrefixes(prefixes);
296
+ showToast('success', 'Prefixes saved.');
297
+ } catch {
298
+ showToast('error', 'Failed to save prefixes.');
299
+ } finally {
300
+ prefixesSaving = false;
301
+ }
302
+ }
303
+
304
+ async function handleMigratePrefixes() {
305
+ migrating = true;
306
+ try {
307
+ await migratePrefixes(migrateForm);
308
+ prefixes = { ...prefixes, ...migrateForm };
309
+ showToast('success', 'Prefix migration complete. All IDs updated.');
310
+ } catch {
311
+ showToast('error', 'Migration failed.');
312
+ } finally {
313
+ migrating = false;
314
+ }
315
+ }
316
+
317
+ async function handleUpdateProfile() {
318
+ profileError = '';
319
+ profileSaving = true;
320
+ try {
321
+ const { user } = await updateProfile({
322
+ token: $auth.token,
323
+ name: profileForm.name,
324
+ email: profileForm.email
325
+ });
326
+ auth.login($auth.token, { ...$auth.user, ...user });
327
+ showToast('success', 'Profile updated.');
328
+ } catch (e) {
329
+ profileError = e.message;
330
+ } finally {
331
+ profileSaving = false;
332
+ }
333
+ }
334
+
335
+ async function handleCreateUser() {
336
+ userFormError = '';
337
+ if (!userForm.name || !userForm.email || !userForm.password) {
338
+ userFormError = 'Name, email and password are required.';
339
+ return;
340
+ }
341
+ userFormSaving = true;
342
+ try {
343
+ const user = await createUserApi(userForm);
344
+ allUsers = [...allUsers, user];
345
+ userForm = { name: '', email: '', password: '', role: 'user' };
346
+ showToast('success', `User "${user.name}" added.`);
347
+ } catch (e) {
348
+ userFormError = e.message;
349
+ } finally {
350
+ userFormSaving = false;
351
+ }
352
+ }
353
+
354
+ async function handleDeleteUser(id, name) {
355
+ try {
356
+ await deleteUserApi(id);
357
+ allUsers = allUsers.filter((u) => u.id !== id);
358
+ showToast('success', `User "${name}" removed.`);
359
+ } catch (e) {
360
+ showToast('error', e.message);
361
+ }
362
+ confirmDeleteUser = null;
363
+ confirmDeleteUserOpen = false;
364
+ }
365
+
366
+ async function handleChangePassword() {
367
+ pwError = '';
368
+ if (pwForm.newPassword !== pwForm.confirmPassword) {
369
+ pwError = 'Passwords do not match.';
370
+ return;
371
+ }
372
+ if (pwForm.newPassword.length < 8) {
373
+ pwError = 'Password must be at least 8 characters.';
374
+ return;
375
+ }
376
+ pwSaving = true;
377
+ try {
378
+ await changePassword({
379
+ token: $auth.token,
380
+ currentPassword: pwForm.currentPassword,
381
+ newPassword: pwForm.newPassword
382
+ });
383
+ pwForm = { currentPassword: '', newPassword: '', confirmPassword: '' };
384
+ showToast('success', 'Password changed.');
385
+ } catch (e) {
386
+ pwError = e.message;
387
+ } finally {
388
+ pwSaving = false;
389
+ }
390
+ }
391
+
392
+ function handleLogout() {
393
+ auth.logout();
394
+ goto('/login');
395
+ }
396
+
397
+ $: navItems = [
239
398
  { id: 'project', label: 'Project' },
240
399
  { id: 'runners', label: 'Runners' },
400
+ { id: 'repository', label: 'Repository' },
401
+ { id: 'account', label: 'Account' },
402
+ ...($auth.user?.role === 'admin' ? [{ id: 'users', label: 'Users' }] : []),
241
403
  { id: 'backup', label: 'Backup' }
242
404
  ];
243
405
  </script>
@@ -258,7 +420,7 @@
258
420
  <button
259
421
  class="sidebar-item"
260
422
  class:active={section === item.id}
261
- on:click={() => (section = item.id)}
423
+ on:click={() => setSection(item.id)}
262
424
  >
263
425
  {item.label}
264
426
  </button>
@@ -563,6 +725,293 @@
563
725
  </div>
564
726
  </div>
565
727
 
728
+ <!-- REPOSITORY -->
729
+ {:else if section === 'repository'}
730
+ <div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
731
+ <div class="content-header">
732
+ <h2>Test Repository</h2>
733
+ <p class="content-desc">Configure ID prefixes for test suites and cases.</p>
734
+ </div>
735
+
736
+ <div class="card settings-card">
737
+ <div class="field-row">
738
+ <div class="field">
739
+ <label class="field-label" for="tc-prefix">Test Case prefix</label>
740
+ <input
741
+ id="tc-prefix"
742
+ type="text"
743
+ class="field-input mono"
744
+ bind:value={prefixes.testCasePrefix}
745
+ placeholder="TC"
746
+ maxlength="10"
747
+ />
748
+ </div>
749
+ <div class="field">
750
+ <label class="field-label" for="ts-prefix">Test Suite prefix</label>
751
+ <input
752
+ id="ts-prefix"
753
+ type="text"
754
+ class="field-input mono"
755
+ bind:value={prefixes.testSuitePrefix}
756
+ placeholder="TS"
757
+ maxlength="10"
758
+ />
759
+ </div>
760
+ </div>
761
+ <p class="content-desc">
762
+ Examples: <code class="code-sample">{prefixes.testCasePrefix || 'TC'}-001</code>,
763
+ <code class="code-sample">{prefixes.testSuitePrefix || 'TS'}-001</code>
764
+ </p>
765
+ <div class="card-footer">
766
+ <Button on:click={handleSavePrefixes} disabled={prefixesSaving}>
767
+ {prefixesSaving ? 'Saving…' : 'Save Prefixes'}
768
+ </Button>
769
+ </div>
770
+ </div>
771
+
772
+ <div class="content-header" style="margin-top: 1rem">
773
+ <h2>Migrate IDs</h2>
774
+ <p class="content-desc">
775
+ Rename all existing test IDs to use a new prefix. Cucumber tags in code are <strong
776
+ >not</strong
777
+ > affected — you manage those separately.
778
+ </p>
779
+ </div>
780
+
781
+ <div class="card settings-card">
782
+ <div class="field-row">
783
+ <div class="field">
784
+ <label class="field-label" for="mig-tc">New case prefix</label>
785
+ <input
786
+ id="mig-tc"
787
+ type="text"
788
+ class="field-input mono"
789
+ bind:value={migrateForm.testCasePrefix}
790
+ placeholder={prefixes.testCasePrefix}
791
+ maxlength="10"
792
+ />
793
+ </div>
794
+ <div class="field">
795
+ <label class="field-label" for="mig-ts">New suite prefix</label>
796
+ <input
797
+ id="mig-ts"
798
+ type="text"
799
+ class="field-input mono"
800
+ bind:value={migrateForm.testSuitePrefix}
801
+ placeholder={prefixes.testSuitePrefix}
802
+ maxlength="10"
803
+ />
804
+ </div>
805
+ </div>
806
+ <div class="card-footer">
807
+ <Button variant="ghost" on:click={handleMigratePrefixes} disabled={migrating}>
808
+ {migrating ? 'Migrating…' : 'Run Migration'}
809
+ </Button>
810
+ </div>
811
+ </div>
812
+ </div>
813
+
814
+ <!-- ACCOUNT -->
815
+ {:else if section === 'account'}
816
+ <div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
817
+ <div class="content-header">
818
+ <h2>Account</h2>
819
+ <p class="content-desc">Manage your profile, credentials and session.</p>
820
+ </div>
821
+
822
+ <div class="card settings-card">
823
+ <p class="card-title">Profile</p>
824
+ <div class="field">
825
+ <label class="field-label" for="profile-name">Name</label>
826
+ <input
827
+ id="profile-name"
828
+ type="text"
829
+ class="field-input"
830
+ bind:value={profileForm.name}
831
+ />
832
+ </div>
833
+ <div class="field">
834
+ <label class="field-label" for="profile-email">Email</label>
835
+ <input
836
+ id="profile-email"
837
+ type="email"
838
+ class="field-input"
839
+ bind:value={profileForm.email}
840
+ />
841
+ </div>
842
+ {#if profileError}<p class="form-error">{profileError}</p>{/if}
843
+ <div class="card-footer">
844
+ <Button
845
+ on:click={handleUpdateProfile}
846
+ disabled={profileSaving || !profileForm.name || !profileForm.email}
847
+ >
848
+ {profileSaving ? 'Saving…' : 'Save Profile'}
849
+ </Button>
850
+ </div>
851
+ </div>
852
+
853
+ <div class="card settings-card">
854
+ <p class="card-title">Change password</p>
855
+ <div class="field">
856
+ <label class="field-label" for="pw-current">Current password</label>
857
+ <input
858
+ id="pw-current"
859
+ type="password"
860
+ class="field-input"
861
+ bind:value={pwForm.currentPassword}
862
+ autocomplete="current-password"
863
+ />
864
+ </div>
865
+ <div class="field">
866
+ <label class="field-label" for="pw-new">New password</label>
867
+ <input
868
+ id="pw-new"
869
+ type="password"
870
+ class="field-input"
871
+ bind:value={pwForm.newPassword}
872
+ autocomplete="new-password"
873
+ />
874
+ </div>
875
+ <div class="field">
876
+ <label class="field-label" for="pw-confirm">Confirm new password</label>
877
+ <input
878
+ id="pw-confirm"
879
+ type="password"
880
+ class="field-input"
881
+ bind:value={pwForm.confirmPassword}
882
+ autocomplete="new-password"
883
+ />
884
+ </div>
885
+ {#if pwError}<p class="form-error">{pwError}</p>{/if}
886
+ <div class="card-footer">
887
+ <Button
888
+ on:click={handleChangePassword}
889
+ disabled={pwSaving || !pwForm.currentPassword || !pwForm.newPassword}
890
+ >
891
+ {pwSaving ? 'Saving…' : 'Change Password'}
892
+ </Button>
893
+ </div>
894
+ </div>
895
+
896
+ <div class="card settings-card">
897
+ <div class="card-footer">
898
+ <Button variant="danger" size="sm" on:click={handleLogout}>Sign out</Button>
899
+ </div>
900
+ </div>
901
+ </div>
902
+
903
+ <!-- USERS (admin only) -->
904
+ {:else if section === 'users'}
905
+ <div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
906
+ <div class="content-header">
907
+ <h2>Users</h2>
908
+ <p class="content-desc">Add and manage who can access Plum.</p>
909
+ </div>
910
+
911
+ <ConfirmModal
912
+ bind:open={confirmDeleteUserOpen}
913
+ title="Remove User"
914
+ confirmLabel="Remove"
915
+ on:confirm={() => handleDeleteUser(confirmDeleteUser?.id, confirmDeleteUser?.name)}
916
+ >
917
+ {#if confirmDeleteUser}
918
+ Remove <strong>{confirmDeleteUser.name}</strong>? They will lose access immediately.
919
+ {/if}
920
+ </ConfirmModal>
921
+
922
+ <div class="card settings-card">
923
+ <p class="card-title">Add User</p>
924
+ <div class="field-row">
925
+ <div class="field">
926
+ <label class="field-label" for="u-name">Name</label>
927
+ <input
928
+ id="u-name"
929
+ type="text"
930
+ class="field-input"
931
+ bind:value={userForm.name}
932
+ placeholder="Jane Smith"
933
+ />
934
+ </div>
935
+ <div class="field">
936
+ <label class="field-label" for="u-email">Email</label>
937
+ <input
938
+ id="u-email"
939
+ type="email"
940
+ class="field-input"
941
+ bind:value={userForm.email}
942
+ placeholder="jane@example.com"
943
+ />
944
+ </div>
945
+ </div>
946
+ <div class="field-row">
947
+ <div class="field">
948
+ <label class="field-label" for="u-pw">Password</label>
949
+ <input
950
+ id="u-pw"
951
+ type="password"
952
+ class="field-input"
953
+ bind:value={userForm.password}
954
+ autocomplete="new-password"
955
+ />
956
+ </div>
957
+ <div class="field">
958
+ <label class="field-label" for="u-role">Role</label>
959
+ <select id="u-role" class="field-input" bind:value={userForm.role}>
960
+ <option value="user">User</option>
961
+ <option value="admin">Admin</option>
962
+ </select>
963
+ </div>
964
+ </div>
965
+ {#if userFormError}<p class="form-error">{userFormError}</p>{/if}
966
+ <div class="card-footer">
967
+ <Button on:click={handleCreateUser} disabled={userFormSaving}>
968
+ {userFormSaving ? 'Adding…' : 'Add User'}
969
+ </Button>
970
+ </div>
971
+ </div>
972
+
973
+ {#if allUsers.length > 0}
974
+ <div class="users-table">
975
+ {#each allUsers as u (u.id)}
976
+ <div class="user-row">
977
+ <div class="user-info">
978
+ <span class="user-name">{u.name}</span>
979
+ <span class="user-email">{u.email}</span>
980
+ </div>
981
+ <span class="role-chip {u.role}">{u.role}</span>
982
+ {#if u.id !== $auth.user?.id}
983
+ <button
984
+ class="icon-btn danger"
985
+ title="Remove user"
986
+ on:click={() => {
987
+ confirmDeleteUser = { id: u.id, name: u.name };
988
+ confirmDeleteUserOpen = true;
989
+ }}
990
+ >
991
+ <svg
992
+ width="13"
993
+ height="13"
994
+ viewBox="0 0 24 24"
995
+ fill="none"
996
+ stroke="currentColor"
997
+ stroke-width="2"
998
+ stroke-linecap="round"
999
+ stroke-linejoin="round"
1000
+ >
1001
+ <polyline points="3 6 5 6 21 6" /><path
1002
+ d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"
1003
+ /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
1004
+ </svg>
1005
+ </button>
1006
+ {:else}
1007
+ <span class="you-chip">you</span>
1008
+ {/if}
1009
+ </div>
1010
+ {/each}
1011
+ </div>
1012
+ {/if}
1013
+ </div>
1014
+
566
1015
  <!-- BACKUP -->
567
1016
  {:else if section === 'backup'}
568
1017
  <div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
@@ -1025,6 +1474,48 @@
1025
1474
  background: var(--bg-subtle);
1026
1475
  }
1027
1476
 
1477
+ /* ── Field row (two columns) ── */
1478
+ .field-row {
1479
+ display: grid;
1480
+ grid-template-columns: 1fr 1fr;
1481
+ gap: 0.75rem;
1482
+ }
1483
+
1484
+ .mono {
1485
+ font-family: 'JetBrains Mono', monospace !important;
1486
+ font-size: 0.8125rem !important;
1487
+ }
1488
+
1489
+ .code-sample {
1490
+ font-family: 'JetBrains Mono', monospace;
1491
+ font-size: 0.78rem;
1492
+ background: var(--bg-subtle);
1493
+ padding: 0.1em 0.3em;
1494
+ border-radius: 3px;
1495
+ }
1496
+
1497
+ .account-info {
1498
+ display: flex;
1499
+ flex-direction: column;
1500
+ gap: 0.2rem;
1501
+ }
1502
+
1503
+ .account-name {
1504
+ font-size: 0.9375rem;
1505
+ font-weight: 500;
1506
+ color: var(--text);
1507
+ }
1508
+
1509
+ .account-email {
1510
+ font-size: 0.8125rem;
1511
+ color: var(--text-muted);
1512
+ }
1513
+
1514
+ .form-error {
1515
+ font-size: 0.8125rem;
1516
+ color: var(--fail);
1517
+ }
1518
+
1028
1519
  /* ── Responsive ── */
1029
1520
  @media (max-width: 640px) {
1030
1521
  .settings-layout {
@@ -1050,8 +1541,98 @@
1050
1541
  height: 1px;
1051
1542
  }
1052
1543
 
1053
- .runner-form-fields {
1544
+ .runner-form-fields,
1545
+ .field-row {
1054
1546
  grid-template-columns: 1fr;
1055
1547
  }
1056
1548
  }
1549
+
1550
+ /* ── Users table ── */
1551
+ .users-table {
1552
+ display: flex;
1553
+ flex-direction: column;
1554
+ gap: 0.5rem;
1555
+ margin-top: 1rem;
1556
+ }
1557
+
1558
+ .user-row {
1559
+ display: flex;
1560
+ align-items: center;
1561
+ gap: 0.75rem;
1562
+ background: var(--bg-elevated);
1563
+ border: 1px solid var(--border);
1564
+ border-radius: var(--radius-md);
1565
+ padding: 0.75rem 1rem;
1566
+ }
1567
+
1568
+ .user-info {
1569
+ flex: 1;
1570
+ min-width: 0;
1571
+ display: flex;
1572
+ flex-direction: column;
1573
+ gap: 0.15rem;
1574
+ }
1575
+
1576
+ .user-name {
1577
+ font-size: 0.9rem;
1578
+ font-weight: 500;
1579
+ color: var(--text);
1580
+ }
1581
+
1582
+ .user-email {
1583
+ font-size: 0.8rem;
1584
+ color: var(--text-muted);
1585
+ }
1586
+
1587
+ .role-chip {
1588
+ font-size: 0.7rem;
1589
+ font-weight: 500;
1590
+ border-radius: 100px;
1591
+ padding: 0.15rem 0.55rem;
1592
+ flex-shrink: 0;
1593
+ }
1594
+
1595
+ .role-chip.admin {
1596
+ background: var(--accent-soft);
1597
+ color: var(--accent);
1598
+ }
1599
+
1600
+ .role-chip.user {
1601
+ background: var(--bg-subtle);
1602
+ color: var(--text-muted);
1603
+ border: 1px solid var(--border);
1604
+ }
1605
+
1606
+ .you-chip {
1607
+ font-size: 0.7rem;
1608
+ color: var(--text-muted);
1609
+ background: var(--bg-subtle);
1610
+ border: 1px solid var(--border);
1611
+ border-radius: 100px;
1612
+ padding: 0.15rem 0.55rem;
1613
+ flex-shrink: 0;
1614
+ }
1615
+
1616
+ /* reuse icon-btn from other pages */
1617
+ .icon-btn {
1618
+ display: flex;
1619
+ align-items: center;
1620
+ justify-content: center;
1621
+ width: 28px;
1622
+ height: 28px;
1623
+ border: none;
1624
+ border-radius: var(--radius-sm);
1625
+ background: transparent;
1626
+ cursor: pointer;
1627
+ color: var(--text-muted);
1628
+ transition:
1629
+ background var(--duration-fast),
1630
+ color var(--duration-fast);
1631
+ flex-shrink: 0;
1632
+ }
1633
+
1634
+ .icon-btn.danger:hover {
1635
+ background: var(--fail-soft);
1636
+ color: var(--fail);
1637
+ }
1057
1638
  </style>