plum-e2e 1.3.7 → 2.2.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 (62) hide show
  1. package/README.md +111 -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/lib/runnerProcess.js +50 -4
  6. package/backend/logs/runner-cmqneqerz0000qq01i5ap2rvl.log +22 -0
  7. package/backend/logs/runner-cmqnfv7kr0000r101aeocm8eu.log +20 -0
  8. package/backend/logs/runner-cmqnfvb560001r101qoi0phau.log +43 -0
  9. package/backend/logs/runner-cmqnfvlm20002r101gsyqb837.log +20 -0
  10. package/backend/logs/runner-cmqnfvqfy0003r101fh41pzx3.log +20 -0
  11. package/backend/logs/runner-cmqnfvvwo0004r101q4dtqxd2.log +20 -0
  12. package/backend/middleware/jwtAuth.js +33 -0
  13. package/backend/middleware/requireAdmin.js +25 -0
  14. package/backend/package.json +2 -0
  15. package/backend/prisma/migrations/20260618000000_add_test_repository/migration.sql +133 -0
  16. package/backend/prisma/migrations/20260618000001_add_user_roles/migration.sql +3 -0
  17. package/backend/prisma/migrations/20260618000002_drop_automated_tag/migration.sql +2 -0
  18. package/backend/prisma/migrations/20260618000003_entry_assignee/migration.sql +2 -0
  19. package/backend/prisma/migrations/20260621000000_add_notifications/migration.sql +8 -0
  20. package/backend/prisma/schema.prisma +123 -10
  21. package/backend/routes/auth.routes.js +96 -0
  22. package/backend/routes/node.routes.js +9 -0
  23. package/backend/routes/runners.routes.js +10 -0
  24. package/backend/routes/settings.routes.js +71 -8
  25. package/backend/routes/test-cases.routes.js +80 -0
  26. package/backend/routes/test-runs.routes.js +122 -0
  27. package/backend/routes/test-suites.routes.js +92 -0
  28. package/backend/routes/users.routes.js +67 -0
  29. package/backend/scripts/create-test.js +7 -6
  30. package/backend/scripts/manage-runners.mjs +49 -8
  31. package/backend/server.js +22 -1
  32. package/backend/services/cronService.js +91 -7
  33. package/backend/services/notificationService.js +163 -0
  34. package/backend/services/reportService.js +96 -4
  35. package/backend/services/settingsService.js +46 -2
  36. package/backend/services/testCaseService.js +139 -0
  37. package/backend/services/testRunService.js +203 -0
  38. package/backend/services/testSuiteService.js +191 -0
  39. package/backend/services/userService.js +114 -0
  40. package/backend/websockets/socketHandler.js +96 -7
  41. package/bin/plum.js +105 -9
  42. package/frontend/src/lib/api/auth.js +69 -0
  43. package/frontend/src/lib/api/repository.js +256 -0
  44. package/frontend/src/lib/api/schedules.js +5 -1
  45. package/frontend/src/lib/api/settings.js +15 -0
  46. package/frontend/src/lib/api/users.js +52 -0
  47. package/frontend/src/lib/components/layout/Nav.svelte +116 -4
  48. package/frontend/src/lib/components/layout/RunnerPanel.svelte +321 -31
  49. package/frontend/src/lib/components/ui/Modal.svelte +8 -1
  50. package/frontend/src/lib/constants.js +2 -0
  51. package/frontend/src/lib/stores/auth.js +60 -0
  52. package/frontend/src/lib/stores/runner.js +11 -2
  53. package/frontend/src/routes/+layout.svelte +32 -4
  54. package/frontend/src/routes/+page.svelte +1 -1
  55. package/frontend/src/routes/login/+page.svelte +209 -0
  56. package/frontend/src/routes/scheduled-tests/+page.svelte +65 -7
  57. package/frontend/src/routes/settings/+page.svelte +677 -6
  58. package/frontend/src/routes/setup/+page.svelte +249 -0
  59. package/frontend/src/routes/test-repository/+page.svelte +1379 -0
  60. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
  61. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
  62. package/package.json +1 -1
@@ -18,7 +18,15 @@
18
18
  <script>
19
19
  import { onMount } from 'svelte';
20
20
  import { fly } from 'svelte/transition';
21
- import { fetchProject, saveProject, exportBackup, importBackup } from '$lib/api/settings';
21
+ import { goto } from '$app/navigation';
22
+ import {
23
+ fetchProject,
24
+ saveProject,
25
+ exportBackup,
26
+ importBackup,
27
+ fetchIntegrations,
28
+ saveIntegrations
29
+ } from '$lib/api/settings';
22
30
  import {
23
31
  fetchRunners,
24
32
  createRunner,
@@ -27,24 +35,65 @@
27
35
  pingRunner,
28
36
  probeRunner
29
37
  } from '$lib/api/runners';
38
+ import { fetchPrefixes, savePrefixes, migratePrefixes } from '$lib/api/repository';
39
+ import { updateProfile, changePassword } from '$lib/api/auth';
40
+ import {
41
+ fetchUsers,
42
+ createUser as createUserApi,
43
+ deleteUser as deleteUserApi
44
+ } from '$lib/api/users';
30
45
  import { builtInEnabled } from '$lib/stores/runner';
46
+ import { auth } from '$lib/stores/auth';
31
47
  import { theme } from '$lib/stores/theme';
32
48
  import { BROWSERS, TOAST_TIMEOUT_MS } from '$lib/constants';
33
49
  import Button from '$lib/components/ui/Button.svelte';
34
50
  import Toast from '$lib/components/ui/Toast.svelte';
51
+ import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
52
+
53
+ /** @type {'project' | 'runners' | 'repository' | 'integrations' | 'account' | 'users' | 'backup'} */
54
+ let section =
55
+ (typeof sessionStorage !== 'undefined' && sessionStorage.getItem('plum:settings:section')) ||
56
+ 'project';
35
57
 
36
- /** @type {'project' | 'runners' | 'backup'} */
37
- let section = 'project';
58
+ function setSection(s) {
59
+ section = s;
60
+ try {
61
+ sessionStorage.setItem('plum:settings:section', s);
62
+ } catch {}
63
+ }
38
64
 
39
65
  let project = { name: '', logoUrl: '' };
40
66
  let projectSaving = false;
41
67
  let toast = null;
42
68
 
69
+ let prefixes = { testCasePrefix: 'TC', testSuitePrefix: 'TS' };
70
+ let prefixesSaving = false;
71
+ let migrateForm = { testCasePrefix: '', testSuitePrefix: '' };
72
+ let migrating = false;
73
+
74
+ let profileForm = { name: '', email: '' };
75
+ let profileSaving = false;
76
+ let profileError = '';
77
+
78
+ let allUsers = [];
79
+ let userForm = { name: '', email: '', password: '', role: 'user' };
80
+ let userFormSaving = false;
81
+ let userFormError = '';
82
+ let confirmDeleteUser = null;
83
+ let confirmDeleteUserOpen = false;
84
+
85
+ let pwForm = { currentPassword: '', newPassword: '', confirmPassword: '' };
86
+ let pwSaving = false;
87
+ let pwError = '';
88
+
43
89
  let importFile = null;
44
90
  let importing = false;
45
91
  let exporting = false;
46
92
  let fileInput;
47
93
 
94
+ let integrations = { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
95
+ let integrationsSaving = false;
96
+
48
97
  let runners = [];
49
98
  let runnerForm = { name: '', url: '', token: '', browser: 'chromium' };
50
99
  let runnerFormError = '';
@@ -75,6 +124,24 @@
75
124
  runners = await fetchRunners();
76
125
  runnersLoaded = true;
77
126
  } catch {}
127
+ try {
128
+ prefixes = await fetchPrefixes();
129
+ migrateForm = {
130
+ testCasePrefix: prefixes.testCasePrefix,
131
+ testSuitePrefix: prefixes.testSuitePrefix
132
+ };
133
+ } catch {}
134
+ try {
135
+ integrations = await fetchIntegrations();
136
+ } catch {}
137
+ if ($auth.user) {
138
+ profileForm = { name: $auth.user.name, email: $auth.user.email };
139
+ }
140
+ if ($auth.user?.role === 'admin') {
141
+ try {
142
+ allUsers = await fetchUsers();
143
+ } catch {}
144
+ }
78
145
  });
79
146
 
80
147
  $: if (section === 'runners' && runnersLoaded) pingAll();
@@ -235,9 +302,130 @@
235
302
  importFile = e.target.files[0] ?? null;
236
303
  }
237
304
 
238
- const navItems = [
305
+ async function handleSavePrefixes() {
306
+ prefixesSaving = true;
307
+ try {
308
+ prefixes = await savePrefixes(prefixes);
309
+ showToast('success', 'Prefixes saved.');
310
+ } catch {
311
+ showToast('error', 'Failed to save prefixes.');
312
+ } finally {
313
+ prefixesSaving = false;
314
+ }
315
+ }
316
+
317
+ async function handleMigratePrefixes() {
318
+ migrating = true;
319
+ try {
320
+ await migratePrefixes(migrateForm);
321
+ prefixes = { ...prefixes, ...migrateForm };
322
+ showToast('success', 'Prefix migration complete. All IDs updated.');
323
+ } catch {
324
+ showToast('error', 'Migration failed.');
325
+ } finally {
326
+ migrating = false;
327
+ }
328
+ }
329
+
330
+ async function handleUpdateProfile() {
331
+ profileError = '';
332
+ profileSaving = true;
333
+ try {
334
+ const { user } = await updateProfile({
335
+ token: $auth.token,
336
+ name: profileForm.name,
337
+ email: profileForm.email
338
+ });
339
+ auth.login($auth.token, { ...$auth.user, ...user });
340
+ showToast('success', 'Profile updated.');
341
+ } catch (e) {
342
+ profileError = e.message;
343
+ } finally {
344
+ profileSaving = false;
345
+ }
346
+ }
347
+
348
+ async function handleCreateUser() {
349
+ userFormError = '';
350
+ if (!userForm.name || !userForm.email || !userForm.password) {
351
+ userFormError = 'Name, email and password are required.';
352
+ return;
353
+ }
354
+ userFormSaving = true;
355
+ try {
356
+ const user = await createUserApi(userForm);
357
+ allUsers = [...allUsers, user];
358
+ userForm = { name: '', email: '', password: '', role: 'user' };
359
+ showToast('success', `User "${user.name}" added.`);
360
+ } catch (e) {
361
+ userFormError = e.message;
362
+ } finally {
363
+ userFormSaving = false;
364
+ }
365
+ }
366
+
367
+ async function handleDeleteUser(id, name) {
368
+ try {
369
+ await deleteUserApi(id);
370
+ allUsers = allUsers.filter((u) => u.id !== id);
371
+ showToast('success', `User "${name}" removed.`);
372
+ } catch (e) {
373
+ showToast('error', e.message);
374
+ }
375
+ confirmDeleteUser = null;
376
+ confirmDeleteUserOpen = false;
377
+ }
378
+
379
+ async function handleChangePassword() {
380
+ pwError = '';
381
+ if (pwForm.newPassword !== pwForm.confirmPassword) {
382
+ pwError = 'Passwords do not match.';
383
+ return;
384
+ }
385
+ if (pwForm.newPassword.length < 8) {
386
+ pwError = 'Password must be at least 8 characters.';
387
+ return;
388
+ }
389
+ pwSaving = true;
390
+ try {
391
+ await changePassword({
392
+ token: $auth.token,
393
+ currentPassword: pwForm.currentPassword,
394
+ newPassword: pwForm.newPassword
395
+ });
396
+ pwForm = { currentPassword: '', newPassword: '', confirmPassword: '' };
397
+ showToast('success', 'Password changed.');
398
+ } catch (e) {
399
+ pwError = e.message;
400
+ } finally {
401
+ pwSaving = false;
402
+ }
403
+ }
404
+
405
+ function handleLogout() {
406
+ auth.logout();
407
+ goto('/login');
408
+ }
409
+
410
+ async function handleSaveIntegrations() {
411
+ integrationsSaving = true;
412
+ try {
413
+ integrations = await saveIntegrations(integrations);
414
+ showToast('success', 'Integration settings saved.');
415
+ } catch {
416
+ showToast('error', 'Failed to save integration settings.');
417
+ } finally {
418
+ integrationsSaving = false;
419
+ }
420
+ }
421
+
422
+ $: navItems = [
239
423
  { id: 'project', label: 'Project' },
240
424
  { id: 'runners', label: 'Runners' },
425
+ { id: 'repository', label: 'Repository' },
426
+ { id: 'integrations', label: 'Integrations' },
427
+ { id: 'account', label: 'Account' },
428
+ ...($auth.user?.role === 'admin' ? [{ id: 'users', label: 'Users' }] : []),
241
429
  { id: 'backup', label: 'Backup' }
242
430
  ];
243
431
  </script>
@@ -258,7 +446,7 @@
258
446
  <button
259
447
  class="sidebar-item"
260
448
  class:active={section === item.id}
261
- on:click={() => (section = item.id)}
449
+ on:click={() => setSection(item.id)}
262
450
  >
263
451
  {item.label}
264
452
  </button>
@@ -563,6 +751,357 @@
563
751
  </div>
564
752
  </div>
565
753
 
754
+ <!-- REPOSITORY -->
755
+ {:else if section === 'repository'}
756
+ <div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
757
+ <div class="content-header">
758
+ <h2>Test Repository</h2>
759
+ <p class="content-desc">Configure ID prefixes for test suites and cases.</p>
760
+ </div>
761
+
762
+ <div class="card settings-card">
763
+ <div class="field-row">
764
+ <div class="field">
765
+ <label class="field-label" for="tc-prefix">Test Case prefix</label>
766
+ <input
767
+ id="tc-prefix"
768
+ type="text"
769
+ class="field-input mono"
770
+ bind:value={prefixes.testCasePrefix}
771
+ placeholder="TC"
772
+ maxlength="10"
773
+ />
774
+ </div>
775
+ <div class="field">
776
+ <label class="field-label" for="ts-prefix">Test Suite prefix</label>
777
+ <input
778
+ id="ts-prefix"
779
+ type="text"
780
+ class="field-input mono"
781
+ bind:value={prefixes.testSuitePrefix}
782
+ placeholder="TS"
783
+ maxlength="10"
784
+ />
785
+ </div>
786
+ </div>
787
+ <p class="content-desc">
788
+ Examples: <code class="code-sample">{prefixes.testCasePrefix || 'TC'}-001</code>,
789
+ <code class="code-sample">{prefixes.testSuitePrefix || 'TS'}-001</code>
790
+ </p>
791
+ <div class="card-footer">
792
+ <Button on:click={handleSavePrefixes} disabled={prefixesSaving}>
793
+ {prefixesSaving ? 'Saving…' : 'Save Prefixes'}
794
+ </Button>
795
+ </div>
796
+ </div>
797
+
798
+ <div class="content-header" style="margin-top: 1rem">
799
+ <h2>Migrate IDs</h2>
800
+ <p class="content-desc">
801
+ Rename all existing test IDs to use a new prefix. Cucumber tags in code are <strong
802
+ >not</strong
803
+ > affected — you manage those separately.
804
+ </p>
805
+ </div>
806
+
807
+ <div class="card settings-card">
808
+ <div class="field-row">
809
+ <div class="field">
810
+ <label class="field-label" for="mig-tc">New case prefix</label>
811
+ <input
812
+ id="mig-tc"
813
+ type="text"
814
+ class="field-input mono"
815
+ bind:value={migrateForm.testCasePrefix}
816
+ placeholder={prefixes.testCasePrefix}
817
+ maxlength="10"
818
+ />
819
+ </div>
820
+ <div class="field">
821
+ <label class="field-label" for="mig-ts">New suite prefix</label>
822
+ <input
823
+ id="mig-ts"
824
+ type="text"
825
+ class="field-input mono"
826
+ bind:value={migrateForm.testSuitePrefix}
827
+ placeholder={prefixes.testSuitePrefix}
828
+ maxlength="10"
829
+ />
830
+ </div>
831
+ </div>
832
+ <div class="card-footer">
833
+ <Button variant="ghost" on:click={handleMigratePrefixes} disabled={migrating}>
834
+ {migrating ? 'Migrating…' : 'Run Migration'}
835
+ </Button>
836
+ </div>
837
+ </div>
838
+ </div>
839
+
840
+ <!-- INTEGRATIONS -->
841
+ {:else if section === 'integrations'}
842
+ <div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
843
+ <div class="content-header">
844
+ <h2>Integrations</h2>
845
+ <p class="content-desc">
846
+ Connect Discord and Slack to receive run notifications with pass/fail results and report
847
+ links.
848
+ </p>
849
+ </div>
850
+
851
+ <div class="card settings-card">
852
+ <p class="card-title">Webhooks</p>
853
+
854
+ <div class="field">
855
+ <label class="field-label" for="discord-url">
856
+ <span>Discord Webhook URL</span>
857
+ <span class="field-hint">Leave blank to disable Discord notifications</span>
858
+ </label>
859
+ <input
860
+ id="discord-url"
861
+ type="url"
862
+ class="field-input"
863
+ bind:value={integrations.discordWebhookUrl}
864
+ placeholder="https://discord.com/api/webhooks/…"
865
+ />
866
+ </div>
867
+
868
+ <div class="field">
869
+ <label class="field-label" for="slack-url">
870
+ <span>Slack Webhook URL</span>
871
+ <span class="field-hint">Leave blank to disable Slack notifications</span>
872
+ </label>
873
+ <input
874
+ id="slack-url"
875
+ type="url"
876
+ class="field-input"
877
+ bind:value={integrations.slackWebhookUrl}
878
+ placeholder="https://hooks.slack.com/services/…"
879
+ />
880
+ </div>
881
+
882
+ <div class="field">
883
+ <label class="field-label" for="public-url">
884
+ <span>Public URL</span>
885
+ <span class="field-hint"
886
+ >Base URL of this Plum instance, used to link reports in notifications</span
887
+ >
888
+ </label>
889
+ <input
890
+ id="public-url"
891
+ type="url"
892
+ class="field-input"
893
+ bind:value={integrations.notifyPublicUrl}
894
+ placeholder="https://plum.yourcompany.com"
895
+ />
896
+ </div>
897
+
898
+ <Button on:click={handleSaveIntegrations} disabled={integrationsSaving}>
899
+ {integrationsSaving ? 'Saving…' : 'Save Integrations'}
900
+ </Button>
901
+ </div>
902
+ </div>
903
+
904
+ <!-- ACCOUNT -->
905
+ {:else if section === 'account'}
906
+ <div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
907
+ <div class="content-header">
908
+ <h2>Account</h2>
909
+ <p class="content-desc">Manage your profile, credentials and session.</p>
910
+ </div>
911
+
912
+ <div class="card settings-card">
913
+ <p class="card-title">Profile</p>
914
+ <div class="field">
915
+ <label class="field-label" for="profile-name">Name</label>
916
+ <input
917
+ id="profile-name"
918
+ type="text"
919
+ class="field-input"
920
+ bind:value={profileForm.name}
921
+ />
922
+ </div>
923
+ <div class="field">
924
+ <label class="field-label" for="profile-email">Email</label>
925
+ <input
926
+ id="profile-email"
927
+ type="email"
928
+ class="field-input"
929
+ bind:value={profileForm.email}
930
+ />
931
+ </div>
932
+ {#if profileError}<p class="form-error">{profileError}</p>{/if}
933
+ <div class="card-footer">
934
+ <Button
935
+ on:click={handleUpdateProfile}
936
+ disabled={profileSaving || !profileForm.name || !profileForm.email}
937
+ >
938
+ {profileSaving ? 'Saving…' : 'Save Profile'}
939
+ </Button>
940
+ </div>
941
+ </div>
942
+
943
+ <div class="card settings-card">
944
+ <p class="card-title">Change password</p>
945
+ <div class="field">
946
+ <label class="field-label" for="pw-current">Current password</label>
947
+ <input
948
+ id="pw-current"
949
+ type="password"
950
+ class="field-input"
951
+ bind:value={pwForm.currentPassword}
952
+ autocomplete="current-password"
953
+ />
954
+ </div>
955
+ <div class="field">
956
+ <label class="field-label" for="pw-new">New password</label>
957
+ <input
958
+ id="pw-new"
959
+ type="password"
960
+ class="field-input"
961
+ bind:value={pwForm.newPassword}
962
+ autocomplete="new-password"
963
+ />
964
+ </div>
965
+ <div class="field">
966
+ <label class="field-label" for="pw-confirm">Confirm new password</label>
967
+ <input
968
+ id="pw-confirm"
969
+ type="password"
970
+ class="field-input"
971
+ bind:value={pwForm.confirmPassword}
972
+ autocomplete="new-password"
973
+ />
974
+ </div>
975
+ {#if pwError}<p class="form-error">{pwError}</p>{/if}
976
+ <div class="card-footer">
977
+ <Button
978
+ on:click={handleChangePassword}
979
+ disabled={pwSaving || !pwForm.currentPassword || !pwForm.newPassword}
980
+ >
981
+ {pwSaving ? 'Saving…' : 'Change Password'}
982
+ </Button>
983
+ </div>
984
+ </div>
985
+
986
+ <div class="card settings-card">
987
+ <div class="card-footer">
988
+ <Button variant="danger" size="sm" on:click={handleLogout}>Sign out</Button>
989
+ </div>
990
+ </div>
991
+ </div>
992
+
993
+ <!-- USERS (admin only) -->
994
+ {:else if section === 'users'}
995
+ <div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
996
+ <div class="content-header">
997
+ <h2>Users</h2>
998
+ <p class="content-desc">Add and manage who can access Plum.</p>
999
+ </div>
1000
+
1001
+ <ConfirmModal
1002
+ bind:open={confirmDeleteUserOpen}
1003
+ title="Remove User"
1004
+ confirmLabel="Remove"
1005
+ on:confirm={() => handleDeleteUser(confirmDeleteUser?.id, confirmDeleteUser?.name)}
1006
+ >
1007
+ {#if confirmDeleteUser}
1008
+ Remove <strong>{confirmDeleteUser.name}</strong>? They will lose access immediately.
1009
+ {/if}
1010
+ </ConfirmModal>
1011
+
1012
+ <div class="card settings-card">
1013
+ <p class="card-title">Add User</p>
1014
+ <div class="field-row">
1015
+ <div class="field">
1016
+ <label class="field-label" for="u-name">Name</label>
1017
+ <input
1018
+ id="u-name"
1019
+ type="text"
1020
+ class="field-input"
1021
+ bind:value={userForm.name}
1022
+ placeholder="Jane Smith"
1023
+ />
1024
+ </div>
1025
+ <div class="field">
1026
+ <label class="field-label" for="u-email">Email</label>
1027
+ <input
1028
+ id="u-email"
1029
+ type="email"
1030
+ class="field-input"
1031
+ bind:value={userForm.email}
1032
+ placeholder="jane@example.com"
1033
+ />
1034
+ </div>
1035
+ </div>
1036
+ <div class="field-row">
1037
+ <div class="field">
1038
+ <label class="field-label" for="u-pw">Password</label>
1039
+ <input
1040
+ id="u-pw"
1041
+ type="password"
1042
+ class="field-input"
1043
+ bind:value={userForm.password}
1044
+ autocomplete="new-password"
1045
+ />
1046
+ </div>
1047
+ <div class="field">
1048
+ <label class="field-label" for="u-role">Role</label>
1049
+ <select id="u-role" class="field-input" bind:value={userForm.role}>
1050
+ <option value="user">User</option>
1051
+ <option value="admin">Admin</option>
1052
+ </select>
1053
+ </div>
1054
+ </div>
1055
+ {#if userFormError}<p class="form-error">{userFormError}</p>{/if}
1056
+ <div class="card-footer">
1057
+ <Button on:click={handleCreateUser} disabled={userFormSaving}>
1058
+ {userFormSaving ? 'Adding…' : 'Add User'}
1059
+ </Button>
1060
+ </div>
1061
+ </div>
1062
+
1063
+ {#if allUsers.length > 0}
1064
+ <div class="users-table">
1065
+ {#each allUsers as u (u.id)}
1066
+ <div class="user-row">
1067
+ <div class="user-info">
1068
+ <span class="user-name">{u.name}</span>
1069
+ <span class="user-email">{u.email}</span>
1070
+ </div>
1071
+ <span class="role-chip {u.role}">{u.role}</span>
1072
+ {#if u.id !== $auth.user?.id}
1073
+ <button
1074
+ class="icon-btn danger"
1075
+ title="Remove user"
1076
+ on:click={() => {
1077
+ confirmDeleteUser = { id: u.id, name: u.name };
1078
+ confirmDeleteUserOpen = true;
1079
+ }}
1080
+ >
1081
+ <svg
1082
+ width="13"
1083
+ height="13"
1084
+ viewBox="0 0 24 24"
1085
+ fill="none"
1086
+ stroke="currentColor"
1087
+ stroke-width="2"
1088
+ stroke-linecap="round"
1089
+ stroke-linejoin="round"
1090
+ >
1091
+ <polyline points="3 6 5 6 21 6" /><path
1092
+ d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"
1093
+ /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
1094
+ </svg>
1095
+ </button>
1096
+ {:else}
1097
+ <span class="you-chip">you</span>
1098
+ {/if}
1099
+ </div>
1100
+ {/each}
1101
+ </div>
1102
+ {/if}
1103
+ </div>
1104
+
566
1105
  <!-- BACKUP -->
567
1106
  {:else if section === 'backup'}
568
1107
  <div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
@@ -1025,6 +1564,48 @@
1025
1564
  background: var(--bg-subtle);
1026
1565
  }
1027
1566
 
1567
+ /* ── Field row (two columns) ── */
1568
+ .field-row {
1569
+ display: grid;
1570
+ grid-template-columns: 1fr 1fr;
1571
+ gap: 0.75rem;
1572
+ }
1573
+
1574
+ .mono {
1575
+ font-family: 'JetBrains Mono', monospace !important;
1576
+ font-size: 0.8125rem !important;
1577
+ }
1578
+
1579
+ .code-sample {
1580
+ font-family: 'JetBrains Mono', monospace;
1581
+ font-size: 0.78rem;
1582
+ background: var(--bg-subtle);
1583
+ padding: 0.1em 0.3em;
1584
+ border-radius: 3px;
1585
+ }
1586
+
1587
+ .account-info {
1588
+ display: flex;
1589
+ flex-direction: column;
1590
+ gap: 0.2rem;
1591
+ }
1592
+
1593
+ .account-name {
1594
+ font-size: 0.9375rem;
1595
+ font-weight: 500;
1596
+ color: var(--text);
1597
+ }
1598
+
1599
+ .account-email {
1600
+ font-size: 0.8125rem;
1601
+ color: var(--text-muted);
1602
+ }
1603
+
1604
+ .form-error {
1605
+ font-size: 0.8125rem;
1606
+ color: var(--fail);
1607
+ }
1608
+
1028
1609
  /* ── Responsive ── */
1029
1610
  @media (max-width: 640px) {
1030
1611
  .settings-layout {
@@ -1050,8 +1631,98 @@
1050
1631
  height: 1px;
1051
1632
  }
1052
1633
 
1053
- .runner-form-fields {
1634
+ .runner-form-fields,
1635
+ .field-row {
1054
1636
  grid-template-columns: 1fr;
1055
1637
  }
1056
1638
  }
1639
+
1640
+ /* ── Users table ── */
1641
+ .users-table {
1642
+ display: flex;
1643
+ flex-direction: column;
1644
+ gap: 0.5rem;
1645
+ margin-top: 1rem;
1646
+ }
1647
+
1648
+ .user-row {
1649
+ display: flex;
1650
+ align-items: center;
1651
+ gap: 0.75rem;
1652
+ background: var(--bg-elevated);
1653
+ border: 1px solid var(--border);
1654
+ border-radius: var(--radius-md);
1655
+ padding: 0.75rem 1rem;
1656
+ }
1657
+
1658
+ .user-info {
1659
+ flex: 1;
1660
+ min-width: 0;
1661
+ display: flex;
1662
+ flex-direction: column;
1663
+ gap: 0.15rem;
1664
+ }
1665
+
1666
+ .user-name {
1667
+ font-size: 0.9rem;
1668
+ font-weight: 500;
1669
+ color: var(--text);
1670
+ }
1671
+
1672
+ .user-email {
1673
+ font-size: 0.8rem;
1674
+ color: var(--text-muted);
1675
+ }
1676
+
1677
+ .role-chip {
1678
+ font-size: 0.7rem;
1679
+ font-weight: 500;
1680
+ border-radius: 100px;
1681
+ padding: 0.15rem 0.55rem;
1682
+ flex-shrink: 0;
1683
+ }
1684
+
1685
+ .role-chip.admin {
1686
+ background: var(--accent-soft);
1687
+ color: var(--accent);
1688
+ }
1689
+
1690
+ .role-chip.user {
1691
+ background: var(--bg-subtle);
1692
+ color: var(--text-muted);
1693
+ border: 1px solid var(--border);
1694
+ }
1695
+
1696
+ .you-chip {
1697
+ font-size: 0.7rem;
1698
+ color: var(--text-muted);
1699
+ background: var(--bg-subtle);
1700
+ border: 1px solid var(--border);
1701
+ border-radius: 100px;
1702
+ padding: 0.15rem 0.55rem;
1703
+ flex-shrink: 0;
1704
+ }
1705
+
1706
+ /* reuse icon-btn from other pages */
1707
+ .icon-btn {
1708
+ display: flex;
1709
+ align-items: center;
1710
+ justify-content: center;
1711
+ width: 28px;
1712
+ height: 28px;
1713
+ border: none;
1714
+ border-radius: var(--radius-sm);
1715
+ background: transparent;
1716
+ cursor: pointer;
1717
+ color: var(--text-muted);
1718
+ transition:
1719
+ background var(--duration-fast),
1720
+ color var(--duration-fast);
1721
+ flex-shrink: 0;
1722
+ }
1723
+
1724
+ .icon-btn.danger:hover {
1725
+ background: var(--fail-soft);
1726
+ color: var(--fail);
1727
+ }
1057
1728
  </style>