plum-e2e 1.3.7 → 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.
- package/README.md +61 -3
- package/backend/app.js +5 -0
- package/backend/config/scripts/create-test.mjs +172 -0
- package/backend/config/scripts/generate-report.js +2 -1
- package/backend/middleware/jwtAuth.js +33 -0
- package/backend/middleware/requireAdmin.js +25 -0
- package/backend/package.json +2 -0
- package/backend/prisma/migrations/20260618000000_add_test_repository/migration.sql +133 -0
- package/backend/prisma/migrations/20260618000001_add_user_roles/migration.sql +3 -0
- package/backend/prisma/migrations/20260618000002_drop_automated_tag/migration.sql +2 -0
- package/backend/prisma/migrations/20260618000003_entry_assignee/migration.sql +2 -0
- package/backend/prisma/schema.prisma +118 -10
- package/backend/routes/auth.routes.js +96 -0
- package/backend/routes/settings.routes.js +44 -8
- package/backend/routes/test-cases.routes.js +80 -0
- package/backend/routes/test-runs.routes.js +122 -0
- package/backend/routes/test-suites.routes.js +92 -0
- package/backend/routes/users.routes.js +67 -0
- package/backend/scripts/create-test.js +7 -6
- package/backend/services/reportService.js +96 -4
- package/backend/services/settingsService.js +18 -2
- package/backend/services/testCaseService.js +139 -0
- package/backend/services/testRunService.js +203 -0
- package/backend/services/testSuiteService.js +191 -0
- package/backend/services/userService.js +114 -0
- package/backend/websockets/socketHandler.js +19 -6
- package/bin/plum.js +105 -9
- package/frontend/src/lib/api/auth.js +69 -0
- package/frontend/src/lib/api/repository.js +256 -0
- package/frontend/src/lib/api/users.js +52 -0
- package/frontend/src/lib/components/layout/Nav.svelte +116 -4
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +243 -29
- package/frontend/src/lib/components/ui/Modal.svelte +8 -1
- package/frontend/src/lib/constants.js +2 -0
- package/frontend/src/lib/stores/auth.js +60 -0
- package/frontend/src/lib/stores/runner.js +9 -2
- package/frontend/src/routes/+layout.svelte +32 -4
- package/frontend/src/routes/+page.svelte +1 -1
- package/frontend/src/routes/login/+page.svelte +209 -0
- package/frontend/src/routes/settings/+page.svelte +586 -5
- package/frontend/src/routes/setup/+page.svelte +249 -0
- package/frontend/src/routes/test-repository/+page.svelte +1379 -0
- package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
- package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
- 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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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={() => (
|
|
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>
|