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.
- package/README.md +111 -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/lib/runnerProcess.js +50 -4
- package/backend/logs/runner-cmqneqerz0000qq01i5ap2rvl.log +22 -0
- package/backend/logs/runner-cmqnfv7kr0000r101aeocm8eu.log +20 -0
- package/backend/logs/runner-cmqnfvb560001r101qoi0phau.log +43 -0
- package/backend/logs/runner-cmqnfvlm20002r101gsyqb837.log +20 -0
- package/backend/logs/runner-cmqnfvqfy0003r101fh41pzx3.log +20 -0
- package/backend/logs/runner-cmqnfvvwo0004r101q4dtqxd2.log +20 -0
- 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/migrations/20260621000000_add_notifications/migration.sql +8 -0
- package/backend/prisma/schema.prisma +123 -10
- package/backend/routes/auth.routes.js +96 -0
- package/backend/routes/node.routes.js +9 -0
- package/backend/routes/runners.routes.js +10 -0
- package/backend/routes/settings.routes.js +71 -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/scripts/manage-runners.mjs +49 -8
- package/backend/server.js +22 -1
- package/backend/services/cronService.js +91 -7
- package/backend/services/notificationService.js +163 -0
- package/backend/services/reportService.js +96 -4
- package/backend/services/settingsService.js +46 -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 +96 -7
- 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/schedules.js +5 -1
- package/frontend/src/lib/api/settings.js +15 -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 +321 -31
- 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 +11 -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/scheduled-tests/+page.svelte +65 -7
- package/frontend/src/routes/settings/+page.svelte +677 -6
- 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,7 +18,15 @@
|
|
|
18
18
|
<script>
|
|
19
19
|
import { onMount } from 'svelte';
|
|
20
20
|
import { fly } from 'svelte/transition';
|
|
21
|
-
import {
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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={() => (
|
|
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>
|