254 Commits

Author SHA1 Message Date
810b020d7d Merge pull request 'b999_chrome_file_upload' (#47) from b999_chrome_file_upload into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #47
2025-12-16 13:24:37 +01:00
5be89f83fd add .GLD file verification for drag and drop 2025-12-16 13:20:18 +01:00
2a7783004a fix chrome issue: selecting the same file does not work properly 2025-12-16 12:54:28 +01:00
6f9b386c18 Merge pull request 'implement drag and drop feature' (#46) from f120_drag_and_drop into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #46
2025-12-16 09:43:49 +01:00
b4d9824942 implement drag and drop feature 2025-12-16 09:23:22 +01:00
776cc7a1f0 Merge pull request 'fix file upload for Chrome' (#45) from b119_model_upload_chrome into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #45
2025-12-15 12:19:11 +01:00
eaa75833df fix file upload for Chrome 2025-12-15 12:18:00 +01:00
30d457f562 Merge pull request 'sync dev with main' (#44) from main into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #44
2025-12-15 10:43:14 +01:00
titver968
c1dd9092ea config_prod und dev auf s3 minio 2025-12-10 14:37:03 +01:00
79e0b01796 Merge pull request 'development' (#43) from development into main
Reviewed-on: #43
2025-12-10 10:28:32 +01:00
f008cee404 make share-button secondary style for less emphasis
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-12-10 08:36:43 +01:00
bb91162438 copy link into clipboard and open mail client
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-12-09 11:37:47 +01:00
c078266449 change config to new minio setup for prod
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-12-09 10:26:30 +01:00
922b840c9a fix jenkins pipeline: update packages
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-12-09 10:18:30 +01:00
68823d6ced change config to new minio setup
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
2025-12-09 10:01:56 +01:00
9bb4d23a2d Merge pull request 'main' (#42) from main into development
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
Reviewed-on: #42
2025-12-09 09:34:26 +01:00
titver968
e2cd6945f5 Dockerfile.prod 2025-12-02 14:44:35 +01:00
titver968
ba21f797de src/lib/config.ts -> config.json 2025-12-02 14:36:02 +01:00
titver968
646ff668f7 Dockerfile.prod und tailwind.config.cjs 2025-12-02 14:23:12 +01:00
8b6d35b66f remove old configs 2025-11-25 11:44:32 +01:00
e0d6e3cc62 Merge branch 'development' 2025-11-25 11:43:18 +01:00
1158c88d43 fix SonarQube issues: mainly unused imports
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-11-24 08:41:46 +01:00
e6add823a5 update packages 2025-11-24 08:36:42 +01:00
b1c246113c Merge pull request 'f112_vorgang_operationen' (#41) from f112_vorgang_operationen into development
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
Reviewed-on: #41
2025-11-21 13:12:25 +01:00
5c76e77766 add tests for Vorgang operation: change name and pin 2025-11-21 12:00:14 +01:00
3aee87aaed clarify test case description 2025-11-21 09:39:13 +01:00
97aaf2cd12 fix ´onDelete´ function type 2025-11-21 09:33:41 +01:00
9d35079058 change function parameter name to make it more descriptive: newName -> newValue 2025-11-21 09:26:08 +01:00
73cb398aa0 position PIN code on the same line as the label 2025-11-20 13:05:40 +01:00
365fb0f2c7 allow vorgangPIN to be changed on Vorgang page
includes:
- UI and backend logic
- adjustment to `NameItemEditor` to disallow deletion
2025-11-20 12:54:53 +01:00
c81196343f adjust minor test config: global mock of HTML functions and addition of test-id 2025-11-20 09:52:37 +01:00
c7526be3c9 adjust test to work with adjusted NameItemEditor 2025-11-20 09:50:20 +01:00
b6996902cc implement renaming feature for vorgang
UI and backend logic
make ´NameItemEditor´ reusable to be able to use with Vorgang
2025-11-20 09:46:28 +01:00
b3ba6256e0 remove unused import 2025-11-20 09:37:08 +01:00
9d72a99626 Merge pull request 'f113_UI_fixes' (#40) from f113_UI_fixes into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #40
2025-11-13 12:51:29 +01:00
320f6d6c8b remove Vorgang label 2025-11-13 12:46:58 +01:00
ac79f10153 adjust ´edit´ and ´delete´ button on Vorgang page with crimesList 2025-11-13 12:45:07 +01:00
dac1c57c98 align Vorgang item on list page and remove ´Vorgang´ description 2025-11-13 12:03:51 +01:00
4582306dc8 Merge pull request 'f111_frontend_ueberarbeitung' (#39) from f111_frontend_ueberarbeitung into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #39
2025-11-13 10:11:05 +01:00
64ff7c6e97 add some tests for crimes ´add´-button, further tests to be defined 2025-11-13 09:41:46 +01:00
e1f207f6fe tests for + (plus) button onVorgang list 2025-11-12 08:10:43 +01:00
2e16a0bc03 adjust UI test for removal of ´Hinzufügen´ button 2025-11-11 08:13:14 +01:00
1c4b154e41 add + (plus) button for addition of Vorgaenge and Crimes 2025-11-11 07:57:29 +01:00
b26080f4c1 successful upload modal for crimes 2025-11-10 08:40:52 +01:00
f92bcd5876 successful file upload 2025-11-07 11:17:39 +01:00
939b3174f2 Merge branch 'development' into f111_frontend_ueberarbeitung 2025-11-07 08:24:19 +01:00
44a9669ea4 remove ´Hinzufügen´ button on home view 2025-11-06 12:45:46 +01:00
cc469f67a5 allow for addition of Vorgaenge on Vorgang overview 2025-11-05 12:19:34 +01:00
6b22da6a34 Merge pull request 'f110_undo_skipped_test_API_endpoints' (#38) from f110_undo_skipped_test_API_endpoints into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #38
2025-11-05 10:24:20 +01:00
808b56934c remove unused locals parameter 2025-11-05 09:18:05 +01:00
fd907c9851 move API protection check into hooks, adjusting corresponding tests 2025-11-04 09:22:53 +01:00
3c16bc89e5 undo skipped tests, only allow API calls for admin-views, refactor viewer-page to use page.server 2025-11-03 14:21:08 +01:00
a9e3d8264c Merge pull request 'f092_ViewAuth-von-User-vereinfachen' (#37) from f092_ViewAuth-von-User-vereinfachen into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #37
2025-10-30 13:04:08 +01:00
332a3e5c15 change description of test case: load() now returns undefined if not logged-in 2025-10-30 12:16:21 +01:00
4fc6da850b change invalid user login 2025-10-30 12:04:58 +01:00
36273fd426 fix tests for refactoring of viewer Vorgang-PIN-validation 2025-10-30 11:15:07 +01:00
793ddb17d6 magic strings for login and logout 2025-10-30 10:56:23 +01:00
349d2cea6a named actions for logging in and out 2025-10-30 10:38:11 +01:00
23f2feeefb remove ununsed import 2025-10-30 10:36:50 +01:00
48fe999b5b protect admin pages after refactoring 2025-10-30 10:35:45 +01:00
c857041e21 refactor viewer-login page with error messages and validation 2025-10-30 08:57:58 +01:00
e26b36121a refactor homepage for admin-user and login mask if not logged in 2025-10-29 12:34:38 +01:00
d3e7a3b4ae update packages
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-27 08:42:35 +01:00
35859cf13c fix SonarCube issues
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
2025-10-27 08:37:12 +01:00
416118197b test Login angepasst, return fail wenn formaDaten leer 2025-10-17 12:12:07 +02:00
01afbea9a3 Merge branch 'development' into f092_ViewAuth-von-User-vereinfachen 2025-10-17 10:37:39 +02:00
549ea896c7 select storage path depending on local testing or prod
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-14 10:39:07 +02:00
662211e1c3 make sure the DB is initiated
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-14 09:48:04 +02:00
5082e6d526 select path for DB depending on local or dev environment 2025-10-14 09:40:23 +02:00
9bf85c79e4 clean up, remove console.logs and debugging code 2025-10-14 09:39:06 +02:00
3b0b9d724a testing PV: kick off build
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-14 08:50:55 +02:00
915153cb62 testing PV: kick off build
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-14 08:15:08 +02:00
c0a25c7a26 testing PV: run DB init before server run
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-14 07:46:20 +02:00
b44bac760d testing PV: remove DB init run during build
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-13 13:47:46 +02:00
ddee170aea testing: run DB init script before server start
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-13 13:41:46 +02:00
04b5aaa0dc testing PV: debug DB init script
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-13 13:31:12 +02:00
5128398516 add DB init during container start
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-13 13:10:02 +02:00
69422d1f92 refactoring UUID Anzeige, noch keine Tests angepasst 2025-10-13 13:01:12 +02:00
5d6ac9438d testing PV: temporarily disable test creating dir
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-13 12:58:54 +02:00
76c2e26e8c test persistent storage
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
2025-10-13 12:44:48 +02:00
0ec9692db3 Merge pull request 'f091_PIN-verstecken-in-URL' (#36) from f091_PIN-verstecken-in-URL into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #36
2025-10-13 10:44:43 +02:00
bc53c309df Merge branch 'development' into f091_PIN-verstecken-in-URL 2025-10-13 10:44:20 +02:00
5e3205ae3f Revert "move sqlite DB file to persistent storage"
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
This reverts commit 5d5f140091.
2025-10-13 10:37:10 +02:00
5d5f140091 move sqlite DB file to persistent storage
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-13 09:57:34 +02:00
bb912841f4 test PV: kick off build
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-13 09:16:54 +02:00
e9b42ff85a test persistent volume (PV): modify static text for kick off build
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-10 09:44:32 +02:00
a66f79896a test persistent volume (PV): add static text
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-10 09:27:57 +02:00
cf00f6f12c test persistent volume (PV): skip test for building
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-10 09:13:52 +02:00
01eb80f8ab test: check dir
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
2025-10-10 08:49:41 +02:00
5b9f5e70ec update package-lock.json
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-10-10 08:41:46 +02:00
9c6b2ebd4f Revert "test persistent directory"
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
This reverts commit 57368ca3e4.
2025-10-10 08:04:41 +02:00
b5ee0bd6a7 Revert "testing: display path availability on screen"
This reverts commit 59585e26f1.
2025-10-10 08:04:28 +02:00
59585e26f1 testing: display path availability on screen
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
2025-10-09 14:18:05 +02:00
57368ca3e4 test persistent directory
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
2025-10-09 14:14:11 +02:00
468427622f fix PR remarks 2025-10-09 13:05:21 +02:00
45b5a36d04 add tests for PIN handling via cookies 2025-10-06 09:40:30 +02:00
4c4be8ba42 remove attachment of to URLs on list-view and routes 2025-10-01 13:48:06 +02:00
45bcce0fb2 hide PIN during Anmeldung and within route guards 2025-10-01 13:48:06 +02:00
e288d768bf Merge pull request 'f090_magic_strings_refactoring' (#35) from f090_magic_strings_refactoring into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #35
2025-09-30 13:33:26 +02:00
2863eae3fb organize tests into units 2025-09-30 11:43:54 +02:00
e1e76612d6 create alias for imports in test files 2025-09-30 11:37:20 +02:00
8fbc59499b fix fake URL in test fixtures pointing to 404-page and refactoring magic strings URLs 2025-09-30 10:40:29 +02:00
66b0d1cb3c refactoring magic strings URL in Anmeldung view 2025-09-30 09:34:01 +02:00
45a5c46489 refactoring magic strings: in Vorgang view load() function 2025-09-30 09:09:02 +02:00
c93a5c50de refactoring magic strings: in Vorgang View, add CRIME API route 2025-09-30 09:07:04 +02:00
2ad5a67340 refactoring magic strings: Anmeldung URL with Vorgang in view guard 2025-09-30 08:31:33 +02:00
e1694552c9 refactoring magic strings: User API URLs in user-management view 2025-09-30 08:22:56 +02:00
50a9286895 refactoring magic strings API URLs in upload view 2025-09-30 08:16:28 +02:00
e79d0a1a2d refactoring magic urls:. anmeldung actions 2025-09-29 08:49:26 +02:00
1d2769d114 refactoring magic strings in upload actions 2025-09-29 08:42:54 +02:00
387a9b21a8 refactoring in auth-service magic urls: path 2025-09-29 08:07:04 +02:00
7d0ec1283b refactoring hooks.server.ts magic urls: path 2025-09-29 08:00:00 +02:00
0e13744a79 refactor test 2025-09-26 12:47:55 +02:00
f737e4da4c refactoring: magic strings in Vorgang-View 2025-09-26 12:34:15 +02:00
f43497d69c refactoring magic strings in layout.server file (guard), including new routes and tests 2025-09-26 11:59:21 +02:00
59abf0880d add conditional route and crimeList View refactoring + tests 2025-09-26 10:04:24 +02:00
ca88a541c8 refactoring: magic strings and tests for Vorgang overview page 2025-09-26 08:32:42 +02:00
0820622ace make param in routes optional 2025-09-26 08:31:21 +02:00
67a24f3650 test links on Home-Page View 2025-09-25 14:05:38 +02:00
f0b133101d remove option 2025-09-25 13:53:59 +02:00
7396e15241 use userData from fixtures in Footer and Header 2025-09-25 13:44:50 +02:00
e1ce9373c0 refactor magic links in Header file, incl. routes 2025-09-25 13:29:07 +02:00
ec537901a6 add UI test 2025-09-25 08:11:18 +02:00
bd0c324705 add testing ID to component 2025-09-25 08:07:19 +02:00
5e7eeabda5 Merge branch 'development' into f090_magic_strings_refactoring 2025-09-24 12:45:17 +02:00
dc44a2f4ce Merge pull request 'f086_Zusatz-Edit-der-Namen' (#34) from f086_Zusatz-Edit-der-Namen into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #34
2025-09-24 12:40:12 +02:00
3a6b10e860 deleted debug comments 2025-09-24 12:37:14 +02:00
0c05cf6661 Merge branch 'development' into f086_Zusatz-Edit-der-Namen 2025-09-24 12:35:53 +02:00
011c07b753 formatting 2025-09-24 11:01:45 +02:00
f4e1917357 Merge pull request 'f102_test_KeineListeVorhanden' (#33) from f102_test_KeineListeVorhanden into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #33
2025-09-24 10:01:56 +02:00
406e7f8e4d formatting: VorgangList.view.test.ts 2025-09-24 09:56:18 +02:00
b4591d044b formatting: fixtures.ts file 2025-09-24 09:48:47 +02:00
6f5176fcb9 chenge URL und baseData magic string in testData.name 2025-09-23 16:50:34 +02:00
5d2cfa6dfd comment usage of props with old syntax 2025-09-23 13:23:09 +02:00
67b027e33f formatting 2025-09-23 10:38:59 +02:00
961ed39615 formatting 2025-09-23 09:39:33 +02:00
90745e02d5 rename test description to match tests done 2025-09-23 09:36:46 +02:00
df24fcf7e8 formatting 2025-09-23 09:36:04 +02:00
02e05930f1 formatting 2025-09-23 09:14:33 +02:00
33b40ee29f refactor magic links in Footer component and associated tests 2025-09-23 09:04:22 +02:00
ef5971d41c initial ROUTE_NAMES contant 2025-09-22 12:44:06 +02:00
03835fe624 fixed crimesList aktualisiert sich nach dem Editing 2025-09-22 11:15:07 +02:00
6377bba1a9 make $lib alias in Svelte available in Vitest 2025-09-22 09:40:29 +02:00
012f7ee3e8 implement testsNameItemEditor funktionalität 2025-09-12 10:23:47 +02:00
bcf24122bc fixed tests and Code edit and delete Name in TatortList 2025-09-11 16:52:54 +02:00
47ca05f2d4 save changes, bin aber noch nicht fertig 2025-09-10 17:50:10 +02:00
8803187ce1 implement tests TatortList.view, check delete/edit Item 2025-09-09 18:25:04 +02:00
650cfd0061 implement tests, Tatort List , ComponentEmptyList refactoring 2025-09-08 17:46:33 +02:00
38cdaa538a implement test verschiedene Buttons beim click von editButton 2025-09-05 17:30:17 +02:00
14509fdffe kleine Änderungen, copied baseData, change names of tests 2025-09-05 14:58:26 +02:00
18a37a0e6b change VorgangsListPage in VorgangListPage 2025-09-05 14:50:35 +02:00
7c20aa08b4 add tetId now tests client-view all passed 2025-09-05 14:45:51 +02:00
eaf74c9a5a Merge branch 'development' into f102_test_KeineListeVorhanden 2025-09-05 14:38:04 +02:00
fedc781d74 implement test VorgangList view 2025-09-05 14:37:19 +02:00
e66de4059e add testing state for Jenkins
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-09-05 08:41:37 +02:00
98794a29e1 fix SonarQube issues (unused imports, commented file)
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-09-05 08:29:38 +02:00
0483fe7766 correct test setup: remove test:e2e, skip auth tests (see prev. commit), ´BUCKET´ const mock
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-09-05 08:24:43 +02:00
edb37d8117 implement test View Leere Liste in TatortListe 2025-09-04 19:07:43 +02:00
0bbbe0064b Merge pull request 'f105_umstellung_seaweedS3' (#32) from f105_umstellung_seaweedS3 into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #32
2025-09-04 15:17:38 +02:00
92f5f8e5ed Merge pull request 'f100_backend_api-endpoints_tests' (#31) from f100_backend_api-endpoints_tests into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #31
2025-09-04 15:16:49 +02:00
e935adf8df hotfix: viewer cannot access Vorgang with correct PIN, temp remove check if logged-in
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-09-04 12:44:33 +02:00
d007513e82 formatting 2025-09-04 10:56:11 +02:00
b9c03831cb switch from minio to seaweed S3 storage: configs and buckets (dev vs. prod, refactoring magic strings) 2025-09-04 10:55:45 +02:00
1e85df9127 remove unrelated View tests 2025-09-04 09:59:46 +02:00
3d22aab5b3 stop tracking tatort.db
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-09-04 08:11:38 +02:00
5a1b27f81c Merge branch 'development' into f094_create_tests 2025-09-03 13:05:02 +02:00
0290616890 Merge branch 'b086_Nachbearbeitung_edit-der-Namen' into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-09-03 13:01:08 +02:00
8eef717e50 kleine Nachbesserungen: page.server.ts reset, config_prod zurück in config 2025-09-03 12:55:26 +02:00
283755c7db edit Leere Liste in Vorgang und Crime 2025-09-03 12:27:13 +02:00
f18ef07116 add tests for API endpoint: vorgang/[vorgang]/vorgangPIN 2025-09-03 10:16:18 +02:00
821b8a6440 refactoring: extract common locals.user definition and translatation of comments 2025-09-03 08:41:57 +02:00
e0b490a353 add tests for API endpoint: users 2025-09-03 08:31:53 +02:00
1e96f13d22 remove unused import 2025-09-02 11:49:11 +02:00
c222d75ac5 add tests for API endpoint: user 2025-09-02 11:10:07 +02:00
877c350824 rename test file for ViewAngemeldet 2025-09-02 10:58:38 +02:00
418e91c504 add tests for API endpoint: list/[vorgang]/[tatort] 2025-09-02 10:47:08 +02:00
9be9676f6c add more tests for API endpoint: list/[vorgang], HEAD and DELETE 2025-08-29 10:19:45 +02:00
52bffcd4f0 reorder import, vi.mock is being hoisted 2025-08-29 08:25:52 +02:00
66a4c014ad add tests for API endpoint: list/[vorgang] 2025-08-28 12:43:56 +02:00
a85ef8eab1 rename api-list test file to use CamelCase 2025-08-28 12:40:56 +02:00
9bb691055a add tests for API endpoint: list vorgang 2025-08-28 12:09:43 +02:00
0aa49643ca add tests for API endpoint: list vorgang 2025-08-28 12:03:40 +02:00
cfe00b5424 Add Landing Page View tests, admin and viewer access 2025-08-28 10:09:34 +02:00
b43cfe345a configure vite.config to include toplevel ´tests´ folder 2025-08-27 13:56:39 +02:00
37e103a494 properly format mail-to link
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-08-26 10:55:42 +02:00
d4dba5aef8 Merge branch 'b084_zugangsPIN_vorhandener_Vorgang' into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-08-26 08:34:44 +02:00
f2aa7df441 add guard to dbService checking for directory existence
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-08-26 08:23:39 +02:00
323264150b add guard in init_DB, check for existing folder before db creation
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
2025-08-22 10:03:39 +02:00
0c000f72a2 more unused imports
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
2025-08-21 14:32:58 +02:00
3d59beb3a6 remove unused import 2025-08-21 13:57:42 +02:00
a790bb0974 fix zugangsPIN for existing Vorgang in upload page 2025-08-21 12:35:24 +02:00
e966a1817c remove unused import in DB init file
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
2025-08-21 11:46:58 +02:00
e5d3c494ec replace plain-text password with hashed one
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
2025-08-21 11:21:12 +02:00
e3f4a97772 Merge pull request 'f052_admin_area' (#27) from f052_admin_area into development
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
Reviewed-on: #27
2025-08-21 11:08:44 +02:00
7995aab697 remove unused import
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
2025-08-21 11:03:05 +02:00
ec15095da3 remove jssha and add bcrypt for password hashing with salt 2025-08-21 10:52:29 +02:00
723ec0773d add error handling and formatting 2025-08-19 13:19:23 +02:00
9d54a0fdb8 refactor addUser API endpoint to return user object if successful 2025-08-19 12:56:28 +02:00
ef0b981d84 fix user not found during auth 2025-08-19 12:24:51 +02:00
df03a833d2 update .gitignore for IDE workspace settings and remove db
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
2025-08-19 09:38:39 +02:00
b703fdcb3d Merge pull request 'f047_neu_Edit-der-Namen' (#28) from f047_neu_Edit-der-Namen into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #28
2025-08-19 09:30:13 +02:00
be9e64cfd8 Überarbeitung des Pull Request, für Chico bitte offen lassen. 2025-08-13 16:56:35 +02:00
6c9afda777 Teilbearbeitung des Pull-Requests, nicht fertig da meeting 2025-08-13 12:03:14 +02:00
7bdfa1f69b refactoring branch, tatort page-svelte, Datenaktualisierung durch api, page.ts, choose better names 2025-08-12 17:36:24 +02:00
35a55d0676 vorgaenge and crimesList API endpoints, auto-formatting 2025-08-12 09:26:41 +02:00
84588cedfd fix vorgangPIN API endpoint 2025-08-12 09:11:28 +02:00
ac857cd384 Merge branch '_f047_neu_Edit-der-Namen__API-endpoints' into f047_neu_Edit-der-Namen 2025-08-12 09:04:04 +02:00
6ffeeb1d71 remove unnecessary console.log 2025-08-12 08:43:18 +02:00
45166c0cac implement rename backend PUT function for crime 2025-08-08 10:17:16 +02:00
a91d7292c5 integrate Editable Input, Netzwerkfehler bei PUT 2025-08-07 16:28:15 +02:00
18a1e4ea1c implement getVorgang API endpoit 2025-08-07 14:17:56 +02:00
4d1f002781 implement getVorgaenge API endpoint 2025-08-07 14:06:55 +02:00
e45e401569 kommentare eingefügt 2025-08-07 13:20:38 +02:00
171b0e8546 wechsel daher commit 2025-08-07 12:41:07 +02:00
3c8e521ced init DB 2025-08-07 12:11:28 +02:00
61363cc400 check if new account data is non-empty 2025-08-07 08:00:30 +02:00
885ad40543 add typing for userList and change userId parameter type 2025-08-07 07:58:27 +02:00
cbea96f892 implement deletion of admin user, frontend and backend 2025-08-06 11:52:39 +02:00
7e87a01c59 rename userList variable to CamelCase 2025-08-06 11:44:48 +02:00
4f0526c71f rename loop item variable for userlist-user 2025-08-06 09:59:18 +02:00
dd06c93b1c fix currentUser fetching from loaded data 2025-08-06 09:58:00 +02:00
26b94938a9 user list and possibility of adding new users implemented on FE and BE 2025-08-06 08:37:24 +02:00
b5ff4a72fb Merge branch 'development' into f052_admin_area 2025-08-05 08:34:16 +02:00
eea1621339 Merge branch 'f078_delete_unused_views' into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-08-05 08:31:35 +02:00
c53400d7b7 Merge pull request 'replace copy-to-clipboard with share-via-link-mail-to' (#24) from f054_mail-to-link into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #24
2025-08-05 08:22:47 +02:00
ef1ddb58a0 encode body in mail-to-link button 2025-08-05 08:21:28 +02:00
8c4f95d52e Merge pull request 'f076_api_endpoints_for_renaming_crimes' (#25) from f076_api_endpoints_for_renaming_crimes into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #25
2025-08-05 08:13:26 +02:00
5b408b096e remove console.log 2025-08-05 08:12:34 +02:00
aae8237aae Merge pull request 'fix SonarQube issue, mainly type annotations' (#26) from f079_sonarqube_issues_fixen into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #26
Reviewed-by: jared <jared.busch@polizei.niedersachsen.de>
2025-08-04 14:10:49 +02:00
e45b0e34c9 add ´unique´ constraint in db init script for username 2025-08-04 09:41:13 +02:00
54e3a64dac remove unused link button to deleted view 2025-08-01 12:40:36 +02:00
5927f1dacd fix SonarQube issue, mainly type annotations 2025-08-01 10:29:34 +02:00
f39debd0b8 fix renaming PUT endpoint to work around parameters 2025-08-01 09:44:45 +02:00
7d265341cc rename index looping variable 2025-08-01 08:45:17 +02:00
d6f2956bcb replace copy-to-clipboard with share-via-link-mail-to 2025-07-31 13:15:00 +02:00
e1cf6dc16f remove old unused ´view´ and ´tatorte´ views 2025-07-31 10:47:31 +02:00
5a06c99fb7 Merge pull request 'f077_default_admin_password' (#22) from f077_default_admin_password into development
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
Reviewed-on: #22
Reviewed-by: jared <jared.busch@polizei.niedersachsen.de>
2025-07-31 08:40:14 +02:00
b3e2b297a3 change default admin password 2025-07-31 08:33:03 +02:00
b655df1087 fix changing of PIN on upload page 2025-07-31 08:13:55 +02:00
77c57cb58b fix pipeline: Docker npm run init-db
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-07-30 09:04:11 +02:00
0f2cf87a78 Merge pull request 'r70_variable_names' (#20) from r70_variable_names into development
Some checks failed
InnoHub Processor/tatort/pipeline/head There was a failure building this commit
Reviewed-on: #20
Reviewed-by: jared <jared.busch@polizei.niedersachsen.de>
2025-07-30 08:56:54 +02:00
1fd6cb3ab3 adjust to renaming of object keys of vorgangItem in list view 2025-07-30 08:23:08 +02:00
d402282efe minor adjustments: pin parameter, async and await functions 2025-07-29 14:00:11 +02:00
Daniel
952c3901c1 Update Dockerfile.dev
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-07-29 08:14:40 +02:00
dfa5c9ade1 revised init db pw to pin, and check name routine 2025-07-28 11:39:33 +02:00
4406a86f44 renaming pw to vorgangPIN, case to vorgang, password to vorgangToken 2025-07-25 14:21:37 +02:00
08d83c9ed4 renaming pw to vorgangPIN, case to vorgang, password to vorgangToken 2025-07-25 14:21:23 +02:00
Daniel
cf567451bb Update Dockerfile.dev
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
2025-07-25 10:07:23 +02:00
titver968
2d9acb402d Merge branch 'main' of ssh://gitea.innovation-hub-niedersachsen.de:4422/innohub/tatort 2025-06-25 12:27:00 +02:00
titver968
a71807c63a Dockerfile end config.json for prod and dev 2025-06-25 12:26:18 +02:00
78bf7c7dbc Jenkinsfile gelöscht 2025-06-25 09:29:34 +02:00
aeccb684b4 Jenkinsfile aktualisiert
All checks were successful
InnoHub Processor/tatort/pipeline/head This commit looks good
rename file
2025-06-25 09:11:37 +02:00
425b1ee8fa Jenkins hinzugefügt 2025-06-24 17:20:25 +02:00
6b1a49583e Merge pull request 'refactor-login-page' (#7) from refactor-login-page into main
Reviewed-on: #7
2025-06-18 13:10:25 +02:00
82 changed files with 5558 additions and 1564 deletions

View File

@@ -1,2 +1,3 @@
node_modules node_modules
build build
config.json

6
.gitignore vendored
View File

@@ -1,5 +1,8 @@
test-results test-results
node_modules node_modules
config.json
src/lib/data/tatort.db
# Output # Output
.output .output
@@ -22,3 +25,6 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Jetbrains
/.idea

View File

@@ -1,5 +1,5 @@
# --- Build stage --- # --- Build stage ---
FROM node:22 AS build FROM node:24-alpine AS build
ENV NODE_ENV=development ENV NODE_ENV=development
ENV ORIGIN=https://tatort-dev.innovation-hub-niedersachsen.de ENV ORIGIN=https://tatort-dev.innovation-hub-niedersachsen.de
WORKDIR /app WORKDIR /app
@@ -10,8 +10,8 @@ COPY config_dev.json ./config.json
RUN npm run build RUN npm run build
# --- Production stage --- # --- Production stage ---
FROM node:22-alpine3.20 FROM node:24-alpine
COPY --from=build /app . COPY --from=build /app .
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
EXPOSE 3000 EXPOSE 3000
CMD ["sh", "-c", "ORIGIN=https://tatort-dev.innovation-hub-niedersachsen.de node build/index.js"] CMD ["sh", "-c", "npm run init-db && ORIGIN=https://tatort-dev.innovation-hub-niedersachsen.de node build/index.js"]

View File

@@ -1,7 +1,5 @@
# --- Build stage --- # --- Build stage ---
FROM node:22 AS build FROM node:22 AS build
ENV NODE_ENV=production
ENV ORIGIN=https://tatort.innovation-hub-niedersachsen.de
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
@@ -11,7 +9,13 @@ RUN npm run build
# --- Production stage --- # --- Production stage ---
FROM node:22-alpine3.20 FROM node:22-alpine3.20
COPY --from=build /app . WORKDIR /app
ENV NODE_ENV=production
ENV ORIGIN=https://tatort.innovation-hub-niedersachsen.de
COPY --from=build /app/build ./build
COPY --from=build /app/package*.json ./
COPY --from=build /app/config.json ./config.json
RUN npm ci --omit=dev
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
EXPOSE 3000 EXPOSE 3000
CMD ["sh", "-c", "ORIGIN=https://tatort.innovation-hub-niedersachsen.de node build/index.js"] CMD ["node", "build/index.js"]

8
Jenkinsfile vendored
View File

@@ -57,7 +57,7 @@ pipeline {
} }
} }
stage('Test & Security Audit') { stage('Security Audit') {
steps { steps {
script { script {
didRun = true didRun = true
@@ -67,6 +67,12 @@ pipeline {
} }
} }
stage('Run Tests') {
steps {
sh 'npm run test'
}
}
stage('SonarQube Analysis') { stage('SonarQube Analysis') {
steps { steps {
withSonarQubeEnv('sonarqube') { withSonarQubeEnv('sonarqube') {

View File

@@ -39,9 +39,9 @@ You can preview the production build with `npm run preview`.
## Initializing the SQLite DB ## Initializing the SQLite DB
A database initialization script `init_db.js` in included in the `src/init` folder. It will create a users database (if not existing) and populate it with a default admin user. Additionally, an empty cases table will be created. A database initialization script `init_db.ts` in included in the `src/init` folder. It will create a users database (if not existing) and populate it with a default admin user. Additionally, an empty cases table will be created.
It can be run with `node init_db.js` It can be run with `node init_db.ts`
Database schema: Database schema:
@@ -56,4 +56,4 @@ Cases
- id - id
- token - token
- name - name
- pw - pin

View File

@@ -1,10 +1,10 @@
{ {
"minio": { "minio": {
"endPoint": "sws3.innovation-hub-niedersachsen.de", "endPoint": "api-s3.innovation-hub-niedersachsen.de",
"port": 443, "port": 443,
"useSSL": true, "useSSL": true,
"accessKey": "wjpKrmaqXra99rX3D61H", "accessKey": "AbCdEfGhIjKlMnOpQrSt",
"secretKey": "fTPi0u0FR6Lv9Y9IKydWv6WM0EA5XrsK008HCt9u" "secretKey": "UvWxYz1234567890AbCdEfGhIjKlMnOpQrStUvWx"
}, },
"jwt": { "jwt": {
"secret": "@S2!q@@wXz$dCQ8JoVsHLpzaJ6JCfB", "secret": "@S2!q@@wXz$dCQ8JoVsHLpzaJ6JCfB",

View File

@@ -1,10 +1,10 @@
{ {
"minio": { "minio": {
"endPoint": "sws3.innovation-hub-niedersachsen.de", "endPoint": "api-s3.innovation-hub-niedersachsen.de",
"port": 443, "port": 443,
"useSSL": true, "useSSL": true,
"accessKey": "wjpKrmaqXra99rX3D61H", "accessKey": "AbCdEfGhIjKlMnOpQrSt",
"secretKey": "fTPi0u0FR6Lv9Y9IKydWv6WM0EA5XrsK008HCt9u" "secretKey": "UvWxYz1234567890AbCdEfGhIjKlMnOpQrStUvWx"
}, },
"jwt": { "jwt": {
"secret": "@S2!q@@wXz$dCQ8JoVsHLpzaJ6JCfB", "secret": "@S2!q@@wXz$dCQ8JoVsHLpzaJ6JCfB",

2772
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,8 @@
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"test:unit": "vitest", "test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e", "test": "npm run test:unit -- --run",
"init_db": "npx vite-node src/init/init_db.ts" "init-db": "tsx ./src/init/init_db.ts"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.9", "@eslint/compat": "^1.2.9",
@@ -22,9 +22,10 @@
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.21.3", "@sveltejs/kit": "^2.21.3",
"@sveltejs/vite-plugin-svelte": "^5.1.0", "@sveltejs/vite-plugin-svelte": "^5.1.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.8.0",
"@testing-library/svelte": "^5.2.8", "@testing-library/svelte": "^5.2.8",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.9", "@types/jsonwebtoken": "^9.0.9",
"eslint": "^9.28.0", "eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
@@ -35,19 +36,20 @@
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.33.18", "svelte": "^5.33.18",
"svelte-check": "^4.2.1", "svelte-check": "^4.2.1",
"tsx": "^4.20.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.34.0", "typescript-eslint": "^8.34.0",
"vite": "^6.3.5", "vite": "^6.3.5",
"vitest": "^3.2.3" "vitest": "^3.2.4"
}, },
"dependencies": { "dependencies": {
"@google/model-viewer": "^4.1.0", "@google/model-viewer": "^4.1.0",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jssha": "^3.3.1",
"minio": "^8.0.5", "minio": "^8.0.5",
"postcss": "^8.5.4", "postcss": "^8.5.4",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",

View File

@@ -1,5 +1,7 @@
import { decryptToken } from '$lib/auth'; import { decryptToken } from '$lib/auth';
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { ROUTE_NAMES } from './routes';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const jwt = event.cookies.get('session'); const jwt = event.cookies.get('session');
@@ -9,8 +11,18 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event); return resolve(event);
} }
} catch (_) { } catch (_) {
event.cookies.delete('session', {path: '/'}); event.cookies.delete('session', {path: ROUTE_NAMES.ROOT});
event.locals.user = null; event.locals.user = null;
} }
if (event.url.pathname.startsWith('/api')) {
if (!event.locals.user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
}
return await resolve(event); return await resolve(event);
} }

View File

@@ -1,24 +1,33 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import jsSHA from 'jssha'; import fs from 'fs';
import path from 'path';
import { DB_FULLPATH } from '../routes';
const db = new Database('./src/lib/data/tatort.db'); const fullPath = DB_FULLPATH;
const dir = path.dirname(fullPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
const db = new Database(fullPath);
let createSQLStmt = `CREATE TABLE IF NOT EXISTS users let createSQLStmt = `CREATE TABLE IF NOT EXISTS users
(id INTEGER PRIMARY KEY AUTOINCREMENT, (id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL UNIQUE,
pw TEXT NOT NULL)`; pw TEXT NOT NULL)`;
db.exec(createSQLStmt); db.exec(createSQLStmt);
// check if there are any users; if not add one default admin one // check if there are any users; if not add one default admin one
let password = 'pass-123'; // const saltRounds = 12;
let hashedPassword = new jsSHA('SHA-512', 'TEXT').update(password).getHash('HEX'); // const hashedUserPassword = bcrypt.hashSync(userPasswordHashed, saltRounds);
const hashedUserPassword = '$2b$12$d6bDzoDluXeCTuoxmWSVtOp5Cpian3mZm8qxzox6B37BIf6qtOnnG';
let checkInsertSQLStmt = `INSERT INTO users (name, pw) SELECT 'admin', '${hashedPassword}' const checkInsertSQLStmt = `INSERT INTO users (name, pw) SELECT 'admin', '${hashedUserPassword}'
WHERE NOT EXISTS (SELECT * FROM users);`; WHERE NOT EXISTS (SELECT * FROM users);`;
db.exec(checkInsertSQLStmt); db.exec(checkInsertSQLStmt);
let usersSQLStmt = `SELECT * FROM USERS`; const usersSQLStmt = `SELECT * FROM USERS`;
let SQLStatement = db.prepare(usersSQLStmt); let SQLStatement = db.prepare(usersSQLStmt);
// cases table // cases table
@@ -27,11 +36,11 @@ createSQLStmt = `CREATE TABLE IF NOT EXISTS cases
(id INTEGER PRIMARY KEY AUTOINCREMENT, (id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE, token TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
pw TEXT NOT NULL)`; pin TEXT NOT NULL)`;
db.exec(createSQLStmt); db.exec(createSQLStmt);
let casesSQLStmt = `SELECT * FROM cases`; const vorgangSQLStmt = `SELECT * FROM cases`;
SQLStatement = db.prepare(casesSQLStmt); SQLStatement = db.prepare(vorgangSQLStmt);
db.close(); db.close();

View File

@@ -1,6 +1,5 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import jsSHA from 'jssha'; import bcrypt from 'bcrypt';
import process from 'process';
import { db } from '$lib/server/dbService'; import { db } from '$lib/server/dbService';
import config from '$lib/config'; import config from '$lib/config';
@@ -8,8 +7,6 @@ import config from '$lib/config';
const SECRET = config.jwt.secret; const SECRET = config.jwt.secret;
const EXPIRES_IN = config.jwt.expiresIn; const EXPIRES_IN = config.jwt.expiresIn;
const AUTH = config.auth;
export function createToken(userData) { export function createToken(userData) {
return jwt.sign(userData, SECRET, { expiresIn: EXPIRES_IN }); return jwt.sign(userData, SECRET, { expiresIn: EXPIRES_IN });
} }
@@ -18,17 +15,19 @@ export function decryptToken(token: string) {
return jwt.verify(token, SECRET); return jwt.verify(token, SECRET);
} }
export function authenticate(user, pass) { export function authenticate(user, password) {
let JWTToken; let JWTToken;
// hash user password const getUserSQLStmt = 'SELECT name, pw FROM users WHERE name = ?';
let hashedPW = new jsSHA('SHA-512', 'TEXT').update(pass).getHash('HEX');
let getUserSQLStmt = 'SELECT name, pw FROM users WHERE name = ?';
const row = db.prepare(getUserSQLStmt).get(user); const row = db.prepare(getUserSQLStmt).get(user);
let storedPW = row.pw;
if (hashedPW && hashedPW === storedPW) { if (!row) {
return null;
}
const storedPW = row.pw;
const isValid = bcrypt.compareSync(password, storedPW)
if (isValid) {
JWTToken = createToken({ id: user, admin: true }); JWTToken = createToken({ id: user, admin: true });
} }

View File

@@ -1,3 +1,31 @@
<script lang="ts">
export let href = null;
export let type: 'button' | 'submit' | 'reset' = 'button';
export let size = 'md';
export let variant = 'primary';
export let fullWidth = false;
export let align = 'center';
export let disabled = false;
let classNames = '';
export { classNames as class };
</script>
{#if href}
<a on:click {href} class:w-full={fullWidth} class="button {variant} {size} {classNames} {align}"
><slot />
</a>
{:else}
<button
on:click
{type}
{disabled}
class:w-full={fullWidth}
class="button {variant} {size} {classNames} {align}"
>
<slot />
</button>
{/if}
<style> <style>
.button { .button {
@apply inline-flex; @apply inline-flex;
@@ -172,31 +200,3 @@
@apply justify-end; @apply justify-end;
} }
</style> </style>
<script lang="ts">
export let href = null;
export let type = 'button';
export let size = 'md';
export let variant = 'primary';
export let fullWidth = false;
export let align = 'center';
export let disabled = false;
let classNames = '';
export { classNames as class };
</script>
{#if href}
<a on:click {href} class:w-full={fullWidth} class="button {variant} {size} {classNames} {align}"
><slot />
</a>
{:else}
<button
on:click
{type}
{disabled}
class:w-full={fullWidth}
class="button {variant} {size} {classNames} {align}"
>
<slot />
</button>
{/if}

View File

@@ -0,0 +1,3 @@
<p data-testid="empty-list" class="flex justify-center m-4">
In dieser Liste sind keine Einträge vorhanden
</p>

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { fly, scale, fade } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { tick } from 'svelte';
let expanded = false;
let formContainer: HTMLDivElement;
async function toggle() {
expanded = !expanded;
if (expanded) {
// Wait for DOM to update
await tick();
// Scroll smoothly into view
formContainer?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
</script>
<div data-testid="expand-container" class="flex flex-col items-center">
<!-- + / × button -->
<button
data-testid="expand-button"
class="flex items-center justify-center w-12 h-12 rounded-full bg-blue-600 text-white text-2xl font-bold hover:bg-blue-700 transition"
on:click={toggle}
aria-expanded={expanded}
aria-label="Add item"
>
{#if expanded}
{:else}
+
{/if}
</button>
<!-- Expandable content below button -->
{#if expanded}
<div
bind:this={formContainer}
class="w-full mt-4 flex justify-center"
transition:fade
>
<div
in:fly={{ y: 10, duration: 200, easing: cubicOut }}
out:scale={{ duration: 150 }}
class="w-full max-w-2xl"
>
<slot />
</div>
</div>
{/if}
</div>

View File

@@ -1,6 +1,8 @@
<script> <script>
import Profile from "$lib/icons/Profile.svelte"; import Profile from "$lib/icons/Profile.svelte";
import { ROUTE_NAMES } from "../../routes";
export let data; export let data;
</script> </script>
@@ -10,19 +12,19 @@
<div class="mx-auto max-w-7xl px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="flex justify-between divide-x divide-gray-900/5 border-x border-gray-900/5"> <div class="flex justify-between divide-x divide-gray-900/5 border-x border-gray-900/5">
<a <a
href="/list" href="{ROUTE_NAMES.LIST}"
class="px-4 py-1 -ml-4 flex items-center justify-center gap-x-2.5 text-sm font-semibold leading-6 text-gray-500 hover:bg-gray-200 hover:text-gray-700" class="px-4 py-1 -ml-4 flex items-center justify-center gap-x-2.5 text-sm font-semibold leading-6 text-gray-500 hover:bg-gray-200 hover:text-gray-700"
> >
&copy; 2023 Innovation Hub Niedersachen &copy; 2023 Innovation Hub Niedersachen
</a> </a>
<a <a
href="/" href="{ROUTE_NAMES.ROOT}"
class="px-4 py-1 flex items-center justify-center gap-x-2.5 text-sm font-semibold leading-6 text-gray-500 hover:bg-gray-200 hover:text-gray-700" class="px-4 py-1 flex items-center justify-center gap-x-2.5 text-sm font-semibold leading-6 text-gray-500 hover:bg-gray-200 hover:text-gray-700"
> >
back back
</a> </a>
<a <a
href="/" href="{ROUTE_NAMES.ROOT}"
class="px-4 py-1 -mr-4 flex items-center justify-center gap-x-2.5 text-sm font-semibold leading-6 text-gray-500 hover:bg-gray-200 hover:text-gray-700" class="px-4 py-1 -mr-4 flex items-center justify-center gap-x-2.5 text-sm font-semibold leading-6 text-gray-500 hover:bg-gray-200 hover:text-gray-700"
> >
<!--icon--> <!--icon-->

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import Chevron from '$lib/icons/Chevron-right.svelte'; import Chevron from '$lib/icons/Chevron-right.svelte';
import { ROUTE_NAMES } from '../../routes';
export let data; export let data;
</script> </script>
@@ -11,7 +13,7 @@
aria-label="Global" aria-label="Global"
> >
<div class="flex w-48"> <div class="flex w-48">
<a href="/" class="-m-1.5 p-1.5 w-10"> <a href="{ROUTE_NAMES.ROOT}" class="-m-1.5 p-1.5 w-10">
<span class="sr-only">Tatort Niedersachen</span> <span class="sr-only">Tatort Niedersachen</span>
<img class="h-8 w-auto" src="/Landeswappen_NI.svg" alt="Landeswappen Niedersachsen" /> <img class="h-8 w-auto" src="/Landeswappen_NI.svg" alt="Landeswappen Niedersachsen" />
</a> </a>
@@ -19,7 +21,7 @@
<h1 class="text-3xl text-slate-400 font-bold">Tatort</h1> <h1 class="text-3xl text-slate-400 font-bold">Tatort</h1>
<div class="lg:flex lg:justify-end w-48"> <div class="lg:flex lg:justify-end w-48">
{#if data.user} {#if data.user}
<form method="POST" action="/anmeldung?/logout"> <form method="POST" action="{ROUTE_NAMES.LOGOUT}">
<input type="hidden" /> <input type="hidden" />
<button type="submit" class="text-sm font-semibold leading-6 text-gray-900" <button type="submit" class="text-sm font-semibold leading-6 text-gray-900"
><span ><span

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import Check from '$lib/icons/Check.svelte';
import Edit from '$lib/icons/Edit.svelte';
import Trash from '$lib/icons/Trash.svelte';
import X from '$lib/icons/X.svelte';
import { tick } from 'svelte';
interface ListItem {
name: string;
token?: string;
}
// props, old syntax
export let list: ListItem[] = [];
export let currentName: string;
export let vorgangToken: string | null;
export let onSave: (n: string, o: string, t?: string) => unknown = () => {};
export let onDelete: ((n: string) => unknown) | null = () => {};
let localName = currentName;
let isEditing = false;
let inputRef: HTMLInputElement | null = null;
$: error = validateName(localName);
function validateName(name: string): string {
const trimmed = name?.trim() ?? '';
if (!trimmed) return 'Name darf nicht leer sein.';
if (list.some((item) => item.name === trimmed && item.name !== currentName)) {
return 'Name existiert bereits.';
}
return '';
}
async function startEdit() {
isEditing = true;
await tick();
inputRef?.focus();
}
function cancelEdit() {
localName = currentName;
isEditing = false;
}
function commitEdit() {
if (!error && localName != currentName) onSave(localName, currentName, vorgangToken);
// restore original value
if (error) { localName = currentName }
isEditing = false;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') commitEdit();
if (event.key === 'Escape') cancelEdit();
}
function handleDeleteClick() {
// vorgangToken defined when deleting Vorgang, otherwise Crime
onDelete(vorgangToken || currentName);
}
</script>
<div data-testid="test-nameItemEditor" class="flex flex-col gap-1">
{#if isEditing}
<div class="flex items-center gap-1">
<input
data-testid="test-input"
bind:this={inputRef}
bind:value={localName}
onkeydown={handleKeydown}
class="flex-1 border border-gray-300 rounded px-1.5 py-0.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
data-testid="commit-button"
disabled={!!error || localName === currentName}
onclick={commitEdit}
class="text-gray-500 hover:text-green-600 transition disabled:opacity-40"
>
<Check class="w-4 h-4" />
</button>
<button
data-testid="cancel-button"
onclick={cancelEdit}
class="text-gray-500 hover:text-red-600 transition"
>
<X class="w-4 h-4" />
</button>
</div>
{:else}
<div class="flex items-center gap-1">
<span class="text-sm font-medium text-gray-900 truncate">{localName}</span>
<button
data-testid="edit-button"
onclick={startEdit}
class="text-gray-500 hover:text-blue-600 transition"
>
<Edit class="w-4 h-4" />
</button>
{#if onDelete}
<button
data-testid="delete-button"
onclick={handleDeleteClick}
class="text-gray-500 hover:text-red-600 transition"
>
<Trash class="w-4 h-4" />
</button>
{/if}
</div>
{/if}
{#if error}
<p class="text-xs text-red-500 mt-1">{error}</p>
{/if}
</div>

Binary file not shown.

View File

@@ -1,17 +0,0 @@
import { client } from '$lib/minio';
export default async function caseNumberOccupied (caseNumber: string): Promise<boolean> {
const prefix = `${caseNumber}`;
const promise: Promise<boolean> = new Promise((resolve) => {
const stream = client.listObjectsV2('tatort', prefix, false, '');
stream.on('data', () => {
stream.destroy();
resolve(true);
});
stream.on('end', () => {
resolve(false);
});
});
return promise;
}

View File

@@ -1,10 +0,0 @@
export default async function get_code(case_no) {
let url = `/api/list/${case_no}/casepw`;
const response = await fetch(url);
if (response.status == 200) {
return response.text();
} else {
return -1;
}
}

View File

@@ -0,0 +1,17 @@
import { client, BUCKET } from '$lib/minio';
export default async function vorgangNumberOccupied(vorgangNumber: string): Promise<boolean> {
const prefix = `${vorgangNumber}`;
const promise: Promise<boolean> = new Promise((resolve) => {
const stream = client.listObjectsV2(BUCKET, prefix, false, '');
stream.on('data', () => {
stream.destroy();
resolve(true);
});
stream.on('end', () => {
resolve(false);
});
});
return promise;
}

View File

@@ -1,4 +1,5 @@
<svg <svg
data-testid="profile-component"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"

Before

Width:  |  Height:  |  Size: 402 B

After

Width:  |  Height:  |  Size: 435 B

View File

@@ -7,6 +7,10 @@ import config from '$lib/config';
/** export const client = new Minio.Client(config.minio); */ /** export const client = new Minio.Client(config.minio); */
export const client = new Client(config.minio); export const client = new Client(config.minio);
export const BUCKET = 'tatort'; const isProd = process.env.NODE_ENV == 'production';
const BUCKET = isProd ? 'tatort' : 'tatort-dev';
export { BUCKET };
export const TOKENFILENAME = '__perm__'; export const TOKENFILENAME = '__perm__';
export const CONFIGFILENAME = 'config.json'; export const CONFIGFILENAME = 'config.json';

View File

@@ -1,6 +1,7 @@
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { fail, redirect, type Cookies, type RequestEvent } from '@sveltejs/kit'; import { fail, redirect, type Cookies, type RequestEvent } from '@sveltejs/kit';
import { authenticate } from '$lib/auth'; import { authenticate } from '$lib/auth';
import { ROUTE_NAMES } from '../../routes';
const COOKIE_NAME = 'session'; const COOKIE_NAME = 'session';
@@ -11,19 +12,20 @@ export const loginUser = async ({ request, cookies }: { request: Request; cookie
const token = authenticate(user, password); const token = authenticate(user, password);
if (!token) return fail(400, { user, incorrect: true }); if (!token) return fail(400, { user, incorrect: true,
message: "Ungültige Zugangsdaten" });
cookies.set(COOKIE_NAME, token, { cookies.set(COOKIE_NAME, token, {
path: '/', path: ROUTE_NAMES.ROOT,
httpOnly: true, httpOnly: true,
sameSite: 'strict', sameSite: 'strict',
secure: !dev secure: !dev
}); });
return redirect(303, '/'); return redirect(303, ROUTE_NAMES.ROOT);
}; };
export const logoutUser = async (event: RequestEvent) => { export const logoutUser = async (event: RequestEvent) => {
event.cookies.delete(COOKIE_NAME, { path: '/' }); event.cookies.delete(COOKIE_NAME, { path: ROUTE_NAMES.ROOT });
event.locals.user = null; event.locals.user = null;
return { success: true }; return redirect(303, ROUTE_NAMES.ROOT);
}; };

View File

@@ -1,3 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { DB_FULLPATH } from '../../routes';
export const db = new Database('./src/lib/data/tatort.db'); // make sure the DB is initiated
import '../../init/init_db'
export const db = new Database(DB_FULLPATH);

View File

@@ -0,0 +1,51 @@
import { db } from '$lib/server/dbService';
export const getUsers = (): { userId: string; userName: string }[] => {
const getUsersSQLStmt = `SELECT id, name
FROM users;`;
const statement = db.prepare(getUsersSQLStmt);
const result = statement.all() as { id: string; name: string }[];
const userList: { userId: string; userName: string }[] = [];
for (const resultItem of result) {
const user = { userId: resultItem.id, userName: resultItem.name };
userList.push(user);
}
return userList;
};
export const addUser = (userName: string, userPassword: string) => {
const addUserSQLStmt = `INSERT into users(name, pw)
values (?, ?)`;
const statement = db.prepare(addUserSQLStmt);
let rowInfo;
try {
rowInfo = statement.run(userName, userPassword);
return rowInfo;
} catch (error) {
console.error('ERROR: ', error);
}
};
export const deleteUser = (userId: string) => {
// make sure to not delete the last entry
const deleteUserSQLStmt = `DELETE
FROM users
WHERE id = ?
AND (SELECT COUNT(*) FROM users) > 1;`;
const statement = db.prepare(deleteUserSQLStmt);
let rowCount;
try {
const info = statement.run(userId);
rowCount = info.changes;
} catch (error) {
console.log(error);
rowCount = 0;
}
return rowCount;
};

View File

@@ -1,16 +1,17 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { BUCKET, client, CONFIGFILENAME, TOKENFILENAME } from '$lib/minio'; import { BUCKET, client, CONFIGFILENAME, TOKENFILENAME } from '$lib/minio';
import { checkIfExactDirectoryExists, getContentOfTextObject } from './s3ClientService'; import { checkIfExactDirectoryExists, getContentOfTextObject } from './s3ClientService';
import { v4 as uuidv4 } from 'uuid';
import { db } from './dbService'; import { db } from './dbService';
/** /**
* Get Vorgang and corresponend list of tatorte * Get Vorgang and corresponend list of tatorte
* @param caseToken * @param vorgangToken
* @returns * @returns
*/ */
export const getCrimesListByToken = async (caseToken: string) => { export const getCrimesListByToken = async (vorgangToken: string) => {
const prefix = `${caseToken}/`; const prefix = `${vorgangToken}/`;
const stream = client.listObjectsV2(BUCKET, prefix, false, ''); const stream = client.listObjectsV2(BUCKET, prefix, false, '');
@@ -28,46 +29,83 @@ export const getCrimesListByToken = async (caseToken: string) => {
/** /**
* Get Vorgang * Get Vorgang
* @param caseToken * @param vorgangToken
* @returns caseObj with keys `token`, `name`, `pw` || undefined * @returns vorgangObj with keys `token`, `name`, `pin` || undefined
*/ */
export const getVorgangByToken = function (caseToken: string) { export const getVorgangByToken = (
let getVorgangSQLStmt = `SELECT token, name, pw FROM cases WHERE token = ?`; vorgangToken: string
): { token: string; name: string; pin: string } | undefined => {
const getVorgangSQLStmt = `SELECT token, name, pin
FROM cases
WHERE token = ?`;
const statement = db.prepare(getVorgangSQLStmt); const statement = db.prepare(getVorgangSQLStmt);
const result = statement.get(caseToken); const result = statement.get(vorgangToken) as
| { token: string; name: string; pin: string }
| undefined;
return result; return result;
}; };
/** /**
* Get Vorgang * Create Vorgang, using a vorgangName and vorgangPIN
* @param caseName * @param vorgangName
* @returns caseObj with keys `token`, `name`, `pw` || undefined * @param vorgangPIN
* @returns {string || false} vorgangToken if successful
*/ */
export const getVorgangByName = function (caseName: string) { export const createVorgang = (vorgangName: string, vorgangPIN: string): string | boolean => {
let getVorgangByNameSQLStmt = `SELECT token, name, pw FROM cases WHERE name = ?`; const vorgangExists = vorgangNameExists(vorgangName);
if (vorgangExists) {
return false;
}
const vorgangToken = uuidv4();
const insertSQLStatement = `INSERT INTO cases (token, name, pin) VALUES (?, ?, ?)`;
const statement = db.prepare(insertSQLStatement);
const info = statement.run(vorgangToken, vorgangName, vorgangPIN);
if (info.changes) {
return vorgangToken;
} else {
return false;
}
};
/**
* Get Vorgang
* @param vorgangName
* @returns vorgangObj with keys `token`, `name`, `pin` || undefined
*/
export const getVorgangByName = (
vorgangName: string
): { token: string; name: string; pin: string } | undefined => {
const getVorgangByNameSQLStmt = `SELECT token, name, pin
FROM cases
WHERE name = ?`;
const statement = db.prepare(getVorgangByNameSQLStmt); const statement = db.prepare(getVorgangByNameSQLStmt);
const result = statement.get(caseName); const result = statement.get(vorgangName) as
| { token: string; name: string; pin: string }
| undefined;
return result; return result;
}; };
/** /**
* Delete Vorgang * Delete Vorgang
* @param caseToken * @param vorgangToken
* @returns int: number of changes * @returns int: number of changes
*/ */
export const deleteVorgangByToken = function (caseToken: string) { export const deleteVorgangByToken = function (vorgangToken: string) {
let deleteSQLStmt = 'DELETE FROM cases WHERE token = ?'; const deleteSQLStmt = 'DELETE FROM cases WHERE token = ?';
const statement = db.prepare(deleteSQLStmt); const statement = db.prepare(deleteSQLStmt);
const info = statement.run(caseToken); const info = statement.run(vorgangToken);
return info.changes; return info.changes;
}; };
/** /**
* Fetches list of vorgänge from s3 bucket * Fetches list of vorgänge from s3 bucket
* @returns list of available cases * @returns list of available vorgaenge
*/ */
export const getListOfVorgänge = async () => { export const getListOfVorgänge = async () => {
const stream = client.listObjectsV2(BUCKET, '', false, ''); const stream = client.listObjectsV2(BUCKET, '', false, '');
@@ -86,15 +124,24 @@ export const getListOfVorgänge = async () => {
/** /**
* Fetches list of vorgänge from database * Fetches list of vorgänge from database
* @returns list with of available cases * @returns list with of available vorgaenge
*/ */
export const getVorgaenge = function () { export const getVorgaenge = (): {
let getVorgaengeSQLStmt = `SELECT token, name, pw from cases`; vorgangToken: string;
vorgangName: string;
vorgangPIN: string;
}[] => {
const getVorgaengeSQLStmt = `SELECT token, name, pin
from cases`;
const statement = db.prepare(getVorgaengeSQLStmt); const statement = db.prepare(getVorgaengeSQLStmt);
const result = statement.all(); const result = statement.all() as { token: string; name: string; pin: string }[];
const vorgaenge_list = []; const vorgaenge_list: { vorgangToken: string; vorgangName: string; vorgangPIN: string }[] = [];
for (const r of result) { for (const resultItem of result) {
const vorg = { token: r.token, name: r.name, pw: r.pw }; const vorg = {
vorgangToken: resultItem.token,
vorgangName: resultItem.name,
vorgangPIN: resultItem.pin
};
vorgaenge_list.push(vorg); vorgaenge_list.push(vorg);
} }
@@ -106,19 +153,19 @@ export const getVorgaenge = function () {
* @param request * @param request
* @returns fail or true * @returns fail or true
*/ */
export const checkIfVorgangExists = async (caseId: string | null) => { export const checkIfVorgangExists = async (vorgangId: string | null) => {
if (!caseId) { if (!vorgangId) {
return fail(400, { return fail(400, {
success: false, success: false,
caseId, vorgangId,
error: { message: 'Die Vorgangsnummer darf nicht leer sein.' } error: { message: 'Die Vorgangsnummer darf nicht leer sein.' }
}); });
} }
if (typeof caseId === 'string' && !(await checkIfExactDirectoryExists(caseId))) { if (typeof vorgangId === 'string' && !(await checkIfExactDirectoryExists(vorgangId))) {
return fail(400, { return fail(400, {
success: false, success: false,
caseId, vorgangId,
error: { message: 'Die Vorgangsnummer existiert in dieser Anwendung nicht.' } error: { message: 'Die Vorgangsnummer existiert in dieser Anwendung nicht.' }
}); });
} }
@@ -126,42 +173,42 @@ export const checkIfVorgangExists = async (caseId: string | null) => {
return true; return true;
}; };
export const vorgangExists = function (caseToken: string | null) { export const vorgangExists = function (vorgangToken: string | null) {
if (!caseToken) { if (!vorgangToken) {
return fail(400, { return fail(400, {
success: false, success: false,
caseId: caseToken, vorgangId: vorgangToken,
error: { message: 'Die Vorgangsnummer darf nicht leer sein.' } error: { message: 'Die Vorgangsnummer darf nicht leer sein.' }
}); });
} }
let vorgaenge = getVorgaenge(); const vorgaenge = getVorgaenge();
const vorgaenge_tokens = vorgaenge.map((vorg) => vorg.token); const vorgaengeTokens = vorgaenge.map((vorgang) => vorgang.vorgangToken);
const found = vorgaenge_tokens.indexOf(caseToken) != -1; const found = vorgaengeTokens.indexOf(vorgangToken) != -1;
return found; return found;
}; };
export const vorgangNameExists = function (caseName: string) { export const vorgangNameExists = (vorgangName: string) => {
let vorgaenge = getVorgaenge(); const vorgaenge = getVorgaenge();
const vorgaengeNames = vorgaenge.map((vorg) => vorg.name); const vorgaengeNames = vorgaenge.map((vorgang) => vorgang.vorgangName);
const found = vorgaengeNames.indexOf(caseName) != -1; const found = vorgaengeNames.indexOf(vorgangName) != -1;
return found; return found;
}; };
export const hasValidToken = async (caseId: string, caseToken: string) => { export const hasValidToken = async (vorgangId: string, vorgangToken: string) => {
const objPath = `${caseId}/${TOKENFILENAME}`; const objPath = `${vorgangId}/${TOKENFILENAME}`;
try { try {
if (!caseToken) { if (!vorgangToken) {
return false; return false;
} }
const token = await getContentOfTextObject(BUCKET, objPath); const token = await getContentOfTextObject(BUCKET, objPath);
if (!token || token !== caseToken) { if (!token || token !== vorgangToken) {
return false; return false;
} }
@@ -174,16 +221,40 @@ export const hasValidToken = async (caseId: string, caseToken: string) => {
} }
}; };
export const passwordValid = function (caseToken, casePassword) { export const vorgangPINValidation = function (vorgangToken: string, vorgangPIN: string) {
if (!casePassword) { if (!vorgangPIN) {
return false; return false;
} }
const vorg = getVorgangByToken(caseToken); const vorgang = getVorgangByToken(vorgangToken);
if (!vorg || vorg.pw !== casePassword) { if (!vorgang || vorgang.pin !== vorgangPIN) {
return false; return false;
} }
return true; return true;
}; };
/**
* Change VorgangName or VorgangPIN
* @param vorgangToken
* @param newValue
* @returns {int} number of affected lines
*/
export const updateVorgangAttrByToken = function (vorgangToken: string,
newValue: string,
column: string) {
const renameSQLStmt = `UPDATE cases set ${column} = ? WHERE token = ?`;
const statement = db.prepare(renameSQLStmt);
let info;
try {
info = statement.run(newValue, vorgangToken);
} catch (err) {
console.log(`error: ${err}`)
return 0;
}
return info.changes;
};

View File

@@ -1,10 +1,10 @@
import { redirect, type ServerLoadEvent } from '@sveltejs/kit'; import { type ServerLoadEvent } from '@sveltejs/kit';
import type { PageServerLoad } from '../anmeldung/$types'; import type { PageServerLoad } from '../anmeldung/$types';
export const load: PageServerLoad = (event: ServerLoadEvent) => { export const load: PageServerLoad = (event: ServerLoadEvent) => {
if (!event.locals.user && event.url.pathname !== '/anmeldung') throw redirect(303, '/anmeldung'); if (event.locals.user) {
return { return {
user: event.locals.user user: event.locals.user
}; };
} }
};

View File

@@ -5,6 +5,8 @@
export let data; export let data;
</script> </script>
{#if data.user?.admin}
<div class="h-screen v-screen flex flex-col"> <div class="h-screen v-screen flex flex-col">
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<Header {data}/> <Header {data}/>
@@ -16,3 +18,10 @@
</div> </div>
</div> </div>
{:else}
<div class="h-screen bg-white"><slot /></div>
{/if}

View File

@@ -0,0 +1,6 @@
import { loginUser, logoutUser } from '$lib/server/authService';
export const actions = {
login: ({ request, cookies }) => loginUser({ request, cookies }),
logout: (event) => logoutUser(event),
} as const;

View File

@@ -2,60 +2,108 @@
import AddProcess from '$lib/icons/Add-Process.svelte'; import AddProcess from '$lib/icons/Add-Process.svelte';
import FileRect from '$lib/icons/File-rect.svelte'; import FileRect from '$lib/icons/File-rect.svelte';
import ListIcon from '$lib/icons/List-icon.svelte'; import ListIcon from '$lib/icons/List-icon.svelte';
import Button from '$lib/components/Button.svelte';
import ArrowRight from '$lib/icons/Arrow-right.svelte';
import { ROUTE_NAMES } from '../index.js';
export let data; export let data;
export let form;
export let outline = true; export let outline = true;
</script> </script>
{#if data.user?.admin}
<div <div
class=" inset-x-0 top-0 -z-10 h-full flex items-center justify-center bg-white shadow-lg ring-1 ring-gray-900/5" class=" inset-x-0 top-0 -z-10 h-full flex items-center justify-center bg-white shadow-lg ring-1 ring-gray-900/5"
> >
<div class="mx-auto flex justify-center max-w-7xl py-10 px-8 w-full"> <div class="mx-auto flex justify-center max-w-7xl py-10 px-8 w-full">
{#if data.user.admin}
<div class="group relative rounded-lg p-6 text-sm leading-6 hover:bg-gray-50 w-1/4"> <div class="group relative rounded-lg p-6 text-sm leading-6 hover:bg-gray-50 w-1/4">
<div <div
class="flex h-11 w-11 items-center justify-center rounded-lg bg-gray-50 group-hover:bg-white" class="flex h-11 w-11 items-center justify-center rounded-lg bg-gray-50 group-hover:bg-white"
> >
<ListIcon class=" group-hover:text-indigo-600" /> <ListIcon class=" group-hover:text-indigo-600" />
</div> </div>
<a href="/list" class="mt-6 block font-semibold text-gray-900"> <a href="{ROUTE_NAMES.LIST}" class="mt-6 block font-semibold text-gray-900">
Liste Vorgänge
<span class="absolute inset-0"></span> <span class="absolute inset-0"></span>
</a> </a>
<p class="mt-1 text-gray-600"> <p class="mt-1 text-gray-600">
Verschaffe Dir einen Überblick über alle gespeicherten Tatorte. Verschaffe Dir einen Überblick über alle gespeicherten Tatorte.
</p> </p>
</div> </div>
{/if}
{#if data.user.admin}
<div class="group relative rounded-lg p-6 text-sm leading-6 hover:bg-gray-50 w-1/4">
<div
class="flex h-11 w-11 items-center justify-center rounded-lg bg-gray-50 group-hover:bg-white"
>
<AddProcess class=" group-hover:text-indigo-600" />
</div>
<a href="/upload" class="mt-6 block font-semibold text-gray-900">
Hinzufügen
<span class="absolute inset-0"></span>
</a>
<p class="mt-1 text-gray-600">Fügen Sie einem Tatort Bilder hinzu.</p>
</div>
{/if}
<div class="group relative rounded-lg p-6 text-sm leading-6 hover:bg-gray-50 w-1/4"> <div class="group relative rounded-lg p-6 text-sm leading-6 hover:bg-gray-50 w-1/4">
<div <div
class="flex h-11 w-11 items-center justify-center rounded-lg bg-gray-50 group-hover:bg-white" class="flex h-11 w-11 items-center justify-center rounded-lg bg-gray-50 group-hover:bg-white"
> >
<FileRect class=" group-hover:text-indigo-600" {outline} /> <FileRect class=" group-hover:text-indigo-600" {outline} />
</div> </div>
<a href="/view" class="mt-6 block font-semibold text-gray-900"> <a href="{ROUTE_NAMES.USERMGMT}" class="mt-6 block font-semibold text-gray-900">
Ansicht Benutzerverwaltung
<span class="absolute inset-0"></span> <span class="absolute inset-0"></span>
</a> </a>
<p class="mt-1 text-gray-600">Schau Dir einen Tatort in der 3D Ansicht an.</p> <p class="mt-1 text-gray-600">Füge neue Benutzer hinzu oder entferne welche.</p>
</div> </div>
</div> </div>
</div> </div>
{:else}
<div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<img class="mx-auto h-10 w-auto" src="/Landeswappen_NI.svg" alt="Landeswappen Niedersachsen" />
<h2 class="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
Willkommen beim 3D Tatort
</h2>
</div>
<div class="w-full max-w-sm mx-auto">
<div class="relative mt-5 bg-gray-50 rounded-xl shadow-xl p-3 pt-1">
<div class="mt-10">
<form action="{ROUTE_NAMES.LOGIN}" method="POST">
<div>
<label for="user" class="text-sm font-medium leading-6 text-gray-900">Name</label>
<div class="mt-2">
<input
id="user"
name="user"
type="text"
autocomplete="email"
required
class="rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium leading-6 text-gray-900"
>Passwort</label
>
<div class="mt-2">
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
{#if form?.incorrect}
<p class="block text-sm leading-6 text-red-900 mt-2">{form.message}</p>
{/if}
<div class="flex justify-end">
<Button type="submit" class="mt-5">Anmelden</Button>
</div>
</form>
</div>
</div>
</div>
</div>
{/if}
<style> <style>
</style> </style>

View File

@@ -1,11 +1,35 @@
import { getListOfVorgänge, getVorgaenge } from '$lib/server/vorgangService'; import { createVorgang, getVorgaenge } from '$lib/server/vorgangService';
import type { PageServerLoad } from '../../(token-based)/view/$types'; import type { PageServerLoad } from '../../(token-based)/view/$types';
import { error, fail } from '@sveltejs/kit';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async (event) => {
// const caseList = await getListOfVorgänge(); if (!event.locals.user) {
const caseList = getVorgaenge(); error(404, 'Not Found')
}
const vorgangList = getVorgaenge();
return { return {
caseList vorgangList
}; };
}; };
export const actions = {
default: async ({ request }: { request: Request }) => {
const data = await request.formData();
const vorgangName: string | null = data.get('vorgang') as string;
const vorgangPIN: string | null = data.get('pin') as string;
const err = {};
const token = createVorgang(vorgangName, vorgangPIN);
if (!token) {
err.message = "Der Vorgang konnte nicht angelegt werden"
return fail(400, err)
} else {
// success
return { token }
}
}
};

View File

@@ -1,24 +1,94 @@
<script lang="ts"> <script lang="ts">
import ExpandableForm from '$lib/components/ExpandableForm.svelte';
import Trash from '$lib/icons/Trash.svelte'; import Trash from '$lib/icons/Trash.svelte';
import Folder from '$lib/icons/Folder.svelte'; import Folder from '$lib/icons/Folder.svelte';
import type { PageData } from '../$types'; import EmptyList from '$lib/components/EmptyList.svelte';
import NameItemEditor from '$lib/components/NameItemEditor.svelte';
import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte';
import Modal from '$lib/components/Modal/Modal.svelte';
import ModalTitle from '$lib/components/Modal/ModalTitle.svelte';
import ModalContent from '$lib/components/Modal/ModalContent.svelte';
import ModalFooter from '$lib/components/Modal/ModalFooter.svelte';
import { API_ROUTES, ROUTE_NAMES } from '../../index.js';
import { invalidateAll } from '$app/navigation';
export let data: PageData; let { data, form } = $props();
const caseList = data.caseList; let vorgangList = $state(data.vorgangList);
async function delete_item(ev: Event) { // same as `vorgangList` but with one different property to be used
// with ´NameItemEditor`
const derivedList = $derived.by(
() => {
return vorgangList.map(
({ vorgangName, ...rest }) => (
{
name: vorgangName,
...rest
}
)
)
}
);
let isEmptyList = vorgangList.length === 0;
let vorgangName = $state('');
let vorgangPIN = $state('');
let errorMsg = $state('');
// reset input fields when submission successful
$effect(() => {
if (form?.token) {
vorgangName = '';
vorgangPIN = '';
errorMsg = '';
}
});
async function submitVorgang(ev: Event) {
const isValid = inputValid(vorgangName, vorgangPIN);
if (!isValid) {
ev.preventDefault();
return;
}
// continue form action on server
}
/**
* Check for required fields
* @param vorgangName
* @param vorgangPIN
* @returns {boolean} Indicates whether input is valid
*/
function inputValid(vorgangName, vorgangPIN) {
if (!(vorgangName || vorgangPIN)) {
errorMsg = 'Bitte beide Felder ausfüllen.';
return false;
} else if (!vorgangName) {
errorMsg = 'Bitte einen Vorgangsnamen vergeben.';
return false;
} else if (!vorgangPIN) {
errorMsg = 'Bitte einen Vorgangs-PIN eingeben.';
return false;
}
const existing = vorgangList.some((vorg) => vorg.vorgangName === vorgangName);
if (existing) {
errorMsg = 'Der Name existiert bereits.';
return false;
}
return true;
}
async function deleteVorgang(vorgangToken: string) {
let delete_item = window.confirm('Bist du sicher?'); let delete_item = window.confirm('Bist du sicher?');
if (delete_item) { if (delete_item) {
const target = ev.currentTarget as HTMLElement | null; let url = API_ROUTES.VORGANG(vorgangToken);
if (!target) return;
let filename = target.id.split('del__')[1];
// delete request
// --------------
let url = `/api/list/${filename}`;
try { try {
const response = await fetch(url, { method: 'DELETE' }); const response = await fetch(url, { method: 'DELETE' });
@@ -36,6 +106,46 @@
} }
} }
} }
//Variablen für Modal
let open = $state(false);
let inProgress = $state(false);
let isError = $state(false);
async function handleSave(newName: string, oldName: string, vorgangToken: string) {
open = true;
inProgress = true;
isError = false;
try {
const res = await fetch(API_ROUTES.VORGANG(vorgangToken), {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ vorgangToken, oldName, newName })
});
if (!res.ok) {
throw new Error('Fehler beim Speichern');
}
await invalidateAll();
vorgangList = data.vorgangList;
open = false;
} catch (err) {
console.error('⚠️ Netzwerkfehler beim Speichern', err);
isError = true;
} finally {
inProgress = false;
}
}
async function closeModal() {
open = false;
isError = false;
}
</script> </script>
<div class="-z-10 bg-white"> <div class="-z-10 bg-white">
@@ -44,37 +154,111 @@
</div> </div>
<div class="mx-auto flex justify-center max-w-7xl h-full"> <div class="mx-auto flex justify-center max-w-7xl h-full">
<ul role="list" class="divide-y divide-gray-100"> <ul role="list" class="divide-y divide-gray-100">
{#each caseList as item} {#if isEmptyList}
<li> <EmptyList></EmptyList>
<a href="/list/{item.token}?pw={item.pw}" class="flex justify-between gap-x-6 py-5"> {:else}
<div class="flex gap-x-4"> {#each vorgangList as vorgangItem}
<!-- Ordner --> <li data-testid="test-list-item">
<Folder /> <div class="flex items-center justify-center gap-3">
<div class="min-w-0 flex-auto"> <a
<span class="text-sm font-semibold leading-6 text-gray-900">{item.name}</span> href="{ROUTE_NAMES.VORGANG(vorgangItem.vorgangToken)}"
<!-- Delete button --> class="flex flex-col items-center justify-center gap-2 py-4 rounded-lg hover:bg-gray-50 transition text-center"
<button
style="padding: 2px"
id="del__{item.token}"
on:click|preventDefault={delete_item}
aria-label="Vorgang {item.name} löschen"
> >
<Trash /> <Folder class="w-6 h-6 text-gray-600" />
</button>
</div>
</div>
<div class="hidden sm:flex sm:flex-col sm:items-end">
<p class="text-sm leading-6 text-gray-900">Vorgang</p>
</div>
</a> </a>
<NameItemEditor
list={derivedList}
currentName={vorgangItem.vorgangName}
vorgangToken={vorgangItem.vorgangToken}
onSave={handleSave}
onDelete={deleteVorgang}
/>
</div>
</li> </li>
{/each} {/each}
{/if}
</ul> </ul>
</div> </div>
</div> </div>
<ExpandableForm>
<form class="flex flex-col items-center" method="POST">
<div class="flex flex-col sm:flex-row sm:space-x-4 w-full max-w-lg">
<div class="flex-1">
<label for="vorgang" class="block text-sm font-medium leading-6 text-gray-900">
<span class="flex"> Vorgangsname </span>
</label>
<div class="mt-2">
<div
class="flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600"
>
<input
required
bind:value={vorgangName}
type="text"
name="vorgang"
id="vorgang"
class="block flex-1 border-0 bg-transparent py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6"
/>
</div>
</div>
</div>
<div class="flex-1 mt-4 sm:mt-0">
<label for="pin" class="block text-sm font-medium leading-6 text-gray-900">
<span class="flex"> PIN </span>
</label>
<div class="mt-2">
<div
class="flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600"
>
<input
required
type="password"
bind:value={vorgangPIN}
name="pin"
id="pin"
class="block flex-1 border-0 bg-transparent py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6"
/>
</div>
</div>
</div>
</div>
{#if errorMsg}
<p>{errorMsg}</p>
{/if}
{#if form?.message}
<p>{form.message}</p>
{/if}
<button
type="submit"
on:click={submitVorgang}
class="mt-4 bg-indigo-600 text-white px-6 py-2 rounded hover:bg-indigo-700 transition"
>
Neuen Vorgang hinzufügen
</button>
</form>
</ExpandableForm>
<Modal {open}
><ModalTitle>Umbenennen</ModalTitle><ModalContent>
{#if inProgress}
<p class="py-2 mb-1">Vorgang läuft...</p>
{:else if isError}
<Alert class="w-full" type="error">Fehler beim Umbenennen</Alert>
{:else}
<Alert class="w-full">Umbenennen erfolgreich</Alert>
{/if}
</ModalContent>
<ModalFooter><Button disabled={inProgress} on:click={closeModal}>Ok</Button></ModalFooter>
</Modal>
<style> <style>
ul { ul {
min-width: 24rem; min-width: 24rem;
} }
</style> </style>

View File

@@ -1,37 +0,0 @@
import { client } from '$lib/minio';
import { fail } from '@sveltejs/kit';
import caseNumberOccupied from '$lib/helper/caseNumberOccupied';
/** @type {import('./$types').Actions} */
export const actions = {
default: async ({ request }: {request: Request}) => {
const data = await request.formData();
const caseNumber = data.get('caseNumber');
const description = data.get('description');
if (!caseNumber) {
return fail(400, {
caseNumber,
description,
error: { caseNumber: 'Es muss eine Vorgangsnummer vorhanden sein.' }
});
}
if (await caseNumberOccupied(`${caseNumber}`)) {
return fail(400, {
caseNumber,
description,
error: { caseNumber: 'Die Vorgangsnummer wurde im System bereits angelegt.' }
});
}
const config = `${JSON.stringify({ caseNumber, description, version: 1 })}\n`;
await client.putObject('tatort', `${caseNumber}/config.json`, config, undefined, {
'Content-Type': 'application/json'
});
return { success: true };
}
};

View File

@@ -1,115 +0,0 @@
<script lang="ts">
import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte';
import Modal from '$lib/components/Modal/Modal.svelte';
import ModalTitle from '$lib/components/Modal/ModalTitle.svelte';
import ModalContent from '$lib/components/Modal/ModalContent.svelte';
import ModalFooter from '$lib/components/Modal/ModalFooter.svelte';
import Exclamation from '$lib/icons/Exclamation.svelte';
export let form;
let open = false;
$: open = form?.success ?? false;
</script>
<div class="mx-auto max-w-2xl">
<div class="flex flex-col items-center justify-center w-full">
<h1 class="text-xl">Neuer Vorgang</h1>
</div>
<form method="POST">
<div class="space-y-12">
<div class="border-b border-gray-900/10 pb-12">
<p class="mt-8 text-sm leading-6 text-gray-600">
This information will be displayed publicly so be careful what you share.
</p>
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8">
<div>
<label for="caseNumber" class="block text-sm font-medium leading-6 text-gray-900"
><span class="flex"
>{#if form?.error?.caseNumber}
<span class="inline-block mr-1"><Exclamation /></span>
{/if} Vorgangs-Nr.</span
></label
>
<div class="mt-2">
<div
class="flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600"
>
<input
value={form?.caseNumber ?? ''}
type="text"
name="caseNumber"
id="caseNumber"
class="block flex-1 border-0 bg-transparent py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0 text-sm leading-6"
/>
</div>
</div>
{#if form?.error?.caseNumber}
<p class="block text-sm leading-6 text-red-900 mt-2">{form.error.caseNumber}</p>
{/if}
</div>
<div>
<label for="description" class="block text-sm font-medium leading-6 text-gray-900"
><span class="flex"
>{#if form?.description}
<span class="inline-block mr-1"><Exclamation /></span>
{/if} Beschreibung</span
></label
>
<div class="mt-2">
<textarea
value={form?.description?.toString() ?? ''}
id="description"
name="description"
rows="3"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
></textarea>
</div>
{#if form?.error}
<p class="block text-sm leading-6 text-red-900 mt-2">{form.description}</p>
{/if}
</div>
<label for="code">
<span >Zugangscode (optional) </span>
</label>
<div class="mt-2">
<div
>
<input
type="text"
id="code"
/>
</div>
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-x-6">
<button type="button" class="text-sm font-semibold leading-6 text-gray-900">Cancel</button>
<Button
type="submit"
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>Save</Button
>
</div>
</div>
</form>
<Modal {open}
><ModalTitle>vorgang anlegen</ModalTitle><ModalContent>
{#if form?.success}
<Alert class="w-full">Vorgang erfolgreich angelegt</Alert>
{:else}
<Alert class="w-full" type="error">Fehler beim Upload</Alert>
{/if}
</ModalContent>
<ModalFooter><Button on:click={() => (open = false)}>Ok</Button></ModalFooter>
</Modal>
</div>

View File

@@ -1,10 +1,8 @@
import { Readable } from 'stream'; import { Readable } from 'stream';
import { client } from '$lib/minio'; import { BUCKET, client } from '$lib/minio';
import { fail } from '@sveltejs/kit'; import { fail, error } from '@sveltejs/kit';
import { v4 as uuidv4 } from 'uuid';
import { db } from '$lib/server/dbService'; import { getVorgangByName } from '$lib/server/vorgangService';
import { getVorgangByName, vorgangNameExists } from '$lib/server/vorgangService';
const isRequiredFieldValid = (value: unknown) => { const isRequiredFieldValid = (value: unknown) => {
if (value == null) return false; if (value == null) return false;
@@ -17,36 +15,16 @@ const isRequiredFieldValid = (value: unknown) => {
export const actions = { export const actions = {
url: async ({ request }: { request: Request }) => { url: async ({ request }: { request: Request }) => {
const data = await request.formData(); const data = await request.formData();
const caseName = data.get('vorgang'); const vorgangName: string | null = data.get('vorgang') as string;
const crimeName = data.get('name'); const crimeName: string | null = data.get('name') as string;
const type = data.get('type'); const type: string | null = data.get('type') as string;
const password = data.get('password'); const fileName: string | null = data.get('fileName') as string;
const fileName = data.get('fileName');
// store case in database let vorgangToken;
// skip if Vorgang exists and token not changed const vorgang = getVorgangByName(vorgangName);
vorgangToken = vorgang.token;
const vorgangExists = vorgangNameExists(caseName); let objectName = `${vorgangToken}/${crimeName}`;
let token;
if (!vorgangExists) {
token = uuidv4();
let insertSQLStatement = `INSERT INTO cases (token, name, pw) VALUES (?, ?, ?)`;
const statement = db.prepare(insertSQLStatement);
statement.run(token, caseName, password);
} else {
// vorgang exists
// check if PW was changed, and update DB if it was
const vorg = getVorgangByName(caseName);
token = vorg.token;
if (vorg.pw != password) {
let updateSQLStmt = `UPDATE cases SET pw = ? WHERE name = ?`;
const statement = db.prepare(updateSQLStmt);
statement.run(password, vorg);
}
}
let objectName = `${token}/${crimeName}`;
switch (type) { switch (type) {
case 'image/png': case 'image/png':
if (!objectName.endsWith('.png')) objectName += '.png'; if (!objectName.endsWith('.png')) objectName += '.png';
@@ -56,7 +34,7 @@ export const actions = {
objectName += '.glb'; objectName += '.glb';
} }
const url = await client.presignedPutObject('tatort', objectName); const url = await client.presignedPutObject(BUCKET, objectName);
return { url }; return { url };
}, },
@@ -65,27 +43,22 @@ export const actions = {
const data = Object.fromEntries(requestData); const data = Object.fromEntries(requestData);
const vorgang = data.vorgang; const vorgang = data.vorgang;
const name = data.name; const name = data.name;
const password = data.password;
let success = true; let success = true;
const err = {}; const err = {};
if (isRequiredFieldValid(vorgang)) err.vorgang = null; if (isRequiredFieldValid(vorgang)) {
else { err.vorgang = null;
} else {
err.vorgang = 'Das Feld Vorgang darf nicht leer bleiben.'; err.vorgang = 'Das Feld Vorgang darf nicht leer bleiben.';
success = false; success = false;
} }
if (isRequiredFieldValid(name)) err.name = null; if (isRequiredFieldValid(name)) {
else { err.name = null;
} else {
err.name = 'Das Feld Name darf nicht leer bleiben.'; err.name = 'Das Feld Name darf nicht leer bleiben.';
success = false; success = false;
} }
if (isRequiredFieldValid(password)) err.password = null;
else {
err.password = 'Das Feld Zugangspasswort darf nicht leer bleiben.';
success = false;
}
if (success) return { success }; if (success) return { success };
return fail(400, err); return fail(400, err);
@@ -97,7 +70,7 @@ export const actions = {
const vorgang = data.vorgang; const vorgang = data.vorgang;
const name = data.name; const name = data.name;
const url = await client.presignedPutObject('tatort', `${vorgang}/${name}`, 60); const url = await client.presignedPutObject(BUCKET, `${vorgang}/${name}`, 60);
return { url }; return { url };
}, },
@@ -108,7 +81,7 @@ export const actions = {
const stream = data.file.stream(); const stream = data.file.stream();
const metaData = { 'Content-Type': 'model-gtlf-binary', 'X-VorgangsNr': '4711' }; const metaData = { 'Content-Type': 'model-gtlf-binary', 'X-VorgangsNr': '4711' };
const result = new Promise((resolve, reject) => { const result = new Promise((resolve, reject) => {
client.putObject('tatort', name, Readable.from(stream), metaData, function (err, etag) { client.putObject(BUCKET, name, Readable.from(stream), metaData, function (err, etag) {
if (err) return reject(err); if (err) return reject(err);
resolve(etag); resolve(etag);
}); });
@@ -125,3 +98,10 @@ export const actions = {
return { etag, error }; return { etag, error };
} }
}; };
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) {
error(404, 'Not found')
}
};

View File

@@ -9,26 +9,27 @@
import shortenFileSize from '$lib/helper/shortenFileSize.js'; import shortenFileSize from '$lib/helper/shortenFileSize.js';
import Exclamation from '$lib/icons/Exclamation.svelte'; import Exclamation from '$lib/icons/Exclamation.svelte';
import FileRect from '$lib/icons/File-rect.svelte'; import FileRect from '$lib/icons/File-rect.svelte';
import { API_ROUTES, ROUTE_NAMES } from '../../index.js';
export let form; export let form;
let open = false; let open = false;
let inProgress = false; let inProgress = false;
let vorgang = ''; let vorgang = '';
const code_len = 8; const PINLength = 8;
function generatePassword() { function generatePIN() {
return Math.random() return Math.random()
.toString(36) .toString(36)
.slice(2, 2 + code_len); .slice(2, 2 + PINLength);
} }
let zugangspasswort = '' let vorgangPIN = '';
let zugangspasswordOld = '' let vorgangPINOld = '';
$: zugangspasswordOld = generatePassword(); $: vorgangPINOld = generatePIN();
$: zugangspasswort = zugangspasswordOld $: vorgangPIN = vorgangPINOld;
let caseExisting = undefined; let vorgangExists = undefined;
$: caseExisting = false; $: vorgangExists = false;
let name = ''; let name = '';
let etag: string | null = null; let etag: string | null = null;
@@ -36,14 +37,14 @@
$: inProgress = form === null; $: inProgress = form === null;
let formErrors: Record<string,any> | null; let formErrors: Record<string, any> | null;
async function validateForm() { async function validateForm() {
let data = new FormData(); let data = new FormData();
data.append('vorgang', vorgang); data.append('vorgang', vorgang);
data.append('name', name); data.append('name', name);
data.append('password', zugangspasswort); data.append('vorgangPIN', vorgangPIN);
const response = await fetch('?/validate', { method: 'POST', body: data }); const response = await fetch(ROUTE_NAMES.UPLOAD_VALIDATE, { method: 'POST', body: data });
/** @type {import('@sveltejs/kit').ActionResult} */ /** @type {import('@sveltejs/kit').ActionResult} */
const result = deserialize(await response.text()); const result = deserialize(await response.text());
@@ -71,12 +72,12 @@
let data = new FormData(); let data = new FormData();
data.append('vorgang', vorgang); data.append('vorgang', vorgang);
data.append('name', name); data.append('name', name);
data.append('password', zugangspasswort); data.append('vorgangPIN', vorgangPIN);
if (files?.length === 1) { if (files?.length === 1) {
data.append('type', files[0].type); data.append('type', files[0].type);
data.append('fileName', files[0].name); data.append('fileName', files[0].name);
} }
const response = await fetch('?/url', { method: 'POST', body: data }); const response = await fetch(ROUTE_NAMES.UPLOAD_URL, { method: 'POST', body: data });
/** @type {import('@sveltejs/kit').ActionResult} */ /** @type {import('@sveltejs/kit').ActionResult} */
const result = deserialize(await response.text()); const result = deserialize(await response.text());
if (result.type === 'success') return result.data?.url; if (result.type === 'success') return result.data?.url;
@@ -139,6 +140,7 @@
// big endian! // big endian!
let file = files[0]; let file = files[0];
let file_header = file.slice(0, 4); let file_header = file.slice(0, 4);
console.log(file_header);
let header_bytes = await file_header.bytes(); let header_bytes = await file_header.bytes();
let file_header_hex = '0x' + header_bytes.toHex().toString(); let file_header_hex = '0x' + header_bytes.toHex().toString();
@@ -150,38 +152,41 @@
return true; return true;
} }
// `/(angemeldet)/view` return true or false async function checkVorgangExists(vorgangName: string) {
async function caseExists(caseName: string) { if (vorgangName == '') {
vorgangPIN = vorgangPINOld;
if (caseName == '') {
zugangspasswort = zugangspasswordOld;
return; return;
} }
let url = `/api/list/${caseName}` try {
// `HEAD` method
const response = await fetch(url, { method: 'HEAD'}); const url = API_ROUTES.VORGANG_NAME_EXIST(vorgangName);
const status = response.status; const response = await fetch(url, { method: 'HEAD' });
if (status == 200) {
caseExisting = true;
const passwort = await getPassword(caseName);
zugangspasswort = passwort;
return true
if (response.status === 200) {
console.log('Vorgang existiert:', vorgangName);
vorgangExists = true;
const token = await getVorgangPIN(vorgangName);
vorgangPIN = token;
return true;
} else { } else {
caseExisting = false; console.log('Vorgang existiert nicht!');
zugangspasswort = zugangspasswordOld; vorgangExists = false;
return false vorgangPIN = vorgangPINOld;
return false;
}
} catch (err) {
console.error('Fehler bei checkVorgangExists:', err);
vorgangExists = false;
vorgangPIN = vorgangPINOld;
return false;
} }
} }
async function getPassword(caseName: string) { async function getVorgangPIN(vorgangName: string) {
if (vorgangName == '') return;
if (caseName == '') return; let url = API_ROUTES.VORGANG_PIN(vorgangName);
let url = `/api/list/${caseName}/casepw`;
const response = await fetch(url); const response = await fetch(url);
if (response.status == 200) { if (response.status == 200) {
@@ -190,7 +195,6 @@
return -1; return -1;
} }
} }
</script> </script>
<div class="mx-auto max-w-2xl"> <div class="mx-auto max-w-2xl">
@@ -211,7 +215,7 @@
><span class="flex" ><span class="flex"
>{#if formErrors?.vorgang} >{#if formErrors?.vorgang}
<span class="inline-block mr-1"><Exclamation /></span> <span class="inline-block mr-1"><Exclamation /></span>
{/if} Vorgang</span {/if} Vorgangsname</span
></label ></label
> >
<div class="mt-2"> <div class="mt-2">
@@ -225,14 +229,14 @@
id="vorgang" id="vorgang"
autocomplete={vorgang} autocomplete={vorgang}
class="block flex-1 border-0 bg-transparent py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6" class="block flex-1 border-0 bg-transparent py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6"
on:input={() => caseExists(vorgang)} on:input={() => checkVorgangExists(vorgang)}
/> />
</div> </div>
</div> </div>
{#if formErrors?.vorgang} {#if formErrors?.vorgang}
<p class="block text-sm leading-6 text-red-900 mt-2">{formErrors.vorgang}</p> <p class="block text-sm leading-6 text-red-900 mt-2">{formErrors.vorgang}</p>
{/if} {/if}
{#if caseExisting && vorgang.length > 0} {#if vorgangExists && vorgang.length > 0}
<span>Datei wird zum existierenden Vorgang hinzugefügt.</span> <span>Datei wird zum existierenden Vorgang hinzugefügt.</span>
{:else if vorgang.length > 0} {:else if vorgang.length > 0}
<span>Neuer Vorgang wird angelegt.</span> <span>Neuer Vorgang wird angelegt.</span>
@@ -244,7 +248,7 @@
><span class="flex" ><span class="flex"
>{#if formErrors?.name} >{#if formErrors?.name}
<span class="inline-block mr-1"><Exclamation /></span> <span class="inline-block mr-1"><Exclamation /></span>
{/if} Name</span {/if} Modellname</span
></label ></label
> >
<div class="mt-2"> <div class="mt-2">
@@ -267,11 +271,11 @@
</div> </div>
<div> <div>
<label for="zugangscode" class="block text-sm font-medium leading-6 text-gray-900" <label for="vorgang-pin" class="block text-sm font-medium leading-6 text-gray-900"
><span class="flex" ><span class="flex"
>{#if formErrors?.zugangscode} >{#if formErrors?.vorgangPIN}
<span class="inline-block mr-1"><Exclamation /></span> <span class="inline-block mr-1"><Exclamation /></span>
{/if} Zugangscode</span {/if} Zugangs-PIN</span
></label ></label
> >
<div class="mt-2"> <div class="mt-2">
@@ -279,25 +283,28 @@
class="flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600" class="flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600"
> >
<input <input
bind:value={zugangspasswort} bind:value={vorgangPIN}
type="text" type="text"
name="zugangscode" name="vorgang-pin"
id="zugangscode" id="vorgang-pin"
on:input="{ (ev) => { zugangspasswordOld = ev.target.value }}" on:input={(ev) => {
vorgangPINOld = ev.target.value;
}}
class="block flex-1 border-0 bg-transparent py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6" class="block flex-1 border-0 bg-transparent py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6"
/> />
</div> </div>
<button <button
class="rounded-md bg-blue-500 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" class="rounded-md bg-blue-500 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
on:click="{() => { on:click={() => {
zugangspasswort = zugangspasswordOld = generatePassword(); }}" vorgangPIN = vorgangPINOld = generatePIN();
type="button"> }}
Generiere Zugangscode type="button"
>
Generiere Zugangs-PIN
</button> </button>
</div> </div>
{#if formErrors?.code} {#if formErrors?.vorgangPIN}
<p class="block text-sm leading-6 text-red-900 mt-2">{formErrors.code}</p> <p class="block text-sm leading-6 text-red-900 mt-2">{formErrors.vorgangPIN}</p>
{/if} {/if}
</div> </div>

View File

@@ -0,0 +1,8 @@
import type { PageServerLoad } from '../../(token-based)/view/$types';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) {
error(404, 'Not Found')
}
};

View File

@@ -0,0 +1,211 @@
<script lang="ts">
import { onMount } from 'svelte';
import Button from '$lib/components/Button.svelte';
import { API_ROUTES } from '../../index.js';
const { data } = $props();
let userName = $state('');
let userPassword = $state('');
let userList: { userId: string; userName: string }[] = $state([]);
let addUserError = $state(false);
let addUserSuccess = $state(false);
const currentUser: string = data.user.id;
onMount(async () => {
try {
userList = await getUsers();
} catch (error) {
console.log(`An error occured while retrieving users: ${error}`);
}
});
async function getUsers() {
const URL = API_ROUTES.USERS;
try {
const response = await fetch(URL);
return await response.json();
} catch (error) {
console.log(`Error fetching users: ${error}`);
return null;
}
}
async function addUser() {
if (userName == '') {
alert('Der Benutzername darf nicht leer sein.');
return;
}
if (userPassword == '') {
alert('Das Passwort darf nicht leer sein.');
return;
}
const URL = API_ROUTES.USERS;
const userData = { userName: userName, userPassword: userPassword };
try {
const response = await fetch(URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});
if (response.ok) {
const newUser = await response.json();
userList = [...userList, newUser];
addUserSuccess = true;
resetInput();
} else {
addUserError = true;
}
} catch (error) {
console.log(`Error creating user: ${error}`);
addUserError = true;
}
}
function resetInput() {
userName = '';
userPassword = '';
addUserError = false;
setInterval(() => {
addUserSuccess = false;
}, 5000);
}
async function deleteUser(userId: string) {
const URL = API_ROUTES.USER(userId);
try {
const response = await fetch(URL, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
if (response.status == 204) {
userList = await getUsers();
} else {
alert('Nutzer konnte nicht gelöscht werden');
}
} catch (error) {
console.log(`Error deleting users: ${error}`);
}
}
</script>
<h1 class="flex justify-center text-3xl md:text-4xl font-bold text-gray-800 dark:text-white tracking-tight mb-4">
Benutzerverwaltung
</h1>
<h1 class="flex justify-center text-lg md:text-xl font-medium text-gray-800 dark:text-white tracking-tight mb-2">
Benutzerliste
</h1>
<div class="w-1/4 mx-auto">
<table class="min-w-full border border-gray-300 rounded overflow-hidden">
<thead class="bg-gray-100 dark:bg-gray-700">
<tr>
<th class="text-left px-4 py-2">Benutzername</th>
<th class="text-center px-4 py-2">Entfernen</th>
</tr>
</thead>
<tbody>
{#each userList as userItem}
<tr class="border-t border-gray-200 dark:border-gray-600">
<td class="px-4 py-2 text-gray-800 dark:text-white">{userItem.userName}</td>
<td class="px-4 py-2 text-center">
<button
class="text-red-600 hover:text-red-800 focus:outline-none focus:ring-2 focus:ring-red-500 rounded-full p-1 transition"
on:click={() => deleteUser(userItem.userId)}
aria-label="Delete user"
>
{#if (userItem.userName != currentUser)}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
{/if}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<h1 style="margin-top: 50px;"
class="flex justify-center text-lg md:text-xl font-medium text-gray-800 dark:text-white tracking-tight mb-2">
Neuer Nutzer
</h1>
<div class="mx-auto flex justify-center">
<form>
<div class="form-group">
<label for="username">Benutzername</label>
<input bind:value={userName} type="text" id="username" placeholder="Namen eingeben" />
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input bind:value={userPassword} type="password" id="password" placeholder="Passwort vergeben" />
</div>
</form>
</div>
<div class="mx-auto flex flex-col items-center space-y-4" style="margin-top: 20px;">
{#if addUserError}
<div class="flex items-center bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 rounded shadow-sm"
role="alert">
<svg class="h-5 w-5 mr-3 text-yellow-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01M12 5a7 7 0 100 14 7 7 0 000-14z" />
</svg>
<span class="text-sm font-medium">Der Benutzer konnte nicht hinzugefügt werden.</span>
</div>
{/if}
{#if addUserSuccess}
<div class="flex items-center bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 rounded shadow-sm"
role="alert">
<svg class="h-5 w-5 mr-3 text-yellow-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01M12 5a7 7 0 100 14 7 7 0 000-14z" />
</svg>
<span class="text-sm font-medium">Der Benutzer wurde hinzugefügt.</span>
</div>
{/if}
<Button on:click={addUser}>Benutzer hinzufügen</Button>
</div>
<style>
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 15px;
}
label {
margin-bottom: 5px;
font-weight: bold;
}
input[type="text"],
input[type="password"] {
padding: 8px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 4px;
}
</style>

View File

@@ -1,24 +1,22 @@
import { import { vorgangPINValidation, vorgangExists } from '$lib/server/vorgangService';
checkIfVorgangExists,
hasValidToken,
passwordValid,
vorgangExists
} from '$lib/server/vorgangService';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './list/[vorgang]/$types'; import type { LayoutServerLoad } from './$types';
import { ROUTE_NAMES } from '..';
export const load: PageServerLoad = async ({ params, url, locals }) => { export const load: LayoutServerLoad = async ({ params, cookies, locals }) => {
if (locals.user) { if (locals.user) {
return { return {
user: locals.user user: locals.user
}; };
} }
const caseToken = params.vorgang; const vorgangToken = params.vorgang ?? '';
const casePassword = url.searchParams.get('pw'); const COOKIE_NAME = `token-${vorgangToken}`;
const vorgangPIN = cookies.get(COOKIE_NAME) ?? '';
const isVorgangValid = vorgangExists(caseToken); const isVorgangValid = vorgangExists(vorgangToken);
const isPasswordValid = passwordValid(caseToken, casePassword); const isVorgangPINValid = vorgangPINValidation(vorgangToken, vorgangPIN);
if (!isVorgangValid || !isPasswordValid) throw redirect(303, `/anmeldung?vorgang=${caseToken}`); if (!isVorgangValid || !isVorgangPINValid)
throw redirect(303, ROUTE_NAMES.ANMELDUNG_VORGANG_PARAM(vorgangToken));
}; };

View File

@@ -1,16 +1,23 @@
import { getVorgangByToken, getCrimesListByToken } from '$lib/server/vorgangService'; import { getCrimesListByToken, getVorgaenge } from '$lib/server/vorgangService.js';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, url }) => { export const load: PageServerLoad = async ({ params, url }) => {
const caseToken = params.vorgang; const vorgangList = getVorgaenge();
const casePassword = url.searchParams.get('pw'); const vorgangToken = params.vorgang;
const crimesList = await getCrimesListByToken(vorgangToken);
const vorgang = vorgangList.find((v) => v.vorgangToken === vorgangToken); //vorgang sollte ein eigener Typ werden, und dann kann man es hier vernünftig typisieren
if (!vorgang || !crimesList) {
throw new Error(`Fehlgeschlagen, es wurden keine Daten zum token gefunden`);
}
const crimesList = await getCrimesListByToken(caseToken); //Variabeln für NameItemEditor
const vorgang = getVorgangByToken(caseToken); const crimeNames: string[] = crimesList.map((l) => l.name);
return { return {
vorgang,
vorgangList,
crimesList, crimesList,
casePassword, url,
vorgang crimeNames
}; };
}; }

View File

@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition';
import shortenFileSize from '$lib/helper/shortenFileSize'; import shortenFileSize from '$lib/helper/shortenFileSize';
import { page } from '$app/stores';
import timeElapsed from '$lib/helper/timeElapsed'; import timeElapsed from '$lib/helper/timeElapsed';
import { deserialize } from '$app/forms';
import ExpandableForm from '$lib/components/ExpandableForm.svelte';
import Alert from '$lib/components/Alert.svelte'; import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import Modal from '$lib/components/Modal/Modal.svelte'; import Modal from '$lib/components/Modal/Modal.svelte';
@@ -11,271 +11,517 @@
import ModalContent from '$lib/components/Modal/ModalContent.svelte'; import ModalContent from '$lib/components/Modal/ModalContent.svelte';
import ModalFooter from '$lib/components/Modal/ModalFooter.svelte'; import ModalFooter from '$lib/components/Modal/ModalFooter.svelte';
import Cube from '$lib/icons/Cube.svelte'; import Cube from '$lib/icons/Cube.svelte';
import Edit from '$lib/icons/Edit.svelte'; import { invalidateAll } from '$app/navigation';
import Trash from '$lib/icons/Trash.svelte'; import NameItemEditor from '$lib/components/NameItemEditor.svelte';
import EmptyList from '$lib/components/EmptyList.svelte';
import FileRect from '$lib/icons/File-rect.svelte';
import Exclamation from '$lib/icons/Exclamation.svelte';
import { API_ROUTES, ROUTE_NAMES } from '../../../index.js';
/** export let data; */ //Seite für die Tatort-Liste
/** @type {import('./$types').PageData} */ let { data, form } = $props();
export let data;
interface ListItem { interface ListItem {
//sollte Typ Vorgang sein, aber der einfachheit ist es noch ListItem, damit die Komponente NameItemEditor für Vorgang und Tatort eingesetzt werden kann
name: string; name: string;
size: number; size: number;
lastModified: string | number | Date; lastModified: string | number | Date;
show_button?: boolean; show_button?: boolean;
prefix?: string;
// add other properties as needed // add other properties as needed
} }
const vorgang = data.vorgang; let crimesList = $state<ListItem[]>(data.crimesList);
const crimesList: ListItem[] = data.crimesList; let vorgangName: string = data.vorgang.vorgangName;
const password: string = data.casePassword; const vorgangPIN: string = data.vorgang.vorgangPIN;
let vorgangToken: string = data.vorgang.vorgangToken;
let isEmptyList = $derived(crimesList.length === 0);
let open = false; // File Upload Variablen
$: open; let name = $state('');
let inProgress = false; let formErrors: Record<string, any> | null = $state(null);
$: inProgress; let etag: string | null = $state(null);
let err = false; let files: FileList | null = $state(null);
$: err; let fileInput = $state(null);
let rename_input; // Model Variablen für Upload
$: rename_input; let openUL = $state(false);
let inProgressUL = $state(form === null);
function uploadSuccessful() { // Variablen für Copy-Funktion
open = false; let copied = $state(false);
}
function defocus_element(i: number) { async function buttonClick(event: MouseEvent) {
let item = crimesList[i]; if (!(await validateForm())) {
let text_field_id = `label__${item.name}`; event.preventDefault();
let text_field = document.getElementById(text_field_id);
if (text_field) {
text_field.setAttribute('contenteditable', 'false');
text_field.textContent = item.name;
}
// reshow button
crimesList[i].show_button = true;
return; return;
} }
const url = await getUrl();
openUL = true;
inProgressUL = true;
async function handle_input(ev: KeyboardEvent, i: number) { fetch(url, { method: 'PUT', body: files[0] })
let item = crimesList[i]; .then((response) => {
if (ev.key == 'Escape') { inProgressUL = false;
let text_field_id = `label__${item.name}`; etag = '123';
})
let text_field = document.getElementById(text_field_id); .catch((err) => {
if (text_field) { inProgressUL = false;
text_field.setAttribute('contenteditable', 'false'); etag = null;
text_field.textContent = item.name; console.log('ERROR', err);
});
} }
// reshow button async function validateForm() {
item.show_button = true; let data = new FormData();
return; data.append('vorgang', vorgangName);
} data.append('name', name);
if (ev.key == 'Enter') { const response = await fetch(ROUTE_NAMES.UPLOAD_VALIDATE, { method: 'POST', body: data });
let name_field = ev.currentTarget as HTMLElement | null; const result = deserialize(await response.text());
let new_name = name_field
? name_field.textContent || (name_field as any).innerText || ''
: '';
if (new_name == '') { let success = true;
alert('Bitte einen gültigen Namen eingeben.'); if (result.type === 'success') {
ev.preventDefault(); formErrors = null;
return; } else {
if (result.type === 'failure' && result.data) formErrors = result.data;
success = false;
} }
// actual upload if (!files?.length) {
// ------------- formErrors = { file: 'Sie haben keine Datei ausgewählt.', ...formErrors };
success = false;
}
// to prevent from item being selected if (!(await check_valid_glb_file())) {
ev.preventDefault(); formErrors = { file: 'Keine gültige .GLD-Datei', ...formErrors };
success = false;
}
return success;
}
// construct PUT URL async function uploadSuccessful() {
const url = $page.url; openUL = false;
name = '';
files = null;
fileInput.value = "";
await invalidateAll();
crimesList = data.crimesList;
}
let data_obj: { new_name: string; old_name: string } = { new_name: '', old_name: '' }; // `val` is hex string
data_obj['new_name'] = new_name; function swap_endian(val) {
data_obj['old_name'] = // from https://www.geeksforgeeks.org/bit-manipulation-swap-endianness-of-a-number/
ev.currentTarget && (ev.currentTarget as HTMLElement).id
? (ev.currentTarget as HTMLElement).id.split('__')[1]
: '';
let leftmost_byte = (val & eval(0x000000ff)) >> 0;
let left_middle_byte = (val & eval(0x0000ff00)) >> 8;
let right_middle_byte = (val & eval(0x00ff0000)) >> 16;
let rightmost_byte = (val & eval(0xff000000)) >> 24;
leftmost_byte <<= 24;
left_middle_byte <<= 16;
right_middle_byte <<= 8;
rightmost_byte <<= 0;
let res = leftmost_byte | left_middle_byte | right_middle_byte | rightmost_byte;
return res;
}
async function check_valid_glb_file() {
// GLD Header, magic value 0x46546C67, identifies data as binary glTF, 4 bytes
// little endian!
const GLD_MAGIC = 0x46546c67;
// big endian!
let file = files[0];
const fileHeader = file.slice(0, 4);
const buffer = await fileHeader.arrayBuffer();
console.log(fileHeader);
let headerBytes = new Uint8Array(buffer);
let fileHeaderHex = '0x' + headerBytes.toHex().toString();
if (GLD_MAGIC == swap_endian(fileHeaderHex)) {
return true;
} else {
return false;
}
}
async function getUrl() {
let data = new FormData();
data.append('vorgang', vorgangName);
data.append('name', name);
if (files?.length === 1) {
data.append('type', files[0].type);
data.append('fileName', files[0].name);
}
const response = await fetch(ROUTE_NAMES.UPLOAD_URL, { method: 'POST', body: data });
const result = deserialize(await response.text());
if (result.type === 'success') return result.data?.url;
return null;
}
//Variablen für Modal
let open = $state(false);
let inProgress = $state(false);
let isError = $state(false);
let admin = data?.user?.admin;
async function handleSave(newName: string, oldName: string) {
open = true; open = true;
inProgress = true; inProgress = true;
isError = false;
try {
const res = await fetch(API_ROUTES.CRIME(vorgangToken, oldName), {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ vorgangToken, oldName, newName })
});
const response = await fetch(url, { method: 'PUT', body: JSON.stringify(data_obj) }); if (!res.ok) {
throw new Error('Fehler beim Speichern');
}
await invalidateAll();
crimesList = data.crimesList;
open = false;
} catch (err) {
console.error('⚠️ Netzwerkfehler beim Speichern', err);
isError = true;
} finally {
inProgress = false; inProgress = false;
if (!response.ok) {
err = true;
if (response.status == 400) {
let json_res = await response.json();
return;
} }
throw new Error(`Fehlgeschlagen: ${response.status}`); }
} else {
uploadSuccessful(); async function savePIN(newVorgangPIN: string, oldVorgangPIN: string) {
open = true;
inProgress = true;
isError = false;
try {
const res = await fetch(API_ROUTES.VORGANG(vorgangToken), {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ vorgangToken, oldVorgangPIN, newVorgangPIN,
changePIN: true})
});
if (!res.ok) {
throw new Error('Fehler beim Speichern');
}
await invalidateAll();
crimesList = data.crimesList;
open = false;
} catch (err) {
console.error('⚠️ Netzwerkfehler beim Speichern', err);
isError = true;
} finally {
inProgress = false;
}
}
async function handleDelete(tatort: string) {
open = true;
inProgress = true;
isError = false;
let path = API_ROUTES.CRIME(vorgangToken, tatort)
try {
const res = await fetch(path, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ vorgangToken, tatort })
});
if (!res.ok) {
throw new Error('Fehler beim Löschen');
}
crimesList = crimesList.filter((i) => i.name !== tatort);
await invalidateAll();
console.log('🗑️ Erfolgreich gelöscht:', path);
open = false;
} catch (err) {
console.error('⚠️ Netzwerkfehler beim Speichern', err);
isError = true;
} finally {
inProgress = false;
}
}
async function copyAndOpenMail() {
const subject = 'Link zum Tatvorgang';
const link = data.url.origin + data.url.pathname;
const body = `Hallo,
hier ist der Link zum Tatvorgang:
${link}
Der Zugangs-PIN wird zur Sicherheit über einen zweiten Kommunikationskanal übermittelt.
Mit freundlichen Grüßen,
`;
try {
await navigator.clipboard.writeText(body);
copied = true;
// Kurz warten, dann Mail öffnen
setTimeout(() => { setTimeout(() => {
window.location.reload(); const mailtoLink = `mailto:?subject=${encodeURIComponent(subject)}`;
}, 500); window.location.href = mailtoLink;
} }, 1000);
// --- upload finished --- setTimeout(() => copied = false, 2000);
} catch (err) {
return; console.error('Clipboard-Fehler:', err);
error = 'Konnte Text nicht kopieren. Bitte manuell markieren und kopieren.';
} }
} }
async function setClipboard(text) { function closeModal() {
const type = "text/plain"; open = false;
const clipboardItemData = { isError = false;
[type]: text, }
// drag and drop functionality
let isDragging = $state(false);
async function handleDrop(event) {
event.preventDefault();
isDragging = false;
if (event.dataTransfer?.files?.length) {
files = event.dataTransfer.files;
}
if (!(await check_valid_glb_file())) {
formErrors = { file: 'Keine gültige .GLD-Datei' }
// reset form fields etc.
files = null;
fileInput.value = '';
} else {
formErrors = { ...formErrors, file: ''}
}; };
const clipboardItem = new ClipboardItem(clipboardItemData);
await navigator.clipboard.write([clipboardItem]);
} }
</script> </script>
<div class="-z-10 bg-white"> <svelte:window
on:dragover|preventDefault
on:drop|preventDefault
/>
{#if data.vorgang && crimesList}
<div class="-z-10 bg-white">
<div class="flex flex-col items-center justify-center w-full"> <div class="flex flex-col items-center justify-center w-full">
<h1 class="text-xl">Vorgang {vorgang.name}</h1> <h1 class="text-xl">{vorgangName}</h1>
{#if data?.user?.admin}
Zugangspasswort: {vorgang.pw} {#if admin}
<Button on:click={() => setClipboard($page.url.toString().split('?')[0])}>Copy Link</Button> <div class="flex items-center gap-2">
Zugangs-PIN:
<NameItemEditor
list={[]}
currentName={vorgangPIN}
onSave={savePIN}
onDelete={null}
/>
</div>
<Button variant="secondary" on:click={copyAndOpenMail} disabled={isEmptyList}>Link kopieren und Mail verfassen</Button>
{#if copied}
<p transition:fade>✔ Kopiert! Per Ctrl+V einfügen.</p>
{/if}
{/if} {/if}
</div> </div>
<div class="mx-auto flex justify-center max-w-7xl h-full"> <div class="mx-auto flex justify-center max-w-7xl h-full">
<ul class="divide-y divide-gray-100"> <ul class="divide-y divide-gray-100">
{#each crimesList as item, i} {#if isEmptyList}
<li> <EmptyList></EmptyList>
<a
href="/view/{$page.params.vorgang}/{item.name}?pw={password}"
class=" flex justify-between gap-x-6 py-5"
aria-label="zum 3D-modell"
>
<div class=" flex gap-x-4">
<Cube />
<div class="min-w-0 flex-auto">
{#if data?.user?.admin}
<span
id="label__{item.name}"
class="text-sm font-semibold leading-6 text-gray-900 inline-block min-w-1"
contenteditable={!item.show_button}
role="textbox"
tabindex="0"
aria-label="Dateiname bearbeiten"
on:focusout={() => {
defocus_element(i);
}}
on:keydown|stopPropagation={// event needed to identify ID
// TO-DO: check if event is needed or if index is sufficient
async (ev) => {
handle_input(ev, i);
}}>{item.name}</span
>
{#if item.show_button}
<button
style="padding: 2px"
id="edit__{item.name}"
aria-label="Datei umbenennen"
on:click|preventDefault={(ev) => {
let text_field_id = `label__${item.name}`;
let text_field = document.getElementById(text_field_id);
if (text_field) {
text_field.setAttribute('contenteditable', 'true');
text_field.focus();
text_field.textContent = '';
}
// hide button
item.show_button = false;
}}
>
<Edit />
</button>
{/if}
<button
style="padding: 2px"
id="del__{item.name}"
on:click|preventDefault={async (ev) => {
let delete_item = window.confirm('Bist du sicher?');
if (delete_item) {
// bucket: tatort, name: <vorgang>/item-name
let vorgang = $page.params.vorgang;
let filename = '';
if (ev && ev.currentTarget && (ev.currentTarget as HTMLElement).id) {
filename = (ev.currentTarget as HTMLElement).id.split('del__')[1];
}
// delete request
// --------------
let url = new URL($page.url);
url.pathname += `/${filename}`;
try {
const response = await fetch(`/api${url.pathname}`, { method: 'DELETE' });
if (response.status == 204) {
setTimeout(() => {
window.location.reload();
}, 500);
}
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
} else {
console.log(error);
}
}
}
}}
aria-label="Datei löschen"
>
<Trash />
</button>
{:else} {:else}
<span class="text-sm font-semibold leading-6 text-gray-900 inline-block min-w-1" {#each crimesList as item (item.name)}
>{item.name}</span <li
data-testid="test-list-item"
class="flex items-center justify-between gap-6 py-4 px-2 hover:bg-gray-50 rounded-lg transition"
> >
{/if} <div class="flex items-center gap-4 flex-1">
<p class="mt-1 truncate text-xs leading-5 text-gray-500"> <a
{shortenFileSize(item.size)} data-testid="crime-link"
</p> href="{ROUTE_NAMES.CRIME(vorgangToken, item.name, vorgangPIN)}"
</div> class="flex items-center justify-center w-8 h-8 text-gray-600 hover:text-blue-600 transition"
</div> aria-label="{ROUTE_NAMES.CRIME(vorgangToken, item.name, vorgangPIN)}"
<div class="hidden sm:flex sm:flex-col sm:items-end"> title={item.name}
<p class="text-sm leading-6 text-gray-900">3D Tatort</p>
<p class="mt-1 text-xs leading-5 text-gray-500">
Zuletzt geändert <time datetime="2023-01-23T13:23Z"
>{timeElapsed(new Date(item.lastModified))}</time
> >
</p> <Cube class="w-5 h-5" />
</div>
</a> </a>
<div class="flex flex-col flex-1 min-w-0">
{#if admin}
<NameItemEditor
list={crimesList}
currentName={item.name}
onSave={handleSave}
onDelete={handleDelete}
/>
{:else}
<p
data-testid="test-nameItem-p"
class="text-sm font-semibold leading-6 text-gray-900 truncate"
>
{item.name}
</p>
{/if}
<!-- size left, last modified right -->
<div class="flex items-center justify-between mt-1 text-xs leading-5 text-gray-500">
{#if item.size}
<span>{shortenFileSize(item.size)}</span>
{:else}
<span></span>
{/if}
{#if item.lastModified}
<span>
Zuletzt geändert
<time datetime={item.lastModified}>
{timeElapsed(new Date(item.lastModified))}
</time>
</span>
{/if}
</div>
</div>
</div>
</li> </li>
{/each} {/each}
{/if}
</ul> </ul>
</div> </div>
{#if admin}
<div class="flex justify-center my-4">
<ExpandableForm>
<div class="mx-auto max-w-2xl">
<div class="flex flex-col items-center space-y-6">
<!-- Name Input -->
<div class="w-full max-w-md">
<label for="name" class="block text-sm font-medium leading-6 text-gray-900">
<span class="flex">
{#if formErrors?.name}
<span class="inline-block mr-1"><Exclamation /></span>
{/if} Modellname
</span>
</label>
<div class="mt-2">
<div
class="flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600"
>
<input
bind:value={name}
type="text"
name="name"
id="name"
autocomplete={name}
class="block flex-1 border-0 bg-transparent py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6"
/>
</div>
</div>
{#if formErrors?.name}
<p class="block text-sm leading-6 text-red-900 mt-2">{formErrors.name}</p>
{/if}
</div>
<!-- File Upload -->
<div class="w-full max-w-md">
<label for="file" class="block text-sm font-medium leading-6 text-gray-900">
<span class="flex">
{#if formErrors?.file}
<span class="inline-block mr-1"><Exclamation /></span>
{/if} Datei
</span>
</label>
<div
class="mt-2 flex justify-center rounded-lg border border-dashed px-6 py-10
{isDragging
? 'border-blue-500 bg-blue-50'
: 'border-gray-900/25'}"
on:dragover|preventDefault={() => (isDragging = true)}
on:dragleave={() => (isDragging = false)}
on:drop={handleDrop}
>
<div class="text-center">
<FileRect />
<div class="mt-4 flex text-sm leading-6 text-gray-600">
<label
for="file"
class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500"
>
<span>Wähle eine Datei aus</span>
<input id="file" bind:this={fileInput} bind:files name="file" type="file" class="sr-only" />
</label>
<p class="pl-1">oder ziehe sie ins Feld</p>
</div>
<p class="text-xs leading-5 text-gray-600">GLB Dateien bis zu 1GB</p>
{#if files?.length}
<div class="flex justify-center text-xs mt-2">
<p class="mx-2">Datei: <span class="font-bold">{files[0].name}</span></p>
<p class="mx-2">
Größe: <span class="font-bold">{shortenFileSize(files[0].size)}</span>
</p>
</div>
{/if}
</div>
</div>
{#if formErrors?.file}
<p class="block text-sm leading-6 text-red-900 mt-2">{formErrors.file}</p>
{/if}
</div>
<div class="mt-6 flex items-center justify-end gap-x-6">
<Button
on:click={buttonClick}
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
Hinzufügen
</Button>
</div>
</div>
</div>
</ExpandableForm>
</div>
{/if}
<Modal {open} <Modal {open}
><ModalTitle>Umbenennen</ModalTitle><ModalContent> ><ModalTitle>Umbenennen</ModalTitle><ModalContent>
{#if inProgress} {#if inProgress}
<p class="py-2 mb-1">Vorgang läuft...</p> <p class="py-2 mb-1">Vorgang läuft...</p>
{/if} {:else if isError}
{#if err}
<Alert class="w-full" type="error">Fehler beim Umbenennen</Alert> <Alert class="w-full" type="error">Fehler beim Umbenennen</Alert>
{:else}
<Alert class="w-full">Umbenennen erfolgreich</Alert>
{/if} {/if}
</ModalContent> </ModalContent>
<ModalFooter><Button disabled={inProgress} on:click={uploadSuccessful}>Ok</Button></ModalFooter> <ModalFooter><Button disabled={inProgress} on:click={closeModal}>Ok</Button></ModalFooter>
</Modal> </Modal>
</div>
<Modal open={openUL}
><ModalTitle>Upload</ModalTitle><ModalContent>
{#if inProgressUL}
<p class="py-2 mb-1">Upload läuft...</p>
{:else if etag}
<Alert class="w-full">Upload erfolgreich</Alert>
{:else}
<Alert class="w-full" type="error">Fehler beim Upload</Alert>
{/if}
</ModalContent>
<ModalFooter
><Button disabled={inProgressUL} on:click={uploadSuccessful}>Ok</Button></ModalFooter
>
</Modal>
</div>
{/if}
<style> <style>
ul { ul {

View File

@@ -1,35 +0,0 @@
import { client } from '$lib/minio';
import { json } from '@sveltejs/kit';
// rename operation
export async function PUT({ request }: {request: Request}) {
const data = await request.json();
// Vorgang
const vorgang = request.url.split('/').at(-1);
// prepare copy, incl. check if new name exists already
const old_name = data["old_name"];
const src_full_path = `/tatort/${vorgang}/${old_name}`;
const new_name = `${vorgang}/${data["new_name"]}`;
try {
await client.statObject('tatort', new_name);
return json({ msg: 'Die Datei existiert bereits.' }, { status: 400 });
} catch (error) {
// continue operation
console.log(error, 'continue operation');
}
// actual copy operation
await client.copyObject('tatort', new_name, src_full_path)
// delete
await client.removeObject('tatort', `${vorgang}/${old_name}`)
// return success or failure
return json({ success: 'success' }, { status: 200 });
};

View File

@@ -1,11 +0,0 @@
import { redirect } from '@sveltejs/kit';
export const actions = {
default: async ({request}: {request: Request}) => {
const data = await request.formData();
const caseId = data.get('case-id');
const caseToken = data.get('case-token');
if( caseId && caseToken) throw redirect(303, `/list/${caseId}?token=${caseToken}`);
}
}

View File

@@ -1,39 +0,0 @@
<script lang="ts">
import BaseInputField from '$lib/components/BaseInputField.svelte';
import Button from '$lib/components/Button.svelte';
import ArrowRight from '$lib/icons/Arrow-right.svelte';
export let form;
</script>
<div class="mx-auto max-w-2xl">
<div class="flex flex-col items-center justify-center w-full">
<h1 class="text-xl">Vorgang ansehen</h1>
</div>
<p class="mt-8 mb-8 text-sm leading-6 text-gray-600">
Anhand der Vorgangsnummer werden Sie zu den Dateien des Vorgangs weitergeleitet und können sich
den Vorgang dann ansehen.
</p>
<form method="POST">
<BaseInputField
id="case-id"
name="case-id"
label="Vorgangskennung"
type="text"
value={form?.caseId}
/>
<div class="mt-5">
<BaseInputField
id="case-token"
name="case-token"
label="Zugangscode"
type="text"
value={form?.token}
error={form?.error?.message}
/>
</div>
<div class="flex justify-end pt-4">
<Button type="submit"><ArrowRight /></Button>
</div>
</form>
</div>

View File

@@ -1,8 +1,8 @@
import { client } from '$lib/minio'; import { BUCKET, client } from '$lib/minio';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params }) => {
const { vorgang, tatort } = params; const { vorgang, tatort } = params;
const url = await client.presignedUrl('GET', 'tatort', `${vorgang}/${tatort}`); const url = await client.presignedUrl('GET', BUCKET, `${vorgang}/${tatort}`);
return { url }; return { url };
} };

View File

@@ -1,18 +1,35 @@
import { loginUser, logoutUser } from '$lib/server/authService'; import { dev } from '$app/environment';
import { redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import { ROUTE_NAMES } from '../index.js';
import { vorgangPINValidation } from '$lib/server/vorgangService.js';
export const actions = { export const actions = {
login: ({ request, cookies }) => loginUser({ request, cookies }), default: async ({ request, cookies }) => {
logout: (event) => logoutUser(event),
getVorgangByToken: async ({ request }) => {
const data = await request.formData(); const data = await request.formData();
const caseToken = data.get('case-token'); const vorgangToken = data.get('vorgang-token');
const casePassword = data.get('case-password'); const vorgangPIN = data.get('vorgang-pin') as string;
console.log(`+++ ${caseToken} + ${casePassword}`); if (!vorgangPIN) {
return fail(400, { message: 'Bitte einen PIN eingeben.'});
}
if (!caseToken || !casePassword) return; if (!vorgangPINValidation(vorgangToken, vorgangPIN)) {
return fail(400, { message: 'Falsche Zugangsdaten.'});
}
throw redirect(303, `/list/${caseToken}?pw=${casePassword}`); const COOKIE_NAME = `token-${vorgangToken}`;
cookies.set(COOKIE_NAME, vorgangPIN, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: !dev
});
throw redirect(303, ROUTE_NAMES.VORGANG(vorgangToken));
} }
} as const; } as const;
export const load: PageServerLoad = async ({ url }) => {
const vorgang = url.searchParams.get('vorgang');
if (!vorgang) error(404, "Not Found");
};

View File

@@ -1,21 +1,15 @@
<script lang="ts"> <script lang="ts">
import BaseInputField from '$lib/components/BaseInputField.svelte'; import BaseInputField from '$lib/components/BaseInputField.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import Modal from '$lib/components/Modal/Modal.svelte';
import ModalContent from '$lib/components/Modal/ModalContent.svelte';
import ModalFooter from '$lib/components/Modal/ModalFooter.svelte';
import ModalTitle from '$lib/components/Modal/ModalTitle.svelte';
import ArrowRight from '$lib/icons/Arrow-right.svelte'; import ArrowRight from '$lib/icons/Arrow-right.svelte';
import Login from '$lib/icons/Login.svelte';
export let form; export let form;
export let open = false;
import { page } from '$app/state'; import { page } from '$app/state';
import { ROUTE_NAMES } from '../index.js';
const vorgangToken = page.url.searchParams.get('vorgang'); const vorgangToken = page.url.searchParams.get('vorgang');
</script> </script>
{#if vorgangToken}
<div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8"> <div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-sm"> <div class="sm:mx-auto sm:w-full sm:max-w-sm">
<img class="mx-auto h-10 w-auto" src="/Landeswappen_NI.svg" alt="Landeswappen Niedersachsen" /> <img class="mx-auto h-10 w-auto" src="/Landeswappen_NI.svg" alt="Landeswappen Niedersachsen" />
@@ -27,73 +21,30 @@
<div class="w-full max-w-sm mx-auto"> <div class="w-full max-w-sm mx-auto">
<div class="relative mt-5 bg-gray-50 rounded-xl shadow-xl p-3 pt-1"> <div class="relative mt-5 bg-gray-50 rounded-xl shadow-xl p-3 pt-1">
<div class="mt-10"> <div class="mt-10">
<form action="?/getVorgangByToken" method="POST">
<BaseInputField <form method="POST">
id="case-token" <input type="hidden" name="vorgang-token" value={vorgangToken} />
name="case-token"
label="Vorgangskennung"
type="text"
value={vorgangToken}
/>
<div class="mt-5"> <div class="mt-5">
<BaseInputField <BaseInputField
id="case-password" id="vorgang-pin"
name="case-password" name="vorgang-pin"
label="Zugangspasswort" label="Zugangs-PIN"
type="text" type="text"
value={form?.password} value={form?.vorgangPIN}
error={form?.error?.message} error={form?.error?.message}
/> />
</div> </div>
{#if form?.message}
<p class="block text-sm leading-6 text-red-900 mt-2">{form.message}</p>
{/if}
<div class="flex justify-end pt-4"> <div class="flex justify-end pt-4">
<Button type="submit"><ArrowRight /></Button> <Button type="submit"><ArrowRight /></Button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<div class="flex justify-end mt-10 px-3">
<Button on:click={() => (open = true)}><Login /></Button>
</div>
</div> </div>
</div> </div>
<Modal {open}> {/if}
<ModalTitle>Anmelden</ModalTitle>
<ModalContent class="flex justify-center">
<form action="?/login" method="POST">
<div>
<label for="user" class="text-sm font-medium leading-6 text-gray-900">Kennung</label>
<div class="mt-2">
<input
id="user"
name="user"
type="text"
autocomplete="email"
required
class="rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium leading-6 text-gray-900"
>Passwort</label
>
<div class="mt-2">
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div class="flex justify-end">
<Button type="submit" class="mt-5">Anmelden</Button>
</div>
</form>
</ModalContent>
<ModalFooter><Button on:click={() => (open = false)}>Ok</Button></ModalFooter>
</Modal>

View File

@@ -0,0 +1,9 @@
import { getVorgaenge } from '$lib/server/vorgangService';
export async function GET() {
const vorgaenge = getVorgaenge();
return new Response(JSON.stringify(vorgaenge), {
status: 200
});
}

View File

@@ -1,10 +1,10 @@
import { client } from '$lib/minio'; import { BUCKET, client } from '$lib/minio';
import { db } from '$lib/server/dbService'; import { json } from '@sveltejs/kit';
import { import {
deleteVorgangByToken, deleteVorgangByToken,
getVorgangByToken, getCrimesListByToken,
getVorgangByName, vorgangNameExists,
vorgangNameExists updateVorgangAttrByToken
} from '$lib/server/vorgangService'; } from '$lib/server/vorgangService';
export async function DELETE({ params }) { export async function DELETE({ params }) {
@@ -12,7 +12,7 @@ export async function DELETE({ params }) {
const object_list = await new Promise((resolve, reject) => { const object_list = await new Promise((resolve, reject) => {
const res = []; const res = [];
const items_str = client.listObjects('tatort', vorgangToken, true); const items_str = client.listObjects(BUCKET, vorgangToken, true);
items_str.on('data', (obj) => { items_str.on('data', (obj) => {
res.push(obj.name); res.push(obj.name);
@@ -25,20 +25,64 @@ export async function DELETE({ params }) {
}); });
}); });
await client.removeObjects('tatort', object_list); await client.removeObjects(BUCKET, object_list);
deleteVorgangByToken(vorgangToken); deleteVorgangByToken(vorgangToken);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
export async function HEAD({ params }) { export async function HEAD({ params }) {
try {
const vorgangName = params.vorgang; const vorgangName = params.vorgang;
const existing = vorgangNameExists(vorgangName); const existing = vorgangNameExists(vorgangName);
if (existing) { return new Response(null, {
return new Response(null, { status: 200 }); status: existing ? 200 : 404
} else { });
return new Response(null, { status: 404 }); } catch (err) {
console.error('Fehler im HEAD-Handler:', err);
return new Response(null, { status: 500 });
} }
} }
export async function GET({ params }) {
try {
const vorgangToken = params.vorgang;
const crimesList = await getCrimesListByToken(vorgangToken);
return new Response(JSON.stringify(crimesList), {
status: 200
});
} catch (err) {
console.error('Fehler im GET-Handler:', err);
return new Response(null, { status: 500 });
}
}
// change Vorgang properties
export async function PUT({ request }) {
const data = await request.json();
const vorgangToken = data['vorgangToken'];
const changePIN = data['changePIN'];
let attrChanged;
let newValue;
if (changePIN) {
attrChanged = 'pin';
newValue = data['newVorgangPIN']
} else {
attrChanged = 'name';
newValue = data['newName']
}
const res = updateVorgangAttrByToken(vorgangToken, newValue, attrChanged);
if (!res) {
return json({ msg: 'Fehler beim Umbenennen' }, { status: 400 });
}
return json({ success: 'success' }, { status: 200 });
}

View File

@@ -1,4 +1,5 @@
import { BUCKET, client } from '$lib/minio'; import { BUCKET, client } from '$lib/minio';
import { json } from '@sveltejs/kit';
export async function GET() { export async function GET() {
const stream = client.listObjectsV2(BUCKET, '', true); const stream = client.listObjectsV2(BUCKET, '', true);
@@ -23,8 +24,7 @@ export async function GET() {
}); });
} }
export async function DELETE({ request }) {
export async function DELETE({ request }: { request: Request }) {
const url_fragments = request.url.split('/'); const url_fragments = request.url.split('/');
const item = url_fragments.at(-1); const item = url_fragments.at(-1);
const vorgang = url_fragments.at(-2); const vorgang = url_fragments.at(-2);
@@ -33,3 +33,31 @@ export async function DELETE({ request }: { request: Request }) {
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
// rename operation for crimes
export async function PUT({ params, request }) {
const data = await request.json();
const vorgangToken = params.vorgang;
// prepare copy, incl. check if new name exists already
const crimeOldName = data['oldName'];
const crimeS3FullBucketPathOld = `/${BUCKET}/${vorgangToken}/${crimeOldName}`;
const crimeNewName = `${vorgangToken}/${data['newName']}`;
if (!crimeOldName || !crimeNewName) {
return json({ msg: 'Der Name darf nicht leer sein.' }, { status: 400 });
}
try {
await client.statObject(BUCKET, crimeNewName);
return json({ msg: 'Die Datei existiert bereits.' }, { status: 400 });
} catch (error) {
console.log(error, 'continue operation');
}
await client.copyObject(BUCKET, crimeNewName, crimeS3FullBucketPathOld);
await client.removeObject(BUCKET, `${vorgangToken}/${crimeOldName}`);
return json({ success: 'success' }, { status: 200 });
}

View File

@@ -1,16 +0,0 @@
import { db } from '$lib/server/dbService';
/** @type {import('./$types').RequestHandler} */
export async function GET({ params }) {
const vorgangName = params.vorgang;
let getCodeSQLStatement = `SELECT pw FROM cases WHERE name = ?;`;
const row = db.prepare(getCodeSQLStatement).get(vorgangName);
let password = row.pw;
if (password) {
return new Response(password, { status: 200 });
} else {
return new Response(null, { status: 404 });
}
}

View File

@@ -0,0 +1,10 @@
//Rollenabfrage ob Benutzer admin ist
import { json } from "@sveltejs/kit";
export async function GET({locals}) {
const isAdmin = locals.user?.admin === true;
const data = {admin: isAdmin}
return json(data)
}

View File

@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import { addUser, getUsers } from '$lib/server/userService';
import bcrypt from 'bcrypt';
const saltRounds = 12;
export function GET() {
const userList = getUsers();
return new Response(JSON.stringify(userList));
}
export async function POST({ request }) {
const data = await request.json();
const userName = data.userName;
const userPassword = data.userPassword;
if (!userName || !userPassword) {
return json({ error: 'Missing input' }, { status: 400 });
}
const hashedPassword = bcrypt.hashSync(userPassword, saltRounds);
const rowInfo = addUser(userName, hashedPassword);
if (rowInfo?.changes == 1) {
return json({ userId: rowInfo.lastInsertRowid, userName: userName }, { status: 201 });
} else {
return new Response(null, { status: 400 });
}
}

View File

@@ -0,0 +1,8 @@
import { deleteUser } from '$lib/server/userService';
export async function DELETE({ params }) {
const userId = params.user;
const rowCount = deleteUser(userId);
return new Response(null, { status: rowCount == 1 ? 204 : 400 });
}

View File

@@ -0,0 +1,18 @@
import { db } from '$lib/server/dbService';
/** @type {import('./$types').RequestHandler} */
export async function GET({ params }) {
const vorgangName = params.vorgang;
const getPINSQLStatement = `SELECT pin
FROM cases
WHERE name = ?;`;
const row = db.prepare(getPINSQLStatement).get(vorgangName);
const vorgangPIN = row?.pin;
if (vorgangPIN) {
return new Response(vorgangPIN, { status: 200 });
} else {
return new Response(null, { status: 404 });
}
}

41
src/routes/index.ts Normal file
View File

@@ -0,0 +1,41 @@
export const ROUTE_NAMES = {
ROOT: '/',
// (angemeldet)
LIST: '/list',
UPLOAD: '/upload',
// UPLOAD actions
UPLOAD_URL: '/upload?/url',
UPLOAD_VALIDATE: '/upload?/validate',
USERMGMT: '/user-management',
// (token-based)
VORGANG: (vorgangToken: string) => `/list/${vorgangToken}`,
CRIME: (vorgangToken: string, tatort: string) => `/view/${vorgangToken}/${tatort}`,
// Anmeldung: actions
ANMELDUNG: '/anmeldung',
LOGIN: '/?/login',
LOGOUT: '/?/logout',
ANMELDUNG_GET_VORGANG_BY_TOKEN: '/anmeldung?/getVorgangByToken',
ANMELDUNG_VORGANG_PARAM: (vorgangToken: string) => `/anmeldung?vorgang=${vorgangToken}`
};
export const API_ROUTES = {
LIST: '/api/list',
VORGANG: (vorgangToken: string) => `/api/list/${vorgangToken}`,
// via `HEAD` method
VORGANG_NAME_EXIST: (vorgangName: string) => `/api/list/${vorgangName}`,
VORGANG_PIN: (vorgangName: string) => `/api/vorgang/${vorgangName}/vorgangPIN`,
// Tatort
CRIME: (vorgangToken: string, crimeName: string) => `/api/list/${vorgangToken}/${crimeName}`,
// Users
USERS: '/api/users',
USER: (userId: string) => `/api/users/${userId}`
};
const isProd = process.env.NODE_ENV == 'production';
export const DB_FULLPATH = !isProd ? './src/lib/data/tatort.db' : '/daten/tatort.db';

View File

@@ -0,0 +1,37 @@
import { describe, test, expect, vi } from 'vitest';
import { handle } from '../../src/hooks.server';
const event = {
url: new URL("http://localhost/api/list"),
cookies: { get: vi.fn(() => null) },
locals: {user: null}
};
vi.mock('$lib/auth', () => ({
decryptToken: vi.fn()
}));
describe('API-Endpoints: Zugangs-Mechanismus', () => {
test('Unautorisierter Zugriff', async () => {
const resolve = vi.fn();
const response = await handle({ event, resolve });
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe('Unauthorized');
expect(resolve).not.toHaveBeenCalled();
});
test('Authentifizierter Zugriff', async () => {
event.locals = {user: { id: 'admin', admin: true }}
const resolve = vi.fn(() => new Response('ok', { status: 200 }));
const response = await handle({ event, resolve });
expect(response.status).toBe(200);
expect(await response.text()).toBe('ok');
expect(resolve).toHaveBeenCalled();
});
})

49
tests/api/List.test.ts Normal file
View File

@@ -0,0 +1,49 @@
import { describe, test, expect, vi } from 'vitest';
import { GET } from '$root/routes/api/list/+server';
import { getVorgaenge } from '$lib/server/vorgangService';
// Mocks
vi.mock('$lib/server/vorgangService', () => ({
getVorgaenge: vi.fn()
}));
const event = {
locals: {
user: { id: 'admin', admin: true }
}
};
describe('API-Endpoints: list', () => {
test('Leere Liste wenn keine Vorgänge existieren', async () => {
vi.mocked(getVorgaenge).mockReturnValueOnce([]);
const response = await GET(event);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual([]);
});
test('Liste mit existierenden Vorgängen', async () => {
const testVorgaenge = [
{
vorgangToken: '19f1d34e-4f31-48e8-830f-c4e42c29085e',
vorgangName: 'xyz-123',
vorgangPIN: 'pin-123'
},
{
vorgangToken: '7596e4d5-c51f-482d-a4aa-ff76434305fc',
vorgangName: 'vorgang-2',
vorgangPIN: 'pin-2'
}
];
vi.mocked(getVorgaenge).mockReturnValueOnce(testVorgaenge);
const response = await GET(event);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual(testVorgaenge);
});
});

View File

@@ -0,0 +1,122 @@
import { describe, test, expect, vi } from 'vitest';
import { DELETE, GET, HEAD } from '$root/routes/api/list/[vorgang]/+server';
import {
getCrimesListByToken,
vorgangNameExists,
deleteVorgangByToken
} from '$lib/server/vorgangService';
import { BUCKET, client } from '$lib/minio';
import { EventEmitter } from 'events';
// Mocks
vi.mock('$lib/server/vorgangService', () => ({
getCrimesListByToken: vi.fn(),
vorgangNameExists: vi.fn(),
deleteVorgangByToken: vi.fn()
}));
vi.mock('$lib/minio', () => ({
client: {
listObjects: vi.fn(),
removeObjects: vi.fn()
},
BUCKET: 'tatort-test'
}));
const MockEvent = {
params: { vorgang: '123' },
locals: {
user: { id: 'admin', admin: true }
}
};
describe('API-Endpoints: list/[vorgang]', () => {
test('Vorgang ohne Tatorte', async () => {
const testCrimesList = [];
vi.mocked(getCrimesListByToken).mockReturnValueOnce(testCrimesList);
const response = await GET(MockEvent);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual(testCrimesList);
});
test('Vorgang mit Tatorte', async () => {
const testCrimesList = [
{
name: 'model-A',
lastModified: '2025-08-28T09:44:12.453Z',
etag: '558f35716f6af953f9bb5d75f6d77e6a',
size: 8947140,
prefix: '7596e4d5-c51f-482d-a4aa-ff76434305fc',
show_button: true
},
{
name: 'model-z',
lastModified: '2025-08-28T10:37:20.142Z',
etag: '43e3989c32c4682bee407baaf83b6fa0',
size: 35788560,
prefix: '7596e4d5-c51f-482d-a4aa-ff76434305fc',
show_button: true
}
];
vi.mocked(getCrimesListByToken).mockReturnValueOnce(testCrimesList);
const response = await GET(MockEvent);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual(testCrimesList);
});
test('Vorgang existiert via HEAD', async () => {
const vorgangExists = true;
vi.mocked(vorgangNameExists).mockReturnValueOnce(vorgangExists);
const response = await HEAD(MockEvent);
expect(response.status).toBe(200);
const textContent = await response.text();
expect(textContent).toEqual('');
});
test('Vorgang existiert nicht via HEAD', async () => {
const vorgangExists = false;
vi.mocked(vorgangNameExists).mockReturnValueOnce(vorgangExists);
const response = await HEAD(MockEvent);
expect(response.status).toBe(404);
const textContent = await response.text();
expect(textContent).toEqual('');
});
test('Lösche Vorgang und dazugehörige S3 Objekte', async () => {
// Mock data
const fakeStream = new EventEmitter();
vi.mocked(client.listObjects).mockReturnValue(fakeStream);
vi.mocked(client.removeObjects).mockResolvedValue(undefined);
vi.mocked(deleteVorgangByToken).mockReturnValueOnce(undefined);
const responsePromise = DELETE(MockEvent);
const fakeCrimeNames = [
`${MockEvent.params.vorgang}/file1.glb`,
`${MockEvent.params.vorgang}/file2.glb`
];
// simulate data stream
fakeStream.emit('data', { name: fakeCrimeNames[0] });
fakeStream.emit('data', { name: fakeCrimeNames[1] });
fakeStream.emit('end');
const response = await responsePromise;
expect(client.removeObjects).toHaveBeenCalledWith(BUCKET, fakeCrimeNames);
expect(deleteVorgangByToken).toHaveBeenCalledWith(MockEvent.params.vorgang);
expect(response.status).toBe(204);
});
});

View File

@@ -0,0 +1,98 @@
import { describe, test, expect, vi } from 'vitest';
import { DELETE, PUT } from '$root/routes/api/list/[vorgang]/[tatort]/+server';
import { BUCKET, client } from '$lib/minio';
import { baseData } from '../fixtures';
// Mock data and methods
const fakeVorgangToken = `c399423a-ba37-4fe1-bbdf-80e5881168ff`;
const fakeCrimeOldName = `model-A`;
const fakeCrimeNewName = 'model-Z';
const fakeCrimePath = `${fakeVorgangToken}/${fakeCrimeOldName}`;
const fullFakeCrimePath = `/${BUCKET}/${fakeCrimePath}`;
const fakeCrimeAPIURL = `http://localhost:5173/api/list/${fakeCrimePath}`;
vi.mock('$lib/minio', () => ({
client: {
removeObject: vi.fn(),
statObject: vi.fn(),
copyObject: vi.fn()
},
BUCKET: 'tatort'
}));
describe('API-Endpoints: list/[vorgang]/[tatort]', () => {
test('Löschen von Tatorten', async () => {
const request = new Request(fakeCrimeAPIURL);
const locals = { user: baseData.user }
const response = await DELETE({ locals, request });
expect(client.removeObject).toHaveBeenCalledWith(BUCKET, fakeCrimePath);
expect(response.status).toBe(204);
const responseBody = await response.text();
expect(responseBody).toBe('');
});
test('Umbennen von Tatorten: Erfolgreich', async () => {
const request = new Request(fakeCrimeAPIURL, {
method: 'PUT',
body: JSON.stringify({
oldName: fakeCrimeOldName,
newName: fakeCrimeNewName
})
});
const params = { vorgang: fakeVorgangToken };
const locals = { user: baseData.user }
// Mock Datei nicht gefunden
client.statObject.mockRejectedValueOnce(new Error('NotFound'));
const response = await PUT({ locals, params, request });
const fakeCrimeNewPath = `${fakeVorgangToken}/${fakeCrimeNewName}`;
expect(client.statObject).toHaveBeenCalledWith(BUCKET, fakeCrimeNewPath);
expect(client.copyObject).toHaveBeenCalledWith(BUCKET, fakeCrimeNewPath, fullFakeCrimePath);
expect(client.removeObject).toHaveBeenCalledWith(BUCKET, fakeCrimePath);
expect(response.status).toBe(200);
});
test('Umbennen von Tatorten: Fehlende(r) Name', async () => {
const request = new Request(fakeCrimeAPIURL, {
method: 'PUT',
body: JSON.stringify({
oldName: '',
newName: ''
})
});
const locals = { user: baseData.user }
const params = { vorgang: fakeVorgangToken };
const response = await PUT({ locals, params, request });
expect(response.status).toBe(400);
});
test('Umbennen von Tatorten: Existierender Name', async () => {
const request = new Request(fakeCrimeAPIURL, {
method: 'PUT',
body: JSON.stringify({
oldName: fakeCrimeOldName,
newName: fakeCrimeNewName
})
});
const params = { vorgang: fakeVorgangToken };
const locals = { user: baseData.user }
// Datei existiert bereits
client.statObject.mockResolvedValueOnce({});
const response = await PUT({ locals, params, request });
expect(response.status).toBe(400);
const fakeCrimeNewPath = `${fakeVorgangToken}/${fakeCrimeNewName}`;
expect(client.statObject).toHaveBeenCalledWith(BUCKET, fakeCrimeNewPath);
expect(client.copyObject).not.toHaveBeenCalled();
expect(client.removeObject).not.toHaveBeenCalled();
});
});

40
tests/api/User.test.ts Normal file
View File

@@ -0,0 +1,40 @@
import { describe, test, expect } from 'vitest';
import { GET } from '$root/routes/api/user/+server';
const id = 'admin';
describe('API-Endpoints: User ist Admin', () => {
test('User ist Admin', async () => {
const admin = true;
const event = {
locals: {
user: { id, admin }
}
};
const fakeResult = { admin };
const response = await GET(event);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual(fakeResult);
});
test('User ist kein Admin', async () => {
const admin = false;
const event = {
locals: {
user: { id, admin }
}
};
const fakeResult = { admin };
const response = await GET(event);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual(fakeResult);
});
});

148
tests/api/Users.test.ts Normal file
View File

@@ -0,0 +1,148 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { GET, POST } from '$root/routes/api/users/+server';
import bcrypt from 'bcrypt';
import { addUser, getUsers } from '$lib/server/userService';
vi.mock('$lib/server/userService', () => ({
addUser: vi.fn(),
getUsers: vi.fn()
}));
vi.mock('bcrypt', () => ({
default: {
hashSync: vi.fn()
}
}));
describe('API-Endpoint: Users', () => {
// [INFO] Test auf keine User nicht notwendig, da immer min. ein User vorhanden
// Mock eingelogter User bzw. stelle locals.user zur Verfügung
const fakeLoggedInUser = { id: 'admin', admin: true };
const mockLocals = {
user: fakeLoggedInUser
};
test('Rufe Liste aller User ab', async () => {
const fakeLoggedInUser = { id: 'admin', admin: true };
const event = {
locals: {
user: fakeLoggedInUser
}
};
const fakeResult = [{ userId: 42, userName: 'admin' }];
getUsers.mockReturnValueOnce(fakeResult);
const response = await GET(event);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual(fakeResult);
});
test('Füge Benutzer hinzu: Erfolgreich', async () => {
// Mocke Parameter und Funktionen von Drittparteien
const fakeUsersAPIURL = `http://localhost:5173/api/users`;
const fakeUserID = 42;
const fakeUsername = 'admin';
const fakeUserPassword = 'pass-123';
const mockRequest = new Request(fakeUsersAPIURL, {
method: 'POST',
body: JSON.stringify({
userName: fakeUsername,
userPassword: fakeUserPassword
})
});
const mockedHash = 'mocked-hash';
bcrypt.hashSync.mockReturnValueOnce(mockedHash);
const mockedRowInfo = {
changes: 1,
lastInsertRowid: fakeUserID
};
addUser.mockReturnValueOnce(mockedRowInfo);
const response = await POST({
request: mockRequest,
locals: mockLocals
});
expect(response.status).toBe(201);
const fakeResult = { userId: fakeUserID, userName: fakeUsername };
const json = await response.json();
expect(json).toEqual(fakeResult);
expect(addUser).toHaveBeenCalledWith(fakeUsername, mockedHash);
});
test('Füge Benutzer hinzu: Fehlender Name oder Passwort', async () => {
// Mocke Parameter und Funktionen von Drittparteien
const fakeUsersAPIURL = `http://localhost:5173/api/users`;
const fakeUserID = 42;
const fakeUsername = '';
const fakeUserPassword = '';
const mockRequest = new Request(fakeUsersAPIURL, {
method: 'POST',
body: JSON.stringify({
userName: fakeUsername,
userPassword: fakeUserPassword
})
});
const response = await POST({
request: mockRequest,
locals: mockLocals
});
expect(response.status).toBe(400);
const errorMessage = { error: 'Missing input' };
const json = await response.json();
expect(json).toEqual(errorMessage);
expect(addUser).not.toHaveBeenCalled();
});
test('Füge Benutzer hinzu: Nicht erfolgreich, keine Datenbankänderung', async () => {
// Mocke Parameter und Funktionen von Drittparteien
const fakeUsersAPIURL = `http://localhost:5173/api/users`;
const fakeUserID = 42;
const fakeUsername = 'admin';
const fakeUserPassword = 'pass-123';
const mockRequest = new Request(fakeUsersAPIURL, {
method: 'POST',
body: JSON.stringify({
userName: fakeUsername,
userPassword: fakeUserPassword
})
});
const mockedHash = 'mocked-hash';
bcrypt.hashSync.mockReturnValueOnce(mockedHash);
const mockedRowInfo = {
changes: 0,
lastInsertRowid: fakeUserID
};
addUser.mockReturnValueOnce(mockedRowInfo);
const response = await POST({
request: mockRequest,
locals: mockLocals
});
expect(response.status).toBe(400);
const body = await response.text();
expect(body).toEqual('');
expect(addUser).toHaveBeenCalledWith(fakeUsername, mockedHash);
});
});

View File

@@ -0,0 +1,45 @@
import { describe, test, expect, vi } from 'vitest';
import { GET } from '$root/routes/api/vorgang/[vorgang]/vorgangPIN/+server';
import { db } from '$lib/server/dbService';
import { baseData } from '../fixtures';
const mockEvent = {
params: { vorgang: '123' },
locals: { user: baseData.user }
};
vi.mock('$lib/server/dbService', () => ({
db: {
prepare: vi.fn()
}
}));
describe('API-Endpoint: Vorgang-PIN', () => {
test('Vorgang PIN: Erfolgreich', async () => {
// only interested in PIN value
const mockPIN = 'pin-123';
const mockRow = { pin: mockPIN };
const getMock = vi.fn().mockReturnValue(mockRow);
db.prepare.mockReturnValue({ get: getMock });
const response = await GET(mockEvent);
expect(response.status).toBe(200);
const body = await response.text();
expect(body).toEqual(mockPIN);
});
test('Vorgang PIN: Nicht erfolgreich', async () => {
const mockRow = {};
const getMock = vi.fn().mockReturnValue(mockRow);
db.prepare.mockReturnValue({ get: getMock });
const response = await GET(mockEvent);
expect(response.status).toBe(404);
const body = await response.text();
expect(body).toEqual('');
});
});

View File

@@ -0,0 +1,9 @@
import { render, screen } from '@testing-library/svelte';
import EmptyList from '$lib/components/EmptyList.svelte'
import { describe, expect, it } from 'vitest';
describe('Komponente: EmptyList', () => {
it('zeigt Hinweistext "Keine Einträge"', () => {
render(EmptyList);
expect(screen.getByText(/keine Einträge/i)).toBeInTheDocument(); });
});

View File

@@ -0,0 +1,47 @@
import { render, screen } from '@testing-library/svelte';
import { describe, test, expect } from 'vitest';
import { ROUTE_NAMES } from '../../src/routes';
import { baseData } from '../fixtures';
import Footer from '$lib/components/Footer.svelte';
describe('Footer component', () => {
test('Enthält Behörden-Name und entsprechenden Link', () => {
render(Footer);
const linkElement = screen.getByText('Innovation Hub', { exact: false });
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', ROUTE_NAMES.LIST);
});
test('Enthält Zurück-Button und entsprechenden Link', () => {
render(Footer);
const linkElement = screen.getByText('back');
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', ROUTE_NAMES.ROOT);
});
test('Enthält Profil-Icon und entsprechenden Link: angemeldet', () => {
render(Footer, { props: { data: baseData } });
const linkElement = screen.getByText('admin');
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', ROUTE_NAMES.ROOT);
// Check for presence of `Profile` component
const svg = screen.getByTestId('profile-component');
expect(svg).toBeTruthy();
});
test('Enthält Profil-Icon und entsprechenden Link: nicht angemeldet', () => {
const { container } = render(Footer, { props: { data: null } });
const links = container.querySelectorAll('a');
const linkElement = links[2]; // Index starts at 0
expect(linkElement).toHaveAttribute('href', ROUTE_NAMES.ROOT);
// User/View does not have any ID
const ID_displayElement = screen.queryByText('admin');
expect(ID_displayElement).not.toBeInTheDocument();
// Check for presence of `Profile` component
const svg = screen.getByTestId('profile-component');
expect(svg).toBeTruthy();
});
});

View File

@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/svelte';
import { describe, test, expect } from 'vitest';
import { ROUTE_NAMES } from '../../src/routes';
import { baseData } from '../fixtures';
import Header from '$lib/components/Header.svelte';
describe('Header component', () => {
test('Enthält Landeswappen von NDS und entsprechenden Link', () => {
render(Header, { props: { data: baseData } });
const linkElement = screen.getByText('Tatort Niedersachen').closest('a');
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', ROUTE_NAMES.ROOT);
});
test('Form enthält korrekten Link', () => {
const { container } = render(Header, { props: { data: baseData } });
const formElement = container.querySelector('form');
expect(formElement).toBeInTheDocument();
expect(formElement).toHaveAttribute('action', ROUTE_NAMES.ANMELDUNG_LOGOUT);
});
});

View File

@@ -0,0 +1,142 @@
import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it, test, vi } from 'vitest';
import NameItemEditor from '$lib/components/NameItemEditor.svelte';
import { baseData } from '../fixtures';
const testCrimesListIndex = 0;
const testItem = baseData.crimesList[testCrimesListIndex];
const testCurrentName = testItem.name;
const testLocalName = 'Fall-C';
describe('NameItemEditor - Funktionalität', () => {
const onSave = vi.fn();
const onDelete = vi.fn();
const baseProps = {
list: baseData.crimesList,
currentName: testCurrentName,
onSave,
onDelete
};
test.todo('FocusIn nach Klick auf edit');
it('zeigt initial Edit/Delete Buttons und aktuellen Namen', () => {
render(NameItemEditor, { props: baseProps });
expect(screen.getByTestId('edit-button')).toBeInTheDocument();
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
expect(screen.queryByTestId('commit-button')).toBeNull();
expect(screen.queryByTestId('cancel-button')).toBeNull();
expect(screen.getByText(testCurrentName)).toBeInTheDocument();
});
it('wechselt zu Commit/Cancel nach Klick auf Edit', async () => {
render(NameItemEditor, { props: baseProps });
await fireEvent.click(screen.getByTestId('edit-button'));
const input = screen.getByTestId('test-input');
expect(screen.getByTestId('commit-button')).toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
expect(screen.queryByTestId('edit-button')).toBeNull();
expect(screen.queryByTestId('delete-button')).toBeNull();
expect(screen.getAllByRole('textbox')).toHaveLength(1);
expect(input).toHaveValue(testCurrentName);
});
it('zeigt Fehlermeldung bei leerem Namen', async () => {
render(NameItemEditor, { props: baseProps });
await fireEvent.click(screen.getByTestId('edit-button'));
const input = screen.getByTestId('test-input');
await fireEvent.input(input, { target: { value: '' } });
expect(screen.getByText('Name darf nicht leer sein.')).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('entfernt Fehlermeldung live beim nächsten gültigen Tastendruck', async () => {
render(NameItemEditor, {
props: {
list: baseData.crimesList,
currentName: baseData.crimesList[0].name,
onSave: vi.fn(),
onDelete: vi.fn()
}
});
await fireEvent.click(screen.getByTestId('edit-button'));
const input = screen.getByTestId('test-input');
await fireEvent.input(input, { target: { value: '' } });
expect(screen.getByText('Name darf nicht leer sein.')).toBeInTheDocument();
await fireEvent.input(input, { target: { value: 'Fall-C' } });
expect(screen.queryByText('Name darf nicht leer sein.')).toBeNull();
});
it('zeigt Fehlermeldung bei Duplikat', async () => {
const duplicateName = baseData.crimesList[1].name;
render(NameItemEditor, { props: baseProps });
await fireEvent.click(screen.getByTestId('edit-button'));
const input = screen.getByTestId('test-input');
await fireEvent.input(input, { target: { value: duplicateName } });
expect(screen.getByText('Name existiert bereits.')).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('ruft onSave korrekt auf bei gültigem Namen: Tatort/Crime', async () => {
render(NameItemEditor, { props: baseProps });
await fireEvent.click(screen.getByTestId('edit-button'));
const input = screen.getByTestId('test-input');
await fireEvent.input(input, { target: { value: testLocalName } });
await fireEvent.click(screen.getByTestId('commit-button'));
expect(onSave).toHaveBeenCalledWith(testLocalName, testCurrentName, undefined);
});
it('ruft onDelete korrekt auf', async () => {
render(NameItemEditor, { props: baseProps });
await fireEvent.click(screen.getByTestId('delete-button'));
expect(onDelete).toHaveBeenCalledWith(testCurrentName);
});
it('setzt Zustand zurück bei Cancel', async () => {
render(NameItemEditor, { props: baseProps });
await fireEvent.click(screen.getByTestId('edit-button'));
const input = screen.getByTestId('test-input');
await fireEvent.input(input, { target: { value: 'Zwischentext' } });
await fireEvent.click(screen.getByTestId('cancel-button'));
expect(screen.getByText(testCurrentName)).toBeInTheDocument();
expect(screen.getByTestId('edit-button')).toBeInTheDocument();
});
it('triggert Save bei Enter-Taste: Tatort/Crime', async () => {
render(NameItemEditor, { props: baseProps });
await fireEvent.click(screen.getByTestId('edit-button'));
const input = screen.getByTestId('test-input');
await fireEvent.input(input, { target: { value: 'ViaEnter' } });
await fireEvent.keyDown(input, { key: 'Enter' });
expect(onSave).toHaveBeenCalledWith('ViaEnter', testCurrentName, undefined);
});
it('bricht ab bei Escape-Taste', async () => {
render(NameItemEditor, { props: baseProps });
await fireEvent.click(screen.getByTestId('edit-button'));
const input = screen.getByTestId('test-input');
await fireEvent.input(input, { target: { value: 'Zwischentext' } });
await fireEvent.keyDown(input, { key: 'Escape' });
expect(screen.getByText(testCurrentName)).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
});

53
tests/fixtures.ts Normal file
View File

@@ -0,0 +1,53 @@
const testUser = {
admin: true,
exp: 1757067123,
iat: 1757063523,
id: 'admin'
};
const testCrimesList = [
{
name: 'Fall-A',
lastModified: '2025-08-28T09:44:12.453Z',
etag: '558f35716f6af953f9bb5d75f6d77e6a',
size: 8947140,
prefix: '7596e4d5-c51f-482d-a4aa-ff76434305fc',
show_button: true
},
{
name: 'Fall-B',
lastModified: '2025-08-28T10:37:20.142Z',
etag: '43e3989c32c4682bee407baaf83b6fa0',
size: 35788560,
prefix: '7596e4d5-c51f-482d-a4aa-ff76434305fc',
show_button: true
}
];
const testVorgangsList = [
{
vorgangName: 'vorgang-1',
vorgangPIN: 'pin-123',
vorgangToken: 'c322f26f-8c5e-4cb9-94b3-b5433bf5109e'
},
{
vorgangName: 'vorgang-2',
vorgangPIN: 'pin-2',
vorgangToken: 'cb0051bc-5f38-47b8-943c-9352d4d9c984'
}
];
export const baseData = {
user: testUser,
vorgang: testVorgangsList[0],
vorgangList: testVorgangsList,
crimesList: testCrimesList,
url: `https://example.com/list/${testVorgangsList[0].vorgangToken}`,
crimeNames: ['modell-A', 'Fall-A']
};
export const mockEvent = {
locals: {
user: baseData.user
},
url: new URL(`https://example.com/anmeldung`)
};

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, vi } from 'vitest';
// import { actions } from '$root/routes/anmeldung/+page.server';
// import { load } from '$root/routes/(token-based)/+layout.server'
import { actions } from '../../src/routes/anmeldung/+page.server';
import { load } from '../../src/routes/(token-based)/+layout.server';
import { baseData } from '../fixtures';
import { ROUTE_NAMES } from '../../src/routes';
import { dev } from '$app/environment';
import { vorgangExists, vorgangPINValidation } from '$lib/server/vorgangService';
import type { Redirect } from '@sveltejs/kit';
vi.mock('$lib/server/vorgangService', () => ({
vorgangExists: vi.fn(),
vorgangPINValidation: vi.fn()
}));
describe('Vorgang Anzeige via Token', () => {
it('Setze Cookie nach erfolgreicher Eingabe', async () => {
// Mock formData
const vorgObj = baseData.vorgang;
const formData = new FormData();
formData.set('vorgang-token', vorgObj.vorgangToken);
formData.set('vorgang-pin', vorgObj.vorgangPIN);
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData)
};
vi.mocked(vorgangPINValidation).mockReturnValueOnce(true);
const cookiesSet = vi.fn();
const event = {
request: mockRequest,
cookies: {
set: cookiesSet
}
};
let thrownRedirect: Redirect | undefined;
try {
await actions.default(event);
} catch (e) {
thrownRedirect = e as Redirect;
}
// Redirect bei erfolgreicher Eingabe
expect(thrownRedirect?.status).toBe(303);
expect(thrownRedirect?.location).toBe(ROUTE_NAMES.VORGANG(vorgObj.vorgangToken));
// Cookie wurde gesetzt
const COOKIE_NAME = `token-${vorgObj.vorgangToken}`;
expect(cookiesSet).toHaveBeenCalledWith(COOKIE_NAME, vorgObj.vorgangPIN, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: !dev
});
});
it('Schlägt fehl wenn keine Daten übergeben werden', async () => {
const formData = new FormData(); // no data
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData)
};
const cookiesSet = vi.fn();
const event = {
request: mockRequest,
cookies: {
set: cookiesSet
}
};
const result = await actions.default(event);
expect(result.status).toBe(400);
expect(result.data.message).toMatch(/PIN eingeben/i);
// Cookie wird nicht gesetzt
expect(cookiesSet).not.toHaveBeenCalled();
});
it.todo('Überprüfe was passiert, wenn Eingabe falsch, bzw. nicht im System passend gefunden');
});
describe('Teste Guard', () => {
it('Lese Cookie aus', async () => {
const vorgObj = baseData.vorgang;
const COOKIE_NAME = `token-${vorgObj.vorgangToken}`;
const cookiesGet = vi.fn().mockImplementation((key: string) => {
if (key === COOKIE_NAME) return vorgObj.vorgangPIN;
return undefined;
});
// mocked objects
const event = {
cookies: {
get: cookiesGet
},
locals: {},
params: { vorgang: vorgObj.vorgangToken }
};
vi.mocked(vorgangExists).mockReturnValueOnce(true);
vi.mocked(vorgangPINValidation).mockReturnValueOnce(true);
await load(event);
expect(cookiesGet).toHaveBeenCalledWith(COOKIE_NAME);
});
it('Kein Cookie gesetzt', async () => {
const vorgObj = baseData.vorgang;
const COOKIE_NAME = `token-${vorgObj.vorgangToken}`;
const cookiesGet = vi.fn().mockImplementation((key: string) => {
if (key === COOKIE_NAME) return vorgObj.vorgangPIN;
return undefined;
});
// mocked objects
const event = {
cookies: {
get: cookiesGet
},
locals: {},
params: { vorgang: vorgObj.vorgangToken }
};
vi.mocked(vorgangExists).mockReturnValueOnce(true);
vi.mocked(vorgangPINValidation).mockReturnValueOnce(false);
let thrownRedirect;
try {
await load(event);
throw new Error('Function did not throw');
} catch (e) {
thrownRedirect = e;
}
expect(thrownRedirect?.status).toBe(303);
expect(thrownRedirect?.location).toBe(
ROUTE_NAMES.ANMELDUNG_VORGANG_PARAM(vorgObj.vorgangToken)
);
expect(cookiesGet).toHaveBeenCalledWith(COOKIE_NAME);
});
});

View File

@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import HomePage from '$root/routes/(angemeldet)/+page.svelte';
import { ROUTE_NAMES } from '../../src/routes';
import { baseData } from '../fixtures';
describe('Home-Page View', () => {
it('Überprüfe Links', () => {
render(HomePage, { props: { data: baseData } });
let linkElement = screen.getByText('Vorgänge');
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', ROUTE_NAMES.LIST);
linkElement = screen.queryByText('Hinzufügen');
expect(linkElement).not.toBeInTheDocument();
linkElement = screen.getByText('Benutzerverwaltung');
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', ROUTE_NAMES.USERMGMT);
});
});

View File

@@ -0,0 +1,24 @@
import { describe, test, expect } from 'vitest';
import { load } from '$root/routes/(angemeldet)/+layout.server';
import { ROUTE_NAMES } from '../../src/routes';
import { baseData, mockEvent } from '../fixtures';
describe('+layout.server load(): Teste korrekte URL', () => {
test('Werfe keinen Redirect und gebe nichts zurück', async () => {
const mockEvent = {
locals: {
user: null
},
url: new URL(`https://example.com/not-anmeldung`)
};
const res = load(mockEvent);
expect(res).toBe(undefined);
});
});
describe('+layout.server load(): Teste erfolgreichen Pfad', () => {
test('Werfe kein Fehler', async () => {
const result = load(mockEvent);
expect(result).toEqual({ user: baseData.user });
});
});

View File

@@ -0,0 +1,139 @@
import { render, fireEvent, screen, within } from '@testing-library/svelte';
import { describe, it, expect, vi, test } from 'vitest';
import * as nav from '$app/navigation';
import TatortListPage from '$root/routes/(token-based)/list/[vorgang]/+page.svelte';
import { baseData } from '../fixtures';
import { tick } from 'svelte';
import { API_ROUTES } from '../../src/routes';
vi.spyOn(nav, 'invalidateAll').mockResolvedValue();
global.fetch = vi.fn().mockResolvedValue({ ok: true });
async function clickPlusButton() {
// mock animation features of the browser
window.HTMLElement.prototype.scrollIntoView = vi.fn();
window.HTMLElement.prototype.animate = vi.fn(() => ({
finished: Promise.resolve(),
cancel: vi.fn(),
}))
// button is visible
const button = screen.getByRole('button', { name: /add item/i })
expect(button).toBeInTheDocument();
await fireEvent.click(button)
}
describe('Seite: Vorgangsansicht', () => {
test.todo('Share Link disabled wenn Liste leer');
describe('Szenario: Admin + Liste gefüllt - Funktionalität', () => {
test.todo('Share Link Link generierung richtig');
it('führt PUT-Request aus und aktualisiert UI nach onSave', async () => {
const data = structuredClone(baseData);
const oldName = data.crimesList[0].name;
const newName = 'Fall-C';
render(TatortListPage, { props: { data } });
const listItem = screen.getAllByTestId('test-list-item')[0];
expect(listItem).toHaveTextContent(oldName);
await fireEvent.click(within(listItem).getByTestId('edit-button'));
const input = within(listItem).getByTestId('test-input');
await fireEvent.input(input, { target: { value: newName } });
await fireEvent.click(within(listItem).getByTestId('commit-button'));
await tick();
expect(global.fetch).toHaveBeenCalledWith(
`/api/list/${data.vorgang.vorgangToken}/${oldName}`,
expect.objectContaining({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vorgangToken: data.vorgang.vorgangToken,
oldName,
newName
})
})
);
expect(nav.invalidateAll).toHaveBeenCalled();
expect(within(listItem).getByText(newName)).toBeInTheDocument();
});
it('führt DELETE-Request aus und entfernt Element aus UI', async () => {
const testData = structuredClone(baseData);
const oldName = testData.crimesList[0].name;
const vorgang = testData.vorgang;
render(TatortListPage, { props: { data: testData } });
const initialItems = screen.getAllByTestId('test-list-item');
expect(initialItems).toHaveLength(testData.crimesList.length);
const listItem = screen.getAllByTestId('test-list-item')[0];
expect(listItem).toHaveTextContent(oldName);
const del = within(listItem).getByTestId('delete-button');
expect(del).toBeInTheDocument()
await fireEvent.click(within(listItem).getByTestId('delete-button'));
await tick();
let expectedPath = API_ROUTES.CRIME(vorgang.vorgangToken, oldName)
expect(global.fetch).toHaveBeenCalledWith(
expectedPath,
expect.objectContaining({
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vorgangToken: testData.vorgang.vorgangToken,
tatort: oldName
})
})
);
expect(nav.invalidateAll).toHaveBeenCalled();
const updatedItems = screen.queryAllByTestId('test-list-item');
expect(updatedItems).toHaveLength(testData.crimesList.length - 1);
expect(screen.queryByText(oldName)).toBeNull();
});
});
});
describe('Hinzufügen Button', () => {
it('Unexpandierter Button', () => {
const testData = { ...baseData, vorgangList: [] };
const { getByTestId } = render(TatortListPage, { props: { data: testData } });
const container = getByTestId('expand-container')
expect(container).toBeInTheDocument();
// button is visible
const button = within(container).getByRole('button')
expect(button).toBeInTheDocument();
// input fields are not visible
let label = screen.queryByText('Modellname');
expect(label).not.toBeInTheDocument();
});
it('Expandierter Button nach Klick', async () => {
const testData = { ...baseData, vorgangList: [] };
render(TatortListPage, { props: { data: testData } });
await clickPlusButton();
// input fields are visible
let label = screen.queryByText('Modellname');
expect(label).toBeInTheDocument();
});
it.todo('Check Validation: missing name', async () => {
console.log(`test: input field validation`);
});
it.todo('Create Tatort successful', async () => {
console.log(`test: tatort upload`);
});
});

View File

@@ -0,0 +1,115 @@
import { render, screen, within } from '@testing-library/svelte';
import { describe, expect, it, test } from 'vitest';
import TatortListPage from '$root/routes/(token-based)/list/[vorgang]/+page.svelte';
import { baseData } from '../fixtures';
import { ROUTE_NAMES } from '../../src/routes';
describe('Seite: Vorgangsansicht', () => {
test.todo('zeigt PIN und Share-Link, wenn Admin');
test.todo('zeigt PIN und Share-Link disabeld, wenn Liste leer');
describe('Szenario: Liste leer (unabhängig von Rolle)', () => {
it('zeigt Hinweistext bei leerer Liste', () => {
const testData = { ...baseData, crimesList: [] };
const { getByTestId } = render(TatortListPage, { props: { data: testData } });
expect(getByTestId('empty-list')).toBeInTheDocument();
});
it('zeigt keinen Listeneintrag', () => {
const items = screen.queryAllByTestId('test-list-item');
expect(items).toHaveLength(0);
});
});
describe('Szenario: Liste gefüllt (unabhängig von Rolle)', () => {
it('rendert mindestens ein Listenelement bei vorhandenen crimesList-Daten', () => {
const testData = { ...baseData };
const { queryAllByTestId } = render(TatortListPage, { props: { data: testData } });
const items = queryAllByTestId('test-list-item');
expect(items.length).toBeGreaterThan(0);
});
it('zeigt für jeden Eintrag einen Link', () => {
const testData = { ...baseData };
render(TatortListPage, { props: { data: testData } });
const links = screen.queryAllByTestId('crime-link');
expect(links).toHaveLength(testData.crimesList.length);
});
it('prüft href und title jedes Links', () => {
const testData = { ...baseData };
const { queryAllByTestId } = render(TatortListPage, { props: { data: testData } });
const items = queryAllByTestId('test-list-item');
items.forEach((item, i) => {
const link = within(item).getByRole('link');
const expectedHref = ROUTE_NAMES.CRIME(testData.vorgang.vorgangToken, testData.crimesList[i].name, testData.vorgang.vorgangPIN);
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', expectedHref);
expect(link).toHaveAttribute('title', testData.crimesList[i].name);
});
});
test.todo('testet zuletzt angezeigt, wenn item.lastModified');
test.todo('zeigt Dateigröße, wenn item.size vorhanden ist');
});
describe('Szenario: Admin + Liste gefüllt', () => {
const testData = { ...baseData, user: { ...baseData.user, admin: true } };
it('zeigt Listeneinträge mit Komponente NameItemEditor', () => {
const { getAllByTestId } = render(TatortListPage, { props: { data: testData } });
const items = getAllByTestId('test-nameItemEditor');
expect(items.length).toBeGreaterThan(0);
});
test.todo('Modal testen, wenn open');
});
describe('Szenario: Viewer + Liste gefüllt', () => {
const testData = { ...baseData, user: { ...baseData.user, admin: false } };
it('zeigt Listeneinträge mit p', () => {
render(TatortListPage, { props: { data: testData } });
const paragraphs = screen.queryAllByTestId('test-nameItem-p');
expect(paragraphs).toHaveLength(testData.crimesList.length);
paragraphs.forEach((p, i) => {
expect(p).toHaveTextContent(testData.crimesList[i].name);
});
});
test.todo('zeigt keinen Share-Link oder PIN');
});
describe('Teste Links auf Korrektheit', () => {
it('Überprüfe Links', () => {
const crimesListOneItem = baseData.crimesList.slice(0, 1);
const crimeObj = crimesListOneItem[0];
const vorgObj = baseData.vorgangList[0]
const expectedURL = ROUTE_NAMES.CRIME(vorgObj.vorgangToken, crimeObj.name, vorgObj.vorgangPIN)
render(TatortListPage, { props: { data: { ...baseData, crimesList: crimesListOneItem } } });
const listItem = screen.getByTestId("test-list-item");
const linkElement = within(listItem).getByRole('link');
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', expectedURL);
});
});
describe('PIN Anzeige & Button', () => {
it('Teste korrekte Anzeige von PIN Komponente', () => {
const testData = { ...baseData};
render(TatortListPage, { props: { data: testData } });
const vorgObj = baseData.vorgangList[0]
// PIN is being displayed within ´NameItemEditor´
let label = screen.queryByText(vorgObj.vorgangPIN);
expect(label).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,184 @@
import { render, fireEvent, screen, within } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';
import VorgangListPage from '$root/routes/(angemeldet)/list/+page.svelte';
import { baseData } from '../fixtures';
import { ROUTE_NAMES } from '../../src/routes';
import { actions } from '../../src/routes/(angemeldet)/list/+page.server';
import { createVorgang } from '$lib/server/vorgangService';
// mock animation features of the browser
window.HTMLElement.prototype.scrollIntoView = vi.fn();
window.HTMLElement.prototype.animate = vi.fn(() => ({
finished: Promise.resolve(),
cancel: vi.fn(),
}))
describe('Vorgänge Liste Page EmptyList-Komponente View', () => {
it('zeigt EmptyList-Komponente an, wenn Liste leer ist', () => {
const testData = { ...baseData, vorgangList: [] };
const { getByTestId } = render(VorgangListPage, { props: { data: testData } });
expect(getByTestId('empty-list')).toBeInTheDocument();
});
it('zeigt Liste(mockData 2 Elemente) an, wenn Liste vorhanden ist', () => {
const testData = { ...baseData };
const { getAllByTestId } = render(VorgangListPage, { props: { data: testData } });
const items = getAllByTestId('test-list-item');
expect(items.length).toBeGreaterThan(0);
});
});
describe('Teste Links auf Korrektheit', () => {
it('Überprüfe Links', () => {
const vorgListOneItem = baseData.vorgangList.slice(0, 1);
const vorgObj = vorgListOneItem[0];
const expectedURL = ROUTE_NAMES.VORGANG(vorgObj.vorgangToken, vorgObj.vorgangPIN)
render(VorgangListPage, { props: { data: { ...baseData, vorgangList: vorgListOneItem } } });
const listItem = screen.getByTestId("test-list-item");
const linkElement = within(listItem).getByRole('link');
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', expectedURL);
});
it('Links enthalten keinen VorgangsPIN', () => {
const vorgListOneItem = baseData.vorgangList.slice(0, 1)
render(VorgangListPage, { props: { data: { ...baseData, vorgangList: vorgListOneItem } } });
const listItem = screen.getByTestId("test-list-item");
const linkElement = within(listItem).getByRole('link');
expect(linkElement.getAttribute('href')?.toLowerCase()).not.toContain('pin');
});
});
async function clickPlusButton() {
// button is visible
const button = screen.getByTestId('expand-button')
expect(button).toBeInTheDocument();
await fireEvent.click(button)
}
async function inputVorgang() {
const input = document.getElementById("vorgang");
input.value = 'test-vorgang';
// firing the event manually for Svelte
await fireEvent.input(input)
expect(input).toHaveValue('test-vorgang');
}
async function inputVorgangPIN() {
const input = document.getElementById("pin");
input.value = 'test-pin';
// firing the event manually for Svelte
await fireEvent.input(input)
expect(input).toHaveValue('test-pin');
}
describe('Hinzufügen Buton', () => {
it('Unexpandierter Button', () => {
const testData = { ...baseData, vorgangList: [] };
const { getByTestId } = render(VorgangListPage, { props: { data: testData } });
const container = getByTestId('expand-container')
expect(container).toBeInTheDocument();
// button is visible
const button = within(container).getByRole('button')
expect(button).toBeInTheDocument();
// input fields are not visible
let label = screen.queryByText('Vorgangsname');
expect(label).not.toBeInTheDocument();
});
it('Expandierter Button nach Klick', async () => {
const testData = { ...baseData, vorgangList: [] };
render(VorgangListPage, { props: { data: testData } });
await clickPlusButton()
// input fields are visible
let label = screen.queryByText('Vorgangsname');
expect(label).toBeInTheDocument();
});
it('Check Validation: missing PIN', async () => {
const testData = { ...baseData, vorgangList: [] };
render(VorgangListPage, { props: { data: testData } });
await clickPlusButton()
// input
inputVorgang();
// submit
const button = screen.getByText('Neuen Vorgang hinzufügen')
expect(button).toBeInTheDocument()
await fireEvent.click(button);
const errorMsg = 'Bitte einen Vorgangs-PIN eingeben.';
let para = await screen.getByText(errorMsg);
expect(para).toBeInTheDocument();
});
it('Create Vorgang successful', async () => {
const testData = { ...baseData, vorgangList: [] };
render(VorgangListPage, { props: { data: testData } });
await clickPlusButton();
// input fields are visible
let label = screen.queryByText('Vorgangsname');
expect(label).toBeInTheDocument();
inputVorgang();
inputVorgangPIN();
// emulate button click
const button = screen.getByText('Neuen Vorgang hinzufügen');
expect(button).toBeInTheDocument();
await fireEvent.click(button);
// no error message
label = screen.queryByText('Bitte');
expect(label).not.toBeInTheDocument();
});
it('Test default action', async () => {
vi.mock('$lib/server/vorgangService', () => ({
createVorgang: vi.fn(),
}));
const formData = new FormData(); // no data as we are mocking createVorgang
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData)
};
const event = {
request: mockRequest,
};
const testVorgangToken = 'c322f26f-8c5e-4cb9-94b3-b5433bf5109e'
vi.mocked(createVorgang).mockReturnValueOnce(testVorgangToken);
const result = await actions.default(event);
expect(result).toEqual({ token: testVorgangToken });
});
});
describe('Vorgang-Operationen', () => {
it('Teste korrekte Anzeige von Vorgang-Input Komponente', () => {
const testData = { ...baseData};
const { getAllByTestId } = render(VorgangListPage, { props: { data: testData } });
let buttons = getAllByTestId('edit-button')
expect(buttons.length).toBeGreaterThan(1);
});
});

View File

@@ -1,19 +1,38 @@
import { svelteTesting } from '@testing-library/svelte/vite'; import { svelteTesting } from '@testing-library/svelte/vite';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import path from 'path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
resolve: {
alias: {
$lib: path.resolve('./src/lib'),
$root: path.resolve(__dirname, './src')
}
},
test: { test: {
workspace: [ projects: [
{ {
extends: './vite.config.ts', extends: './vite.config.ts',
plugins: [svelteTesting()], plugins: [svelteTesting()],
test: { test: {
name: 'client', name: 'business-logic and API',
environment: 'jsdom', environment: 'jsdom',
clearMocks: true, clearMocks: true,
include: ['src/**/*.svelte.{test,spec}.{js,ts}'], include: ['tests/**/*.{test,spec}.{js,ts}', 'src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**', 'tests/**/*.view.{test,spec}.{js,ts}'],
setupFiles: ['./vitest-setup-client.ts']
}
},
{
extends: './vite.config.ts',
plugins: [svelteTesting()],
test: {
name: 'client-view',
environment: 'jsdom',
clearMocks: true,
include: ['tests/**/*.view.{test,spec}.{js,ts}', 'src/**/*.view.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**'], exclude: ['src/lib/server/**'],
setupFiles: ['./vitest-setup-client.ts'] setupFiles: ['./vitest-setup-client.ts']
} }