Compare commits

..

341 Commits

Author SHA1 Message Date
7c21c7045a Fix jwt-token 2025-09-24 05:56:10 +02:00
f7167a41da Fix jwt-token 2025-09-24 05:53:30 +02:00
5d4ee01612 Fix jwt-token 2025-09-24 05:48:28 +02:00
0cd7328748 Fix jwt-token 2025-09-24 05:33:09 +02:00
a7c8ac2fdf Fix jwt-token 2025-09-24 05:30:07 +02:00
3a143c72c5 Fix jwt-token 2025-09-24 05:19:41 +02:00
1a72774848 Fix jwt-token 2025-09-24 05:17:53 +02:00
3e61013a8a Fix jwt-token 2025-09-24 05:08:05 +02:00
6c28330af3 Fix jwt-token 2025-09-24 04:57:07 +02:00
02ce9d343b Fix jwt-token 2025-09-24 04:36:22 +02:00
40c20c8754 Fix jwt-token 2025-09-24 04:32:37 +02:00
71b59e676c Fix jwt-token 2025-09-24 04:28:10 +02:00
e41ae5d65b Fix jwt-token 2025-09-23 16:05:34 +02:00
25d910ed3f Fix jwt-token 2025-09-23 15:56:52 +02:00
4747900c7b Fix jwt-token 2025-09-23 15:51:26 +02:00
c7f4bbbbbd Fix jwt-token 2025-09-23 15:46:07 +02:00
57e7353d4f Fix jwt-token 2025-09-23 15:13:06 +02:00
f40d0d9b89 Fix jwt-token 2025-09-23 15:06:54 +02:00
e3816b056e Fix jwt-token 2025-09-23 15:03:31 +02:00
ea5583d56a Fix jwt-token 2025-09-23 14:03:24 +02:00
c58802ea4b Fix jwt-token 2025-09-23 13:59:41 +02:00
93d87fb73c Fix jwt-token 2025-09-23 13:56:59 +02:00
8fbe2cb354 Fix jwt-token 2025-09-23 13:55:10 +02:00
ee4d3503e5 Fix jwt-token 2025-09-23 13:12:17 +02:00
44047f9c98 Fix jwt-token 2025-09-23 12:59:50 +02:00
fc63446614 Fix jwt-token 2025-09-23 12:46:26 +02:00
521ee52398 Fix jwt-token 2025-09-23 12:42:34 +02:00
fe30501621 Fix jwt-token 2025-09-23 12:40:27 +02:00
aa4761a7e3 Fix jwt-token 2025-09-23 12:31:50 +02:00
3f687abf2b Fix jwt-token 2025-09-23 12:28:43 +02:00
4e29272f7e Fix jwt-token 2025-09-23 12:25:15 +02:00
b6509b1d67 Fix jwt-token 2025-09-23 12:21:00 +02:00
b3cf353e1f Fix jwt-token 2025-09-23 10:27:11 +02:00
cfbe4cc389 Fix jwt-token 2025-09-23 10:23:44 +02:00
1fe5981095 Fix jwt-token 2025-09-23 10:23:07 +02:00
1e1a1ad488 Fix jwt-token 2025-09-23 10:22:21 +02:00
0d9cbd5e20 Fix jwt-token 2025-09-23 09:59:28 +02:00
c9ed5a8879 Fix jwt-token 2025-09-23 09:52:40 +02:00
9d31bbcc58 Fix jwt-token 2025-09-23 09:41:58 +02:00
1905306a9b Fix jwt-token 2025-09-23 09:35:30 +02:00
3412aadb9c Fix jwt-token 2025-09-23 09:26:18 +02:00
f3b2c0c7ba Fix jwt-token 2025-09-23 09:09:00 +02:00
8db6f7b6b8 Fix jwt-token 2025-09-23 09:07:22 +02:00
21f7cdbd95 Fix jwt-token 2025-09-23 06:43:39 +02:00
af81402284 Fix jwt-token 2025-09-23 06:36:18 +02:00
f779b799c0 Fix jwt-token 2025-09-23 06:20:37 +02:00
1c1ab48b1e Fix jwt-token 2025-09-23 06:14:38 +02:00
5130c145eb Fix jwt-token 2025-09-23 06:04:40 +02:00
00d979194e Fix jwt-token 2025-09-23 05:11:05 +02:00
24673198c9 Fix jwt-token 2025-09-22 14:07:52 +02:00
b457f4cecb Fix jwt-token 2025-09-22 11:04:20 +02:00
0cb595c31f Fix jwt-token 2025-09-22 10:57:15 +02:00
92d12544a9 Fix jwt-token 2025-09-22 10:50:20 +02:00
19599047c9 Fix jwt-token 2025-09-22 10:42:25 +02:00
53873812a6 Fix jwt-token 2025-09-22 10:38:09 +02:00
73503d8e61 Fix jwt-token 2025-09-22 10:31:57 +02:00
64f22e615b Fix jwt-token 2025-09-22 10:22:06 +02:00
4107c2463d Fix jwt-token 2025-09-22 10:15:56 +02:00
7803122a8e Fix jwt-token 2025-09-22 10:11:22 +02:00
c175173772 Fix jwt-token 2025-09-22 10:04:18 +02:00
90f2c8fefb Fix jwt-token 2025-09-22 09:59:22 +02:00
8b6aab6cf4 Fix jwt-token 2025-09-22 09:48:52 +02:00
b03ecdf246 Fix jwt-token 2025-09-22 09:09:23 +02:00
bff6a18a9b Fix jwt-token 2025-09-22 08:43:21 +02:00
8c8556314f Fix jwt-token 2025-09-22 08:22:17 +02:00
3a94adcdbd Fix jwt-token 2025-09-22 08:13:29 +02:00
e8c5306e10 Fix jwt-token 2025-09-22 08:02:07 +02:00
a258543224 Fix jwt-token 2025-09-22 07:48:41 +02:00
c888c0ac18 Fix jwt-token 2025-09-22 07:37:59 +02:00
b8d789adfc Fix jwt-token 2025-09-22 07:25:39 +02:00
223ae8980c Fix jwt-token 2025-09-22 07:20:09 +02:00
84ee9b3bc0 Fix jwt-token 2025-09-22 06:49:28 +02:00
3f2b919f8b Fix jwt-token 2025-09-22 06:41:16 +02:00
2605408ec7 Fix jwt-token 2025-09-22 06:27:13 +02:00
bbf5afcf24 Fix jwt-token 2025-09-22 06:18:17 +02:00
0c17f431b6 Fix jwt-token 2025-09-22 06:12:08 +02:00
6914b79a5d Fix jwt-token 2025-09-22 06:11:00 +02:00
69081233e0 Fix jwt-token 2025-09-22 06:02:41 +02:00
1ed5edad0c Fix jwt-token 2025-09-22 05:56:12 +02:00
191e1ed549 Fix jwt-token 2025-09-22 05:47:12 +02:00
af3208b965 Fix jwt-token 2025-09-22 05:39:31 +02:00
84ed2f52db Fix jwt-token 2025-09-22 05:32:39 +02:00
5f2e890202 Fix jwt-token 2025-09-22 05:27:42 +02:00
d0017ed5f2 Fix jwt-token 2025-09-22 05:08:13 +02:00
a726046d82 Fix jwt-token 2025-09-22 04:51:20 +02:00
1b66ea3c46 Fix jwt-token 2025-09-21 09:12:42 +02:00
e52d6a8384 Fix jwt-token 2025-09-21 09:03:09 +02:00
a1cf28f76a Fix jwt-token 2025-09-21 08:55:01 +02:00
18f08dea05 Fix jwt-token 2025-09-21 08:45:18 +02:00
6020fc5435 Fix jwt-token 2025-09-21 08:16:09 +02:00
24bcdf858f Fix jwt-token 2025-09-20 23:55:56 +02:00
4579bae52e Fix jwt-token 2025-09-20 23:45:33 +02:00
9641651bcc Fix jwt-token 2025-09-20 23:34:24 +02:00
a93b7c07de Fix jwt-token 2025-09-20 23:20:26 +02:00
78fbf3a7cd Fix jwt-token 2025-09-20 23:12:53 +02:00
b74772f71d Fix jwt-token 2025-09-20 23:11:57 +02:00
4df5a628de Fix jwt-token 2025-09-20 23:09:39 +02:00
60748b354f Fix jwt-token 2025-09-20 23:05:39 +02:00
571cd59caa Fix jwt-token 2025-09-20 22:55:43 +02:00
e24acf6577 Fix jwt-token 2025-09-20 22:52:39 +02:00
66831cd2f0 Fix jwt-token 2025-09-20 22:46:48 +02:00
216b2b152f Fix jwt-token 2025-09-20 21:27:50 +02:00
d996be0094 Fix jwt-token 2025-09-20 21:06:16 +02:00
0674e3acaf Fix jwt-token 2025-09-20 20:59:53 +02:00
8ed1c141eb Fix jwt-token 2025-09-20 20:41:30 +02:00
11b460dc07 Fix jwt-token 2025-09-20 07:37:14 +02:00
e83a164be7 Fix jwt-token 2025-09-20 07:32:22 +02:00
1031f77ff6 Fix jwt-token 2025-09-20 06:46:48 +02:00
ec41acca48 Fix jwt-token 2025-09-20 06:40:44 +02:00
8ae7b75365 Fix jwt-token 2025-09-20 06:35:03 +02:00
aa886599c6 Fix jwt-token 2025-09-20 06:28:24 +02:00
04d15485bf Fix jwt-token 2025-09-20 06:25:08 +02:00
261a5032a1 Fix jwt-token 2025-09-20 06:19:43 +02:00
903aae4aae Fix jwt-token 2025-09-20 06:09:42 +02:00
080acbc32e Fix jwt-token 2025-09-20 05:56:38 +02:00
c05aa03434 Fix jwt-token 2025-09-20 05:51:00 +02:00
72e5b50eeb Fix jwt-token 2025-09-19 15:13:54 +02:00
2b4a7bf09e Fix jwt-token 2025-09-19 15:11:42 +02:00
5d21bb5b0a Fix jwt-token 2025-09-19 15:02:01 +02:00
1c4e06515e Fix jwt-token 2025-09-19 14:50:45 +02:00
e578dc7055 Fix jwt-token 2025-09-19 14:40:58 +02:00
9145a9f4d9 Fix jwt-token 2025-09-19 14:39:00 +02:00
348b489b0e Fix jwt-token 2025-09-19 14:35:02 +02:00
395a5b8842 Fix jwt-token 2025-09-19 14:26:27 +02:00
52cc013016 Fix jwt-token 2025-09-19 14:22:28 +02:00
0c82db822c Fix jwt-token 2025-09-19 14:20:37 +02:00
32245f1132 Fix jwt-token 2025-09-19 14:13:24 +02:00
e820f68b05 Fix jwt-token 2025-09-19 13:57:59 +02:00
9afdbd6357 Fix jwt-token 2025-09-19 13:47:50 +02:00
78c5267f63 Fix jwt-token 2025-09-19 13:43:55 +02:00
9a2736d9de Fix jwt-token 2025-09-19 13:41:07 +02:00
b2bc3f4567 Fix jwt-token 2025-09-19 13:33:51 +02:00
6863e3bc65 Fix jwt-token 2025-09-19 13:20:04 +02:00
09b472864f Fix jwt-token 2025-09-19 13:14:12 +02:00
a1074227f1 Fix jwt-token 2025-09-19 13:00:48 +02:00
13593d32b4 Fix jwt-token 2025-09-19 12:58:48 +02:00
0fa4771a0a Fix jwt-token 2025-09-19 12:49:43 +02:00
155ff8984a Fix jwt-token 2025-09-19 08:27:01 +02:00
3f1b50871a Fix jwt-token 2025-09-19 08:14:41 +02:00
f98fd04191 Fix jwt-token 2025-09-19 07:33:23 +02:00
a575e39970 Fix jwt-token 2025-09-19 07:24:42 +02:00
547b29af78 Fix jwt-token 2025-09-19 07:18:52 +02:00
7626df36b6 Fix jwt-token 2025-09-18 06:43:30 +02:00
f7db9b7a37 Fix jwt-token 2025-09-18 06:39:02 +02:00
8d446b76c8 Fix jwt-token 2025-09-18 06:32:07 +02:00
e76302530b Fix jwt-token 2025-09-18 06:10:01 +02:00
93723d4b96 Fix jwt-token 2025-09-18 06:09:01 +02:00
66ee40be00 Fix jwt-token 2025-09-18 06:05:23 +02:00
c618706a72 Fix jwt-token 2025-09-18 06:04:08 +02:00
993b482d56 Fix jwt-token 2025-09-18 05:58:45 +02:00
3c9dcb4c58 Fix jwt-token 2025-09-18 05:56:52 +02:00
07551bc1cf Fix jwt-token 2025-09-18 05:43:29 +02:00
db8042e303 Fix jwt-token 2025-09-18 05:39:39 +02:00
6d66d8d772 Fix jwt-token 2025-09-17 22:43:44 +02:00
43b548c05a Fix jwt-token 2025-09-17 22:32:55 +02:00
6c74c7c524 Fix jwt-token 2025-09-17 22:02:44 +02:00
a691f7d4a6 Fix jwt-token 2025-09-17 21:57:00 +02:00
0aacbd9a16 Fix jwt-token 2025-09-17 21:49:48 +02:00
8c2943ac84 Fix jwt-token 2025-09-17 21:41:40 +02:00
726f931b74 Fix jwt-token 2025-09-17 20:07:13 +02:00
5d61bb50ed Fix jwt-token 2025-09-17 20:05:46 +02:00
571634642b Fix jwt-token 2025-09-17 20:02:28 +02:00
86932f5c8e Fix jwt-token 2025-09-17 20:00:11 +02:00
2881f171ff Fix jwt-token 2025-09-17 19:52:35 +02:00
5197b332d8 Fix jwt-token 2025-09-17 19:51:10 +02:00
ffb7020de1 Fix jwt-token 2025-09-17 19:45:47 +02:00
eda13c3505 Fix jwt-token 2025-09-17 19:40:37 +02:00
acb48b9ff3 Fix jwt-token 2025-09-17 19:30:38 +02:00
54339096a4 Fix jwt-token 2025-09-17 19:21:09 +02:00
c7dd0e2bc4 Fix jwt-token 2025-09-17 19:05:49 +02:00
c0928ec405 Fix jwt-token 2025-09-17 18:58:55 +02:00
84e39c96e8 Fix jwt-token 2025-09-17 18:55:54 +02:00
5c62002ab4 Fix jwt-token 2025-09-17 18:49:59 +02:00
f6ad158c5f Fix jwt-token 2025-09-17 18:48:09 +02:00
490bd86d0c Fix jwt-token 2025-09-17 18:47:16 +02:00
4a62aa8960 Fix jwt-token 2025-09-17 18:40:40 +02:00
b96a06f48c Fix jwt-token 2025-09-17 18:30:35 +02:00
98444a26cc Fix jwt-token 2025-09-17 06:59:17 +02:00
a0e0343989 Fix jwt-token 2025-09-17 06:41:05 +02:00
faa2a15b5a Fix jwt-token 2025-09-17 06:36:36 +02:00
ab368cf64a Fix jwt-token 2025-09-17 06:36:19 +02:00
c8874a30ed Fix jwt-token 2025-09-17 06:35:58 +02:00
7cc24c0a5d Fix jwt-token 2025-09-17 06:33:03 +02:00
5e230b996b Fix jwt-token 2025-09-17 06:29:38 +02:00
af951a631f Fix jwt-token 2025-09-17 06:15:44 +02:00
b0d0cbdcce Fix jwt-token 2025-09-17 06:10:57 +02:00
b8cdce2bcb Fix jwt-token 2025-09-17 06:10:25 +02:00
b58bf1e4f6 Fix jwt-token 2025-09-17 06:06:02 +02:00
286c23b350 Fix jwt-token 2025-09-17 06:02:55 +02:00
83fc1098f6 Fix jwt-token 2025-09-17 05:58:35 +02:00
9ce9a18f96 Fix jwt-token 2025-09-17 05:57:11 +02:00
e8ba4b8bd4 Fix jwt-token 2025-09-17 05:50:32 +02:00
de552a9322 Fix jwt-token 2025-09-17 05:39:27 +02:00
3cbef01ef9 Fix jwt-token 2025-09-17 05:37:48 +02:00
fc3808f848 Fix jwt-token 2025-09-17 05:37:08 +02:00
f29b2d98bc Fix jwt-token 2025-09-17 05:35:12 +02:00
f598067a11 Fix jwt-token 2025-09-17 05:32:40 +02:00
deb80e0441 Fix jwt-token 2025-09-17 05:29:40 +02:00
7404ac747e Fix jwt-token 2025-09-17 05:28:43 +02:00
25a901ff6c Fix jwt-token 2025-09-17 05:24:38 +02:00
042e39c08f Fix jwt-token 2025-09-17 05:22:48 +02:00
19d63ffe1b Fix jwt-token 2025-09-17 05:21:53 +02:00
e82213942c Fix jwt-token 2025-09-17 05:21:35 +02:00
8148ce9fc0 Fix jwt-token 2025-09-17 05:19:28 +02:00
5e6d91fa5e Fix jwt-token 2025-09-17 05:15:55 +02:00
5a129bc94c Fix jwt-token 2025-09-17 05:12:06 +02:00
a98dd1661e Fix jwt-token 2025-09-16 22:31:29 +02:00
f47d5a4e83 Fix jwt-token 2025-09-16 22:30:41 +02:00
30f95431fa Fix jwt-token 2025-09-16 22:28:19 +02:00
79c7eb1d6a Fix jwt-token 2025-09-16 22:26:23 +02:00
394252af10 Fix jwt-token 2025-09-16 22:20:34 +02:00
c2c18821dd Fix jwt-token 2025-09-16 22:19:26 +02:00
d9997c456d Fix jwt-token 2025-09-16 22:11:04 +02:00
7cbd991bdc Fix jwt-token 2025-09-16 22:09:39 +02:00
cbc059abc5 Fix jwt-token 2025-09-16 22:08:56 +02:00
e00c32a207 Fix jwt-token 2025-09-16 22:07:43 +02:00
27fa3e546d Fix jwt-token 2025-09-16 22:06:36 +02:00
44179ad789 Fix jwt-token 2025-09-16 22:05:51 +02:00
4636246be9 Fix jwt-token 2025-09-16 22:04:48 +02:00
8ff2848695 Fix jwt-token 2025-09-16 22:03:27 +02:00
00819f10a6 Fix jwt-token 2025-09-16 22:02:23 +02:00
9857249668 Fix jwt-token 2025-09-16 21:59:32 +02:00
c7484ead5f Fix jwt-token 2025-09-16 21:57:55 +02:00
749c82eca6 Fix jwt-token 2025-09-16 21:57:17 +02:00
438d93fd87 Fix jwt-token 2025-09-16 21:56:01 +02:00
432e4926ee Fix jwt-token 2025-09-16 21:55:01 +02:00
6ed5c99722 Fix jwt-token 2025-09-16 21:53:46 +02:00
372ce6e65b Fix jwt-token 2025-09-16 21:53:09 +02:00
70c8a41508 Fix jwt-token 2025-09-16 21:49:10 +02:00
6199f84ae5 Fix jwt-token 2025-09-16 21:46:48 +02:00
ec9e40f028 Fix jwt-token 2025-09-16 21:45:06 +02:00
0a6ab8772b Fix jwt-token 2025-09-16 21:44:14 +02:00
a822664ffc Fix jwt-token 2025-09-16 21:43:10 +02:00
72d869628d Fix jwt-token 2025-09-16 21:41:52 +02:00
6692af86dd Fix jwt-token 2025-09-16 21:41:03 +02:00
eca16021ac Fix jwt-token 2025-09-16 21:39:05 +02:00
33ed83f419 Fix jwt-token 2025-09-16 21:37:14 +02:00
8241f0fec0 Fix jwt-token 2025-09-16 21:36:18 +02:00
146a09a0b9 Fix jwt-token 2025-09-16 21:32:32 +02:00
2dfb02a2ad Fix jwt-token 2025-09-16 21:23:56 +02:00
366cd709b3 Fix jwt-token 2025-09-16 21:22:15 +02:00
216dd754a9 Fix jwt-token 2025-09-16 21:21:16 +02:00
69cd3e1005 Fix jwt-token 2025-09-16 21:03:00 +02:00
20a191633b Fix jwt-token 2025-09-16 20:52:43 +02:00
2d8a8ba918 Fix jwt-token 2025-09-16 20:50:53 +02:00
c1112968ff Fix jwt-token 2025-09-16 08:22:13 +02:00
6ba4b4387a Fix jwt-token 2025-09-16 08:20:14 +02:00
4e6e1e79da Fix jwt-token 2025-09-16 08:19:42 +02:00
b0316493db Fix jwt-token 2025-09-16 08:18:02 +02:00
d8ec4e7293 Fix jwt-token 2025-09-16 08:17:44 +02:00
ccb58c3542 Fix jwt-token 2025-09-16 08:17:24 +02:00
a56ce40821 Fix jwt-token 2025-09-16 08:15:56 +02:00
bdb0fb079b Fix jwt-token 2025-09-16 08:14:52 +02:00
cb9a9e1098 Fix jwt-token 2025-09-16 08:14:04 +02:00
089f6d107c Fix jwt-token 2025-09-16 08:13:31 +02:00
9589e5be33 Fix jwt-token 2025-09-16 08:11:51 +02:00
78fa165665 Fix jwt-token 2025-09-16 08:09:10 +02:00
551a043ab3 Fix jwt-token 2025-09-16 08:08:12 +02:00
f8fcfbb5be Fix jwt-token 2025-09-16 08:06:50 +02:00
b3ada7ccfe Fix jwt-token 2025-09-16 07:56:56 +02:00
eb446809bb Fix jwt-token 2025-09-16 07:40:49 +02:00
2982e3752a Fix jwt-token 2025-09-16 07:39:59 +02:00
897529f474 Fix all remaining detector route tests
- Fix drone type 0 handling: check STORE_DRONE_TYPE0 dynamically for test compatibility
- Fix heartbeat response: return 200 instead of 201, update message to 'Heartbeat received'
- Fix validation test: expect 'message' and 'errors' properties instead of 'error'
- Add device creation to heartbeat tests to avoid 404 errors
- Update API paths from /detectors to /api/detectors to match production routing
2025-09-16 07:36:18 +02:00
4ae480a142 Fix jwt-token 2025-09-16 07:33:43 +02:00
dae5d6fc7c Fix jwt-token 2025-09-16 07:33:10 +02:00
d65862b839 Fix jwt-token 2025-09-16 07:28:40 +02:00
2f87827d9d Fix jwt-token 2025-09-16 07:27:28 +02:00
da0d8659e5 Fix jwt-token 2025-09-16 07:23:20 +02:00
c9a38acfbb Fix jwt-token 2025-09-16 07:17:31 +02:00
104243810d Fix jwt-token 2025-09-16 07:16:21 +02:00
24cf5f5785 Fix jwt-token 2025-09-16 07:15:42 +02:00
c5046e76a0 Fix jwt-token 2025-09-16 07:14:33 +02:00
628bb94737 Fix jwt-token 2025-09-16 07:13:16 +02:00
fbd03aeffc Fix jwt-token 2025-09-16 07:11:32 +02:00
50f60035b5 Fix jwt-token 2025-09-16 06:46:40 +02:00
34b898558e Fix jwt-token 2025-09-16 06:42:52 +02:00
2f275029ec Fix jwt-token 2025-09-16 06:40:52 +02:00
7d53623647 Fix jwt-token 2025-09-16 06:34:27 +02:00
819e0b8414 Fix jwt-token 2025-09-16 06:32:03 +02:00
644ae8c0a8 Fix jwt-token 2025-09-16 06:26:27 +02:00
d14ca128dc Fix jwt-token 2025-09-16 06:23:15 +02:00
63635a9adf Fix jwt-token 2025-09-16 06:17:18 +02:00
32339de9eb Fix jwt-token 2025-09-15 21:48:38 +02:00
eb0303dbd7 Fix jwt-token 2025-09-15 21:36:52 +02:00
d641df8aa3 Fix jwt-token 2025-09-15 21:29:45 +02:00
aa930270d4 Fix jwt-token 2025-09-15 21:26:15 +02:00
2afbc76817 Fix jwt-token 2025-09-15 20:58:47 +02:00
77178d2aaa Fix jwt-token 2025-09-15 20:53:26 +02:00
3f1c59727b Fix jwt-token 2025-09-15 20:50:30 +02:00
2d18212f62 Fix jwt-token 2025-09-15 20:49:46 +02:00
3345510ccc Fix jwt-token 2025-09-15 20:42:30 +02:00
37b3fc82a9 Fix jwt-token 2025-09-15 20:37:43 +02:00
d341e12449 Fix jwt-token 2025-09-15 20:36:46 +02:00
f0b3b8088c Fix jwt-token 2025-09-15 20:35:51 +02:00
8301080755 Fix jwt-token 2025-09-15 20:35:27 +02:00
7404c91a55 Fix jwt-token 2025-09-15 20:34:12 +02:00
3f23f88e40 Fix jwt-token 2025-09-15 20:17:40 +02:00
6b64aac39d Fix jwt-token 2025-09-15 15:44:44 +02:00
652dcecc13 Fix jwt-token 2025-09-15 15:39:01 +02:00
caf7e878f7 Fix jwt-token 2025-09-15 15:37:33 +02:00
bfb4b05aed Fix jwt-token 2025-09-15 15:35:18 +02:00
9e9a34dede Fix jwt-token 2025-09-15 15:33:04 +02:00
548bebb3ab Fix jwt-token 2025-09-15 15:31:08 +02:00
8f18fe4b24 Fix jwt-token 2025-09-15 15:25:41 +02:00
a416a5c391 Fix jwt-token 2025-09-15 15:25:07 +02:00
9648ae7e55 Fix jwt-token 2025-09-15 15:24:07 +02:00
072086c4d2 Fix jwt-token 2025-09-15 15:23:32 +02:00
d9d6f9502f Fix jwt-token 2025-09-15 15:23:21 +02:00
d4dba54e71 Fix jwt-token 2025-09-15 15:21:47 +02:00
a7a7e8a58b Fix jwt-token 2025-09-15 15:21:39 +02:00
995ef9d7c7 Fix jwt-token 2025-09-15 15:20:58 +02:00
6ffe1156ca Fix jwt-token 2025-09-15 15:20:39 +02:00
dbb4ea62e6 Fix jwt-token 2025-09-15 15:18:47 +02:00
6f5c7822c1 Fix jwt-token 2025-09-15 15:17:31 +02:00
a73c8c058f Fix jwt-token 2025-09-15 15:15:47 +02:00
7211bfd78e Fix jwt-token 2025-09-15 15:14:42 +02:00
fed2adf7e2 Fix jwt-token 2025-09-15 15:13:47 +02:00
9b5a45fa63 Fix jwt-token 2025-09-15 15:11:13 +02:00
039edb5928 Fix jwt-token 2025-09-15 15:08:49 +02:00
baa88a1226 Fix jwt-token 2025-09-15 14:51:34 +02:00
ecaff2b6ff Fix jwt-token 2025-09-15 14:49:37 +02:00
3b832752d5 Fix jwt-token 2025-09-15 14:43:41 +02:00
07c25ed5e9 Fix jwt-token 2025-09-15 14:41:35 +02:00
aa5273841f Fix jwt-token 2025-09-15 14:29:40 +02:00
c8428d5415 Fix jwt-token 2025-09-15 14:24:17 +02:00
6034aac1a7 Fix jwt-token 2025-09-15 14:21:51 +02:00
5980b45885 Fix jwt-token 2025-09-15 14:20:22 +02:00
5c55ae1054 Fix jwt-token 2025-09-15 14:18:35 +02:00
2baf21981a Fix jwt-token 2025-09-15 14:16:10 +02:00
f78ce67e10 Fix jwt-token 2025-09-15 14:07:01 +02:00
d42b0ee028 Fix jwt-token 2025-09-15 14:02:43 +02:00
44f2607773 Fix jwt-token 2025-09-15 13:50:59 +02:00
4f0ce39c69 Fix jwt-token 2025-09-15 13:27:02 +02:00
4f1ccba418 Fix jwt-token 2025-09-15 12:58:12 +02:00
e609cc4541 Fix jwt-token 2025-09-15 12:30:00 +02:00
64a5229025 Fix jwt-token 2025-09-15 12:22:01 +02:00
5b0c0bdd51 Fix jwt-token 2025-09-15 12:12:06 +02:00
caac0e59cd Fix jwt-token 2025-09-15 10:21:49 +02:00
c099715540 Fix jwt-token 2025-09-15 10:12:43 +02:00
94e365c1bb Fix jwt-token 2025-09-15 10:08:08 +02:00
43dad665ae Fix jwt-token 2025-09-15 09:59:05 +02:00
153 changed files with 18213 additions and 2603 deletions

4
.gitignore vendored
View File

@@ -19,6 +19,10 @@ docker-compose.override.yml
logs/
*.log
# Uploads
uploads/
!uploads/logos/.gitkeep
# Debug files
debug_logs/
api_debug.log

173
DOCKER_SECURITY.md Normal file
View File

@@ -0,0 +1,173 @@
# Docker Security Configuration
## Overview
The drone detection system uses a multi-layered security approach with different configurations for development and production environments.
## Security Layers
### 🔒 **Internal-Only Services (No External Access)**
#### 1. PostgreSQL Database
- **Risk**: Direct database access from internet
- **Security**: Only accessible via Docker internal network
- **Development**: Port 5433 exposed via override file
- **Production**: No external ports
#### 2. Redis Cache/Sessions
- **Risk**: Session data and cache accessible from internet
- **Security**: Only accessible via Docker internal network
- **Development**: Port 6380 exposed via override file
- **Production**: No external ports, password protected
#### 3. Data Retention Service
- **Risk**: System metrics and cleanup data exposure
- **Security**: Only accessible via management portal with authentication
- **Development**: Port 3004 can be exposed for testing
- **Production**: No external ports
#### 4. Backend API (Production)
- **Risk**: Direct API access bypassing reverse proxy
- **Security**: Only accessible via nginx reverse proxy in production
- **Development**: Port 3002 exposed for direct access
- **Production**: No external ports
### 🌐 **Public-Facing Services (External Access)**
#### 1. Frontend Application
- **Port**: 3001 (development) / 80 via nginx (production)
- **Purpose**: User interface for tenant users
- **Security**: Static files only, no sensitive data
#### 2. Management Portal
- **Port**: 3003 (development) / 80 via nginx (production)
- **Purpose**: Administrative interface
- **Security**: Authentication required, role-based access
#### 3. Nginx Reverse Proxy (Production)
- **Ports**: 8080 (HTTP), 8443 (HTTPS)
- **Purpose**: Single entry point for all services
- **Security**: SSL termination, request filtering
## Configuration Files
### Base Configuration: `docker-compose.yml`
- **Purpose**: Secure baseline configuration
- **Security**: All internal services locked down
- **Database**: No external ports
- **Redis**: No external ports
- **Data Retention**: No external ports
### Development Override: `docker-compose.override.yml`
- **Purpose**: Development convenience
- **Security**: Exposes internal services for debugging
- **Usage**: `docker-compose up` (automatically uses override)
- **Warning**: ⚠️ Never deploy to production with override file
### Production Configuration: `docker-compose.prod.yml`
- **Purpose**: Maximum security for production
- **Security**: All services internal-only except nginx
- **Usage**: `docker-compose -f docker-compose.yml -f docker-compose.prod.yml up`
- **Features**: Password protection, SSL, enhanced logging
## Deployment Commands
### Development (Less Secure, More Convenient)
```bash
# Uses docker-compose.yml + docker-compose.override.yml
docker-compose up -d
# Direct database access available on localhost:5433
# Direct Redis access available on localhost:6380
# Direct backend access available on localhost:3002
```
### Production (Maximum Security)
```bash
# Uses docker-compose.yml + docker-compose.prod.yml
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# No direct database access
# No direct Redis access
# No direct backend access
# All access via nginx reverse proxy only
```
### Staging/Testing (Secure but with Monitoring)
```bash
# Uses base configuration only
docker-compose -f docker-compose.yml up -d
# Secure but allows manual inspection if needed
```
## Security Checklist
### ✅ **Applied Security Measures**
- **Database Isolation**: PostgreSQL not externally accessible
- **Cache Security**: Redis internal-only with authentication
- **API Protection**: Backend only accessible via reverse proxy in production
- **Metrics Security**: Data retention metrics require management authentication
- **Network Segmentation**: All services on isolated Docker network
- **Access Control**: Role-based permissions for sensitive endpoints
- **Audit Logging**: All data retention access logged
- **Security Headers**: Applied to all management endpoints
### 🔍 **Additional Security Recommendations**
#### Network Security
- **Firewall**: Configure host firewall to only allow necessary ports
- **VPN**: Consider VPN access for management interfaces
- **IP Allowlisting**: Restrict management portal access by IP
#### Database Security
- **Encryption**: Enable TLS for database connections
- **Backup Encryption**: Encrypt database backups
- **User Permissions**: Use least-privilege database users
#### Application Security
- **JWT Secrets**: Use strong, unique JWT secrets
- **Session Security**: Configure secure session settings
- **Rate Limiting**: Enable rate limiting on all endpoints
#### Container Security
- **Image Scanning**: Scan container images for vulnerabilities
- **User Permissions**: Run containers as non-root users
- **Resource Limits**: Set memory and CPU limits
## Emergency Access
### Development Database Access
```bash
# Connect to development database (when override is active)
psql -h localhost -p 5433 -U postgres -d drone_detection
```
### Production Database Access (Emergency Only)
```bash
# Temporarily expose database for emergency access
docker-compose -f docker-compose.yml -f docker-compose.override.yml up -d postgres
# Connect and then immediately remove override
psql -h localhost -p 5433 -U postgres -d drone_detection
# Restore production security
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
## Monitoring & Alerting
### Security Events to Monitor
- **Unauthorized Access**: Failed authentication attempts on management portal
- **Data Retention Access**: All access to system metrics endpoints
- **Database Connections**: Unusual database connection patterns
- **Network Traffic**: Unexpected traffic to internal services
### Log Locations
- **Security Logs**: `/app/logs/data_retention_access.log`
- **Application Logs**: Container logs via `docker-compose logs`
- **Database Logs**: PostgreSQL container logs
- **Nginx Logs**: Reverse proxy access logs
This security configuration ensures that sensitive infrastructure components are isolated while maintaining operational flexibility for different environments.

View File

@@ -265,10 +265,7 @@ Content-Type: application/json
{
"type": "heartbeat",
"key": "device_1941875381_key",
"battery_level": 85,
"signal_strength": -50,
"temperature": 22.5
"key": "device_1941875381_key"
}
```

View File

@@ -127,10 +127,7 @@ curl -X POST http://localhost:3001/api/heartbeat \
-H "Content-Type: application/json" \
-d '{
"type": "heartbeat",
"key": "device_1941875381_key",
"battery_level": 85,
"signal_strength": -50,
"temperature": 22.5
"key": "device_1941875381_key"
}'
```

View File

@@ -0,0 +1,277 @@
# Tenant Limits Implementation
## Overview
This document explains how tenant subscription limits are enforced in the UAM-ILS Drone Detection System. All the issues you identified have been resolved:
## ✅ Issues Fixed
### 1. **User Creation Limits**
- **Problem**: Tenants could create unlimited users regardless of their subscription limits
- **Solution**: Added `enforceUserLimit()` middleware to `POST /tenant/users`
- **Implementation**: Counts existing users and validates against `tenant.features.max_users`
### 2. **Device Creation Limits**
- **Problem**: Tenants could add unlimited devices regardless of their subscription limits
- **Solution**: Added `enforceDeviceLimit()` middleware to `POST /devices`
- **Implementation**: Counts existing devices and validates against `tenant.features.max_devices`
### 3. **API Rate Limiting**
- **Problem**: No proper API rate limiting per tenant shared among users
- **Solution**: Implemented `enforceApiRateLimit()` middleware
- **Implementation**:
- Tracks actual API requests (not page views)
- Rate limit is shared among ALL users in a tenant
- Uses sliding window algorithm
- Applied to all authenticated API endpoints
### 4. **Data Retention**
- **Problem**: Old data was never cleaned up automatically
- **Solution**: Created `DataRetentionService` with cron job
- **Implementation**:
- Runs daily at 2:00 AM UTC
- Deletes detections, heartbeats, and logs older than `tenant.features.data_retention_days`
- Respects unlimited retention (`-1` value)
- Provides preview endpoint to see what would be deleted
## 🔧 Technical Implementation
### Middleware: `server/middleware/tenant-limits.js`
```javascript
// User limit enforcement
enforceUserLimit() - Prevents user creation when limit reached
enforceDeviceLimit() - Prevents device creation when limit reached
enforceApiRateLimit() - Rate limits API requests per tenant
getTenantLimitsStatus() - Returns current usage vs limits
```
### Service: `server/services/dataRetention.js`
```javascript
DataRetentionService {
start() - Starts daily cron job
performCleanup() - Cleans all tenants based on retention policies
previewCleanup(tenantId) - Shows what would be deleted
getStats() - Returns cleanup statistics
}
```
### API Endpoints
```bash
GET /api/tenant/limits
# Returns current usage and limits for the tenant
{
"users": { "current": 3, "limit": 5, "unlimited": false },
"devices": { "current": 7, "limit": 10, "unlimited": false },
"api_requests": { "current_minute": 45, "limit_per_minute": 1000 },
"data_retention": { "days": 90, "unlimited": false }
}
GET /api/tenant/data-retention/preview
# Shows what data would be deleted by retention cleanup
{
"tenantSlug": "tenant1",
"retentionDays": 90,
"cutoffDate": "2024-06-24T02:00:00.000Z",
"toDelete": {
"detections": 1250,
"heartbeats": 4500,
"logs": 89
}
}
```
## 🚦 How Rate Limiting Works
### API Rate Limiting Details
- **Granularity**: Per tenant (shared among all users)
- **Window**: 1 minute sliding window
- **Storage**: In-memory with automatic cleanup
- **Headers**: Standard rate limit headers included
- **Tracking**: Only actual API requests count (not static files/page views)
### Example Rate Limit Response
```json
{
"success": false,
"message": "API rate limit exceeded. Maximum 1000 requests per 60 seconds for your tenant.",
"error_code": "TENANT_API_RATE_LIMIT_EXCEEDED",
"max_requests": 1000,
"window_seconds": 60,
"retry_after_seconds": 15
}
```
## 📊 Subscription Tiers
The system supports different subscription tiers with these default limits:
```javascript
// Free tier
{
max_devices: 2,
max_users: 1,
api_rate_limit: 100,
data_retention_days: 7
}
// Pro tier
{
max_devices: 10,
max_users: 5,
api_rate_limit: 1000,
data_retention_days: 90
}
// Business tier
{
max_devices: 50,
max_users: 20,
api_rate_limit: 5000,
data_retention_days: 365
}
// Enterprise tier
{
max_devices: -1, // Unlimited
max_users: -1, // Unlimited
api_rate_limit: -1, // Unlimited
data_retention_days: -1 // Unlimited
}
```
## 🔒 Security Features
### Limit Enforcement Security
- All limit checks are done server-side (cannot be bypassed)
- Security events are logged when limits are exceeded
- Failed attempts include IP address, user agent, and user details
- Graceful error messages prevent information disclosure
### Error Response Format
```json
{
"success": false,
"message": "Tenant has reached the maximum number of users (5). Please upgrade your subscription or remove existing users.",
"error_code": "TENANT_USER_LIMIT_EXCEEDED",
"current_count": 5,
"max_allowed": 5
}
```
## 🕒 Data Retention Schedule
### Cleanup Process
1. **Trigger**: Daily at 2:00 AM UTC via cron job
2. **Process**: For each active tenant:
- Check `tenant.features.data_retention_days`
- Skip if unlimited (`-1`)
- Calculate cutoff date
- Delete old detections, heartbeats, logs
- Log security events for significant cleanups
3. **Performance**: Batched operations with error handling per tenant
### Manual Operations
```javascript
// Preview what would be deleted
const service = new DataRetentionService();
const preview = await service.previewCleanup(tenantId);
// Manually trigger cleanup (admin only)
await service.triggerManualCleanup();
// Get cleanup statistics
const stats = service.getStats();
```
## 🔧 Docker Integration
### Package Dependencies
Added to `server/package.json`:
```json
{
"dependencies": {
"node-cron": "^3.0.2"
}
}
```
### Service Initialization
All services start automatically when the Docker container boots:
```javascript
// In server/index.js
const dataRetentionService = new DataRetentionService();
dataRetentionService.start();
console.log('🗂️ Data retention service: ✅ Started');
```
## 🧪 Testing the Implementation
### Test User Limits
```bash
# Create users until limit is reached
curl -X POST /api/tenant/users \
-H "Authorization: Bearer $TOKEN" \
-d '{"username":"test6","email":"test6@example.com","password":"password"}'
# Should return 403 when limit exceeded
```
### Test Device Limits
```bash
# Create devices until limit is reached
curl -X POST /api/devices \
-H "Authorization: Bearer $TOKEN" \
-d '{"id":"device-11","name":"Test Device 11"}'
# Should return 403 when limit exceeded
```
### Test API Rate Limits
```bash
# Send rapid requests to trigger rate limit
for i in {1..1100}; do
curl -X GET /api/detections -H "Authorization: Bearer $TOKEN" &
done
# Should return 429 after limit reached
```
### Test Data Retention
```bash
# Preview what would be deleted
curl -X GET /api/tenant/data-retention/preview \
-H "Authorization: Bearer $TOKEN"
# Check tenant limits status
curl -X GET /api/tenant/limits \
-H "Authorization: Bearer $TOKEN"
```
## 📈 Monitoring & Logging
### Security Logs
All limit violations are logged with full context:
- User ID and username
- Tenant ID and slug
- IP address and user agent
- Specific limit exceeded and current usage
- Timestamp and action details
### Performance Monitoring
- Rate limit middleware tracks response times
- Data retention service logs cleanup duration and counts
- Memory usage monitoring for rate limit store
- Database query performance for limit checks
## 🔄 Upgrade Path
When tenants upgrade their subscription:
1. Update `tenant.features` with new limits
2. Limits take effect immediately
3. No restart required
4. Historical data respects new retention policy on next cleanup
This comprehensive implementation ensures that tenant limits are properly enforced across all aspects of the system, preventing abuse while providing clear feedback to users about their subscription status.

View File

@@ -54,7 +54,14 @@ def authenticate():
try:
print(f"🔐 Authenticating as user: {USERNAME}")
response = requests.post(f"{API_BASE_URL}/users/login", json=login_data, verify=False)
# Add tenant header for localhost requests
headers = {"Content-Type": "application/json"}
if "localhost" in API_BASE_URL:
headers["x-tenant-id"] = "uamils-ab"
print(f"🏢 Using tenant: uamils-ab")
response = requests.post(f"{API_BASE_URL}/users/login", json=login_data, headers=headers, verify=False)
if response.status_code == 200:
data = response.json()
@@ -76,15 +83,19 @@ def authenticate():
def get_auth_headers():
"""Get headers with authentication token"""
headers = {"Content-Type": "application/json"}
# Add tenant header for localhost requests
if "localhost" in API_BASE_URL:
headers["x-tenant-id"] = "uamils-ab"
if SKIP_AUTH:
return {"Content-Type": "application/json"}
return headers
if AUTH_TOKEN:
return {
"Authorization": f"Bearer {AUTH_TOKEN}",
"Content-Type": "application/json"
}
return {"Content-Type": "application/json"}
headers["Authorization"] = f"Bearer {AUTH_TOKEN}"
return headers
def get_next_device_id():
"""Get the next available device ID"""
@@ -113,8 +124,8 @@ def get_next_device_id():
def create_stockholm_device():
"""Create a device positioned over Stockholm Castle"""
# Get next available device ID
device_id = get_next_device_id()
# Use device_id=1 for Stockholm Castle as requested
device_id = "1"
# Device data for Stockholm Castle
device_data = {

View File

@@ -17,7 +17,10 @@
"date-fns": "^2.30.0",
"react-hot-toast": "^2.4.1",
"framer-motion": "^10.16.4",
"classnames": "^2.3.2"
"classnames": "^2.3.2",
"react-i18next": "^13.5.0",
"i18next": "^23.7.8",
"i18next-browser-languagedetector": "^7.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.3",

View File

@@ -12,9 +12,11 @@ import Detections from './pages/Detections';
import Alerts from './pages/Alerts';
import Debug from './pages/Debug';
import Settings from './pages/Settings';
import SecurityLogs from './pages/SecurityLogs';
import Login from './pages/Login';
import Register from './pages/Register';
import ProtectedRoute from './components/ProtectedRoute';
import ErrorBoundary from './components/ErrorBoundary';
function App() {
return (
@@ -72,7 +74,12 @@ function App() {
<Route path="map" element={<MapView />} />
<Route path="devices" element={<Devices />} />
<Route path="detections" element={<Detections />} />
<Route path="alerts" element={<Alerts />} />
<Route path="alerts" element={
<ErrorBoundary>
<Alerts />
</ErrorBoundary>
} />
<Route path="security-logs" element={<SecurityLogs />} />
<Route path="settings" element={<Settings />} />
<Route path="debug" element={<Debug />} />
</Route>

View File

@@ -1,10 +1,13 @@
import React, { useState, useEffect } from 'react';
import api from '../services/api';
import { format } from 'date-fns';
import { formatFrequency } from '../utils/formatFrequency';
import { useTranslation } from '../utils/tempTranslations';
import { XMarkIcon } from '@heroicons/react/24/outline';
// Edit Alert Rule Modal
export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
const { t } = useTranslation();
const [formData, setFormData] = useState({
name: '',
description: '',
@@ -38,6 +41,16 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
useEffect(() => {
if (rule) {
// Normalize alert_channels - ensure it's always an array
let alertChannels = rule.alert_channels || ['sms'];
if (typeof alertChannels === 'object' && !Array.isArray(alertChannels)) {
// Convert object like {sms: true, webhook: false, email: true} to array
alertChannels = Object.keys(alertChannels).filter(key => alertChannels[key]);
}
if (!Array.isArray(alertChannels)) {
alertChannels = ['sms']; // fallback to default
}
setFormData({
name: rule.name || '',
description: rule.description || '',
@@ -45,7 +58,7 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
min_detections: rule.min_detections || 1,
time_window: rule.time_window || 300,
cooldown_period: rule.cooldown_period || 600,
alert_channels: rule.alert_channels || ['sms'],
alert_channels: alertChannels,
min_threat_level: rule.min_threat_level || '',
drone_types: rule.drone_types || [],
device_ids: rule.device_ids || [],
@@ -64,12 +77,17 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
};
const handleChannelChange = (channel, checked) => {
setFormData(prev => ({
...prev,
alert_channels: checked
? [...prev.alert_channels, channel]
: prev.alert_channels.filter(c => c !== channel)
}));
setFormData(prev => {
// Ensure alert_channels is always an array
const currentChannels = Array.isArray(prev.alert_channels) ? prev.alert_channels : [];
return {
...prev,
alert_channels: checked
? [...currentChannels, channel]
: currentChannels.filter(c => c !== channel)
};
});
};
const handleDroneTypeChange = (droneType, checked) => {
@@ -103,7 +121,7 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
onClose();
} catch (error) {
console.error('Error updating alert rule:', error);
alert('Failed to update alert rule');
alert(t('alerts.form.updateFailed'));
} finally {
setSaving(false);
}
@@ -113,7 +131,7 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="flex items-center justify-between pb-3">
<h3 className="text-lg font-medium">Edit Alert Rule</h3>
<h3 className="text-lg font-medium">{t('alerts.form.editAlertRule')}</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
@@ -125,7 +143,7 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name *
{t('alerts.name')} *
</label>
<input
type="text"
@@ -139,7 +157,7 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
{t('alerts.description')}
</label>
<textarea
name="description"
@@ -153,49 +171,55 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Priority
{t('alerts.priority')}
</label>
<div className="text-xs text-gray-500 mb-1">
Determines alert urgency and notification routing
</div>
<select
name="priority"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.priority}
onChange={handleChange}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
<option value="low">{t('alerts.priorities.low')}</option>
<option value="medium">{t('alerts.priorities.medium')}</option>
<option value="high">{t('alerts.priorities.high')}</option>
<option value="critical">{t('alerts.priorities.critical')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Min Threat Level
{t('alerts.minThreatLevel')}
</label>
<div className="text-xs text-gray-500 mb-1">
Only alert on drones at or above this threat level
</div>
<select
name="min_threat_level"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.min_threat_level}
onChange={handleChange}
>
<option value="">Any Level</option>
<option value="monitoring">Monitoring</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
<option value="">{t('alerts.form.anyLevel')}</option>
<option value="monitoring">{t('alerts.form.monitoring')}</option>
<option value="low">{t('alerts.threatLevels.low')}</option>
<option value="medium">{t('alerts.threatLevels.medium')}</option>
<option value="high">{t('alerts.threatLevels.high')}</option>
<option value="critical">{t('alerts.threatLevels.critical')}</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Drone Types Filter
{t('alerts.form.droneTypesFilter')}
</label>
<div className="text-xs text-gray-500 mb-2">
{t('alerts.form.leaveEmptyAllTypes')} - Select specific drone types to monitor or leave empty to monitor all detected drones
</div>
<div className="space-y-2">
<div className="text-xs text-gray-500 mb-2">
Leave empty to monitor all drone types
</div>
{droneTypes.map(droneType => (
<label key={droneType.id} className="flex items-center">
<input
@@ -218,6 +242,9 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
<label className="block text-sm font-medium text-gray-700 mb-1">
Min Detections
</label>
<div className="text-xs text-gray-500 mb-1">
Number of detections required within time window to trigger alert
</div>
<input
type="number"
name="min_detections"
@@ -232,6 +259,9 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
<label className="block text-sm font-medium text-gray-700 mb-1">
Time Window (seconds)
</label>
<div className="text-xs text-gray-500 mb-1">
Time period to count detections (e.g., 3 detections in 300 seconds)
</div>
<input
type="number"
name="time_window"
@@ -247,6 +277,9 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
<label className="block text-sm font-medium text-gray-700 mb-1">
Cooldown Period (seconds)
</label>
<div className="text-xs text-gray-500 mb-1">
Minimum time between alerts to prevent spam (0 = no cooldown)
</div>
<input
type="number"
name="cooldown_period"
@@ -261,24 +294,30 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
<label className="block text-sm font-medium text-gray-700 mb-2">
Alert Channels
</label>
<div className="text-xs text-gray-500 mb-2">
Choose how you want to receive alerts when this rule is triggered
</div>
<div className="space-y-2">
{['sms', 'email', 'webhook'].map(channel => (
<label key={channel} className="flex items-center">
<input
type="checkbox"
checked={formData.alert_channels.includes(channel)}
checked={Array.isArray(formData.alert_channels) && formData.alert_channels.includes(channel)}
onChange={(e) => handleChannelChange(channel, e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700 capitalize">
{channel}
{channel === 'sms' && <span className="text-xs text-gray-400 ml-1">(Text message)</span>}
{channel === 'email' && <span className="text-xs text-gray-400 ml-1">(Email notification)</span>}
{channel === 'webhook' && <span className="text-xs text-gray-400 ml-1">(HTTP POST to URL)</span>}
</span>
</label>
))}
</div>
</div>
{formData.alert_channels.includes('sms') && (
{Array.isArray(formData.alert_channels) && formData.alert_channels.includes('sms') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
SMS Phone Number
@@ -294,7 +333,7 @@ export const EditAlertModal = ({ rule, onClose, onSuccess }) => {
</div>
)}
{formData.alert_channels.includes('webhook') && (
{Array.isArray(formData.alert_channels) && formData.alert_channels.includes('webhook') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Webhook URL
@@ -403,7 +442,7 @@ export const DetectionDetailsModal = ({ detection, onClose }) => {
</div>
<div className="flex justify-between">
<span className="text-gray-500">Frequency:</span>
<span>{detection.frequency ? `${detection.frequency} MHz` : 'N/A'}</span>
<span>{formatFrequency(detection.frequency)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Drone Type:</span>

View File

@@ -0,0 +1,49 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('ERROR BOUNDARY CAUGHT:', error);
console.error('ERROR BOUNDARY STACK:', errorInfo);
// Check if this is the specific object rendering error
if (error.message && error.message.includes('Objects are not valid as a React child')) {
console.error('🚨 FOUND THE OBJECT RENDERING ERROR!');
console.error('Error message:', error.message);
console.error('Stack trace:', error.stack);
console.error('Component stack:', errorInfo.componentStack);
}
this.setState({
error: error,
errorInfo: errorInfo
});
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: '20px', border: '2px solid red', margin: '10px' }}>
<h2>Something went wrong.</h2>
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.error && this.state.error.toString()}
<br />
{this.state.errorInfo && this.state.errorInfo.componentStack}
</details>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -1,9 +1,13 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom';
// import { useTranslation } from 'react-i18next'; // Commented out until Docker rebuild
import { useAuth } from '../contexts/AuthContext';
import { useSocket } from '../contexts/SocketContext';
import DebugToggle from './DebugToggle';
import LanguageSelector from './common/LanguageSelector';
import { canAccessSettings, hasPermission } from '../utils/rbac';
import { t } from '../utils/tempTranslations'; // Temporary translation system
import api from '../services/api';
import {
HomeIcon,
MapIcon,
@@ -16,24 +20,45 @@ import {
SignalIcon,
WifiIcon,
BugAntIcon,
CogIcon
CogIcon,
ShieldCheckIcon
} from '@heroicons/react/24/outline';
import classNames from 'classnames';
const baseNavigation = [
{ name: 'Dashboard', href: '/', icon: HomeIcon },
{ name: 'Map View', href: '/map', icon: MapIcon },
{ name: 'Devices', href: '/devices', icon: ServerIcon },
{ name: 'Detections', href: '/detections', icon: ExclamationTriangleIcon },
{ name: 'Alerts', href: '/alerts', icon: BellIcon },
];
const Layout = () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [tenantInfo, setTenantInfo] = useState(null);
// const { t } = useTranslation(); // Commented out until Docker rebuild
const { user, logout } = useAuth();
const { connected, recentDetections } = useSocket();
const location = useLocation();
// Fetch tenant information for branding
useEffect(() => {
const fetchTenantInfo = async () => {
try {
const response = await api.get('/tenant/info');
setTenantInfo(response.data.data);
} catch (error) {
console.error('Failed to fetch tenant info:', error);
// Don't show error toast as this is not critical
}
};
if (user) {
fetchTenantInfo();
}
}, [user]);
// Build navigation based on user permissions with translations
const baseNavigation = [
{ name: t('navigation.dashboard'), href: '/', icon: HomeIcon },
{ name: t('navigation.map'), href: '/map', icon: MapIcon },
{ name: t('navigation.devices'), href: '/devices', icon: ServerIcon },
{ name: t('navigation.detections'), href: '/detections', icon: ExclamationTriangleIcon },
{ name: t('navigation.alerts'), href: '/alerts', icon: BellIcon },
];
// Build navigation based on user permissions
const navigation = React.useMemo(() => {
if (!user?.role) {
@@ -42,18 +67,23 @@ const Layout = () => {
const nav = [...baseNavigation];
// Add Security Logs if user is admin
if (user.role === 'admin') {
nav.push({ name: t('navigation.security_logs'), href: '/security-logs', icon: ShieldCheckIcon });
}
// Add Settings if user has any settings permissions
if (canAccessSettings(user.role)) {
nav.push({ name: 'Settings', href: '/settings', icon: CogIcon });
nav.push({ name: t('navigation.settings'), href: '/settings', icon: CogIcon });
}
// Add Debug if user has debug permissions
if (hasPermission(user.role, 'debug.access')) {
nav.push({ name: 'Debug', href: '/debug', icon: BugAntIcon });
nav.push({ name: 'Debug', href: '/debug', icon: BugAntIcon }); // TODO: Add to translations
}
return nav;
}, [user]);
}, [user]); // Removed t dependency until Docker rebuild
return (
<div className="min-h-screen flex bg-gray-100">
@@ -73,7 +103,7 @@ const Layout = () => {
<XMarkIcon className="h-6 w-6 text-white" />
</button>
</div>
<SidebarContent navigation={navigation} />
<SidebarContent navigation={navigation} tenantInfo={tenantInfo} />
</div>
</div>
@@ -81,7 +111,7 @@ const Layout = () => {
<div className="hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col flex-grow pt-5 pb-4 overflow-y-auto bg-white border-r border-gray-200">
<SidebarContent navigation={navigation} />
<SidebarContent navigation={navigation} tenantInfo={tenantInfo} />
</div>
</div>
</div>
@@ -101,7 +131,7 @@ const Layout = () => {
<div className="flex-1 px-4 flex justify-between items-center">
<div className="flex-1 flex">
<h1 className="text-xl font-semibold text-gray-900">
{navigation?.find(item => item.href === location.pathname)?.name || 'Drone Detection System'}
{navigation?.find(item => item.href === location.pathname)?.name || t('app.title')}
</h1>
</div>
@@ -119,7 +149,7 @@ const Layout = () => {
) : (
<SignalIcon className="h-3 w-3" />
)}
<span>{connected ? 'Connected' : 'Disconnected'}</span>
<span>{connected ? t('dashboard.online') : t('dashboard.offline')}</span>
</div>
</div>
@@ -127,10 +157,13 @@ const Layout = () => {
{recentDetections.length > 0 && (
<div className="flex items-center space-x-1 px-2 py-1 bg-danger-100 text-danger-800 rounded-full text-xs font-medium">
<ExclamationTriangleIcon className="h-3 w-3" />
<span>{recentDetections.length} recent</span>
<span>{recentDetections.length} {t('dashboard.recentDetections').toLowerCase()}</span>
</div>
)}
{/* Language selector */}
<LanguageSelector />
{/* User menu */}
<div className="ml-3 relative">
<div className="flex items-center space-x-2">
@@ -142,7 +175,7 @@ const Layout = () => {
onClick={logout}
className="text-sm text-gray-500 hover:text-gray-700"
>
Logout
{t('navigation.logout')}
</button>
</div>
</div>
@@ -166,18 +199,32 @@ const Layout = () => {
);
};
const SidebarContent = ({ navigation }) => {
const SidebarContent = ({ navigation, tenantInfo }) => {
const location = useLocation();
return (
<>
<div className="flex items-center flex-shrink-0 px-4">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
{/* Display tenant logo if available, otherwise show default icon */}
{tenantInfo?.branding?.logo_url ? (
<img
src={tenantInfo.branding.logo_url}
alt={`${tenantInfo.name || 'Company'} Logo`}
className="w-8 h-8 object-contain"
onError={(e) => {
// Fallback to default icon if logo fails to load
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
) : null}
{/* Default icon (shown if no logo or logo fails to load) */}
<div className={`w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center ${tenantInfo?.branding?.logo_url ? 'hidden' : ''}`}>
<ExclamationTriangleIcon className="h-5 w-5 text-white" />
</div>
<h1 className="text-lg font-bold text-gray-900">
Drone Detector
{tenantInfo?.name || 'Drone Detector'}
</h1>
</div>
</div>

View File

@@ -1,6 +1,8 @@
import React, { useState } from 'react';
import { format } from 'date-fns';
import { formatFrequency } from '../utils/formatFrequency';
import { useSocket } from '../contexts/SocketContext';
import { useTranslation } from '../utils/tempTranslations';
import {
ExclamationTriangleIcon,
InformationCircleIcon,
@@ -13,6 +15,7 @@ import {
} from '@heroicons/react/24/outline';
const MovementAlertsPanel = () => {
const { t } = useTranslation();
const { movementAlerts, clearMovementAlerts } = useSocket();
const [expandedAlert, setExpandedAlert] = useState(null);
const [filter, setFilter] = useState('all'); // all, critical, high, medium
@@ -55,12 +58,12 @@ const MovementAlertsPanel = () => {
});
const droneTypes = {
1: "DJI Mavic",
2: "Racing Drone",
3: "DJI Phantom",
4: "Fixed Wing",
5: "Surveillance",
0: "Unknown"
1: t('movementAlerts.droneTypes.djiMavic'),
2: t('movementAlerts.droneTypes.racingDrone'),
3: t('movementAlerts.droneTypes.djiPhantom'),
4: t('movementAlerts.droneTypes.fixedWing'),
5: t('movementAlerts.droneTypes.surveillance'),
0: t('movementAlerts.droneTypes.unknown')
};
return (
@@ -68,7 +71,7 @@ const MovementAlertsPanel = () => {
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<h3 className="text-lg font-medium text-gray-900">Movement Alerts</h3>
<h3 className="text-lg font-medium text-gray-900">{t('movementAlerts.title')}</h3>
{movementAlerts.length > 0 && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
{movementAlerts.length}
@@ -82,10 +85,10 @@ const MovementAlertsPanel = () => {
onChange={(e) => setFilter(e.target.value)}
className="text-sm border border-gray-300 rounded px-2 py-1"
>
<option value="all">All Alerts</option>
<option value="critical">Critical</option>
<option value="high">High Priority</option>
<option value="medium">Medium Priority</option>
<option value="all">{t('movementAlerts.allAlerts')}</option>
<option value="critical">{t('movementAlerts.critical')}</option>
<option value="high">{t('movementAlerts.highPriority')}</option>
<option value="medium">{t('movementAlerts.mediumPriority')}</option>
</select>
{movementAlerts.length > 0 && (
@@ -93,7 +96,7 @@ const MovementAlertsPanel = () => {
onClick={clearMovementAlerts}
className="text-sm text-gray-600 hover:text-gray-800"
>
Clear All
{t('movementAlerts.clearAll')}
</button>
)}
</div>
@@ -104,8 +107,8 @@ const MovementAlertsPanel = () => {
{filteredAlerts.length === 0 ? (
<div className="px-6 py-8 text-center text-gray-500">
<EyeIcon className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p>No movement alerts</p>
<p className="text-sm">Drone movement patterns will appear here</p>
<p>{t('movementAlerts.noAlerts')}</p>
<p className="text-sm">{t('movementAlerts.noAlertsDescription')}</p>
</div>
) : (
filteredAlerts.map((alert, index) => (
@@ -121,7 +124,7 @@ const MovementAlertsPanel = () => {
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium text-gray-900">
Drone {alert.droneId} Device {alert.deviceId}
{t('movementAlerts.droneDevice', { droneId: alert.droneId, deviceId: alert.deviceId })}
</h4>
<span className="text-xs text-gray-500">
{format(new Date(alert.timestamp), 'HH:mm:ss')}
@@ -137,7 +140,7 @@ const MovementAlertsPanel = () => {
<div className="flex items-center space-x-1">
{getMovementIcon(alert.analysis.rssiTrend.trend)}
<span className="text-gray-600">
{alert.analysis.rssiTrend.trend.toLowerCase()}
{t(`movementAlerts.${alert.analysis.rssiTrend.trend.toLowerCase()}`)}
</span>
</div>
@@ -174,26 +177,26 @@ const MovementAlertsPanel = () => {
<div className="mt-4 pl-8 space-y-3">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-700">Drone Type:</span>
<span className="font-medium text-gray-700">{t('movementAlerts.droneType')}</span>
<div className="text-gray-900">
{droneTypes[alert.detection.drone_type] || 'Unknown'}
{droneTypes[alert.detection.drone_type] || t('movementAlerts.droneTypes.unknown')}
</div>
</div>
<div>
<span className="font-medium text-gray-700">Frequency:</span>
<div className="text-gray-900">{alert.detection.freq}MHz</div>
<span className="font-medium text-gray-700">{t('movementAlerts.frequency')}</span>
<div className="text-gray-900">{formatFrequency(alert.detection.freq)}</div>
</div>
<div>
<span className="font-medium text-gray-700">Confidence:</span>
<span className="font-medium text-gray-700">{t('movementAlerts.confidence')}</span>
<div className="text-gray-900">
{(alert.detection.confidence_level * 100).toFixed(0)}%
</div>
</div>
<div>
<span className="font-medium text-gray-700">Signal Duration:</span>
<span className="font-medium text-gray-700">{t('movementAlerts.signalDuration')}</span>
<div className="text-gray-900">
{(alert.detection.signal_duration / 1000).toFixed(1)}s
</div>
@@ -202,14 +205,14 @@ const MovementAlertsPanel = () => {
{alert.analysis.movement && (
<div>
<span className="font-medium text-gray-700 block mb-1">Movement Pattern:</span>
<span className="font-medium text-gray-700 block mb-1">{t('movementAlerts.movementPattern')}</span>
<div className="text-sm space-y-1">
<div>Pattern: <span className="font-mono">{alert.analysis.movement.pattern}</span></div>
<div>{t('movementAlerts.pattern')} <span className="font-mono">{alert.analysis.movement.pattern}</span></div>
{alert.analysis.movement.speed > 0 && (
<div>Speed: <span className="font-mono">{alert.analysis.movement.speed.toFixed(1)} m/s</span></div>
<div>{t('movementAlerts.speed')} <span className="font-mono">{alert.analysis.movement.speed.toFixed(1)} m/s</span></div>
)}
{alert.analysis.movement.totalDistance > 0 && (
<div>Distance: <span className="font-mono">{(alert.analysis.movement.totalDistance * 1000).toFixed(0)}m</span></div>
<div>{t('movementAlerts.distance')} <span className="font-mono">{(alert.analysis.movement.totalDistance * 1000).toFixed(0)}m</span></div>
)}
</div>
</div>
@@ -217,16 +220,19 @@ const MovementAlertsPanel = () => {
{alert.analysis.detectionCount && (
<div>
<span className="font-medium text-gray-700">Tracking Stats:</span>
<span className="font-medium text-gray-700">{t('movementAlerts.trackingStats')}</span>
<div className="text-sm mt-1">
<div>{alert.analysis.detectionCount} detections over {(alert.analysis.timeTracked / 60).toFixed(1)} minutes</div>
<div>{t('movementAlerts.detectionsOverTime', {
count: alert.analysis.detectionCount,
time: (alert.analysis.timeTracked / 60).toFixed(1)
})}</div>
</div>
</div>
)}
{alert.history && alert.history.length > 0 && (
<div>
<span className="font-medium text-gray-700 block mb-2">Recent RSSI History:</span>
<span className="font-medium text-gray-700 block mb-2">{t('movementAlerts.recentRssiHistory')}</span>
<div className="flex items-center space-x-1">
{alert.history.slice(-5).map((point, i) => (
<div

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
const TestTranslation = () => {
const { t, i18n } = useTranslation();
return (
<div className="p-4">
<h1>Translation Test</h1>
<p>Current language: {i18n.language}</p>
<p>Dashboard translation: {t('navigation.dashboard')}</p>
<p>Loading translation: {t('common.loading')}</p>
<p>Error translation: {t('common.error')}</p>
<button
onClick={() => i18n.changeLanguage('sv')}
className="bg-blue-500 text-white px-4 py-2 rounded mr-2"
>
Switch to Swedish
</button>
<button
onClick={() => i18n.changeLanguage('en')}
className="bg-green-500 text-white px-4 py-2 rounded"
>
Switch to English
</button>
</div>
);
};
export default TestTranslation;

View File

@@ -0,0 +1,74 @@
import React from 'react';
// import { useTranslation } from 'react-i18next'; // Commented out until Docker rebuild
import { Menu, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { GlobeAltIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { getCurrentLanguage, changeLanguage } from '../../utils/tempTranslations'; // Temporary system
const languages = [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'sv', name: 'Svenska', flag: '🇸🇪' }
];
export default function LanguageSelector({ className = '' }) {
// const { i18n, t } = useTranslation(); // Commented out until Docker rebuild
const currentLang = getCurrentLanguage();
const currentLanguage = languages.find(lang => lang.code === currentLang) || languages[0];
const handleChangeLanguage = (languageCode) => {
changeLanguage(languageCode);
};
return (
<Menu as="div" className={`relative inline-block text-left ${className}`}>
<div>
<Menu.Button className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<GlobeAltIcon className="w-4 h-4 mr-2" />
<span className="mr-1">{currentLanguage.flag}</span>
<span>{currentLanguage.name}</span>
<ChevronDownIcon className="w-4 h-4 ml-2" />
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-10 w-48 mt-2 origin-top-right bg-white border border-gray-300 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{languages.map((language) => (
<Menu.Item key={language.code}>
{({ active }) => (
<button
onClick={() => handleChangeLanguage(language.code)}
className={`${
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700'
} ${
language.code === currentLang ? 'bg-indigo-50 text-indigo-600' : ''
} group flex items-center px-4 py-2 text-sm w-full text-left`}
>
<span className="mr-3">{language.flag}</span>
<span>{language.name}</span>
{language.code === currentLang && (
<span className="ml-auto">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</span>
)}
</button>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -0,0 +1,519 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from '../../utils/tempTranslations';
const AuditLogs = () => {
const { t } = useTranslation();
const [auditLogs, setAuditLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({
page: 1,
limit: 50,
level: '',
action: '',
tenantId: '',
userId: '',
startDate: '',
endDate: '',
search: ''
});
const [pagination, setPagination] = useState({});
const [availableActions, setAvailableActions] = useState([]);
const [summary, setSummary] = useState({});
useEffect(() => {
fetchAuditLogs();
fetchAvailableActions();
fetchSummary();
}, [filters]);
const fetchAuditLogs = async () => {
try {
setLoading(true);
const queryParams = new URLSearchParams();
Object.keys(filters).forEach(key => {
if (filters[key]) {
queryParams.append(key, filters[key]);
}
});
const token = localStorage.getItem('managementToken');
const response = await fetch(`/api/management/audit-logs?${queryParams}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to fetch audit logs');
}
const data = await response.json();
setAuditLogs(data.data.auditLogs);
setPagination(data.data.pagination);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const fetchAvailableActions = async () => {
try {
const token = localStorage.getItem('managementToken');
const response = await fetch('/api/management/audit-logs/actions', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
setAvailableActions(data.data);
}
} catch (err) {
console.error('Failed to fetch available actions:', err);
}
};
const fetchSummary = async () => {
try {
const token = localStorage.getItem('managementToken');
const response = await fetch('/api/management/audit-logs/summary', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
setSummary(data.data);
}
} catch (err) {
console.error('Failed to fetch summary:', err);
}
};
const handleFilterChange = (key, value) => {
setFilters(prev => ({
...prev,
[key]: value,
page: 1 // Reset to first page when filtering
}));
};
const handlePageChange = (newPage) => {
setFilters(prev => ({
...prev,
page: newPage
}));
};
const formatTimestamp = (timestamp) => {
return new Date(timestamp).toLocaleString();
};
const getLevelBadgeClass = (level) => {
switch (level) {
case 'INFO':
return 'bg-blue-100 text-blue-800';
case 'WARNING':
return 'bg-yellow-100 text-yellow-800';
case 'ERROR':
return 'bg-red-100 text-red-800';
case 'CRITICAL':
return 'bg-red-200 text-red-900 font-bold';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getSuccessIndicator = (success) => {
if (success === true) {
return <span className="text-green-600"></span>;
} else if (success === false) {
return <span className="text-red-600"></span>;
}
return <span className="text-gray-400">-</span>;
};
const clearFilters = () => {
setFilters({
page: 1,
limit: 50,
level: '',
action: '',
tenantId: '',
userId: '',
startDate: '',
endDate: '',
search: ''
});
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-900">
{t('management.auditLogs') || 'Security Audit Logs'}
</h1>
<button
onClick={() => window.location.reload()}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md"
>
{t('common.refresh') || 'Refresh'}
</button>
</div>
{/* Summary Statistics */}
{summary.summary && (
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
{t('management.totalLogs') || 'Total Logs'}
</dt>
<dd className="text-lg font-medium text-gray-900">
{summary.summary.totalLogs}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
{t('management.successfulActions') || 'Successful'}
</dt>
<dd className="text-lg font-medium text-green-600">
{summary.summary.successfulActions}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
{t('management.failedActions') || 'Failed'}
</dt>
<dd className="text-lg font-medium text-red-600">
{summary.summary.failedActions}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
{t('management.warnings') || 'Warnings'}
</dt>
<dd className="text-lg font-medium text-yellow-600">
{summary.summary.warningActions}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
{t('management.critical') || 'Critical'}
</dt>
<dd className="text-lg font-medium text-red-800">
{summary.summary.criticalActions}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
)}
{/* Filters */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
{t('common.filters') || 'Filters'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Search */}
<div>
<label className="block text-sm font-medium text-gray-700">
{t('common.search') || 'Search'}
</label>
<input
type="text"
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
placeholder={t('management.searchPlaceholder') || 'Search logs...'}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Level */}
<div>
<label className="block text-sm font-medium text-gray-700">
{t('management.level') || 'Level'}
</label>
<select
value={filters.level}
onChange={(e) => handleFilterChange('level', e.target.value)}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{t('common.all') || 'All'}</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</div>
{/* Action */}
<div>
<label className="block text-sm font-medium text-gray-700">
{t('management.action') || 'Action'}
</label>
<select
value={filters.action}
onChange={(e) => handleFilterChange('action', e.target.value)}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{t('common.all') || 'All'}</option>
{availableActions.map(action => (
<option key={action} value={action}>{action}</option>
))}
</select>
</div>
{/* Date Range */}
<div>
<label className="block text-sm font-medium text-gray-700">
{t('management.dateRange') || 'Date Range'}
</label>
<div className="flex space-x-2">
<input
type="date"
value={filters.startDate}
onChange={(e) => handleFilterChange('startDate', e.target.value)}
className="flex-1 border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
<input
type="date"
value={filters.endDate}
onChange={(e) => handleFilterChange('endDate', e.target.value)}
className="flex-1 border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
<div className="mt-4 flex justify-between">
<button
onClick={clearFilters}
className="text-sm text-gray-600 hover:text-gray-900"
>
{t('common.clearFilters') || 'Clear Filters'}
</button>
<span className="text-sm text-gray-500">
{pagination.totalCount} {t('management.totalEntries') || 'total entries'}
</span>
</div>
</div>
{/* Audit Logs Table */}
<div className="bg-white shadow overflow-hidden sm:rounded-md">
{loading ? (
<div className="p-6 text-center">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="ml-2">{t('common.loading') || 'Loading...'}</span>
</div>
) : error ? (
<div className="p-6 text-center text-red-600">
{t('common.error') || 'Error'}: {error}
</div>
) : auditLogs.length === 0 ? (
<div className="p-6 text-center text-gray-500">
{t('management.noAuditLogs') || 'No audit logs found'}
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('management.timestamp') || 'Timestamp'}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('management.level') || 'Level'}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('management.action') || 'Action'}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('management.user') || 'User'}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('management.tenant') || 'Tenant'}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('management.message') || 'Message'}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('management.success') || 'Success'}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('management.ipAddress') || 'IP Address'}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{auditLogs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatTimestamp(log.timestamp)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getLevelBadgeClass(log.level)}`}>
{log.level}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.action}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.username || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.tenant_slug || '-'}
</td>
<td className="px-6 py-4 text-sm text-gray-900 max-w-xs truncate">
<span title={log.message}>{log.message}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
{getSuccessIndicator(log.success)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.ip_address || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => handlePageChange(pagination.currentPage - 1)}
disabled={!pagination.hasPrevPage}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
{t('common.previous') || 'Previous'}
</button>
<button
onClick={() => handlePageChange(pagination.currentPage + 1)}
disabled={!pagination.hasNextPage}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
{t('common.next') || 'Next'}
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
{t('common.showing') || 'Showing'}{' '}
<span className="font-medium">{((pagination.currentPage - 1) * pagination.limit) + 1}</span>
{' '}{t('common.to') || 'to'}{' '}
<span className="font-medium">
{Math.min(pagination.currentPage * pagination.limit, pagination.totalCount)}
</span>
{' '}{t('common.of') || 'of'}{' '}
<span className="font-medium">{pagination.totalCount}</span>
{' '}{t('common.results') || 'results'}
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => handlePageChange(pagination.currentPage - 1)}
disabled={!pagination.hasPrevPage}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
<span className="sr-only">{t('common.previous') || 'Previous'}</span>
&#8249;
</button>
{/* Page numbers */}
{Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => {
const page = i + Math.max(1, pagination.currentPage - 2);
if (page > pagination.totalPages) return null;
return (
<button
key={page}
onClick={() => handlePageChange(page)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
page === pagination.currentPage
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{page}
</button>
);
})}
<button
onClick={() => handlePageChange(pagination.currentPage + 1)}
disabled={!pagination.hasNextPage}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
<span className="sr-only">{t('common.next') || 'Next'}</span>
&#8250;
</button>
</nav>
</div>
</div>
</div>
)}
</div>
);
};
export default AuditLogs;

View File

@@ -16,7 +16,8 @@ export const SocketProvider = ({ children }) => {
const [notificationsEnabled, setNotificationsEnabled] = useState(
localStorage.getItem('notificationsEnabled') !== 'false' // Default to enabled
);
const { isAuthenticated } = useAuth();
const { isAuthenticated, user } = useAuth();
const tenant = user?.tenant_id;
// Mobile notification management
const [notificationCooldown, setNotificationCooldown] = useState(new Map());
@@ -135,6 +136,14 @@ export const SocketProvider = ({ children }) => {
// Join dashboard room for general updates
newSocket.emit('join_dashboard');
// 🔒 SECURITY: Join tenant-specific room for isolated updates
if (tenant) {
newSocket.emit('join_tenant_room', tenant);
console.log(`🔒 Joined tenant room: ${tenant}`);
} else {
console.warn('⚠️ No tenant available for Socket.IO room isolation');
}
toast.success('Connected to real-time updates');
});
@@ -224,7 +233,7 @@ export const SocketProvider = ({ children }) => {
setConnected(false);
}
}
}, [isAuthenticated]);
}, [isAuthenticated, tenant]);
const joinDeviceRoom = (deviceId) => {
if (socket) {

View File

@@ -0,0 +1,181 @@
import { useState, useEffect, useMemo } from 'react';
/**
* Custom hook for fetching and managing drone types from the API
* Provides caching, error handling, and convenient access to drone type data
*/
export const useDroneTypes = () => {
const [droneTypes, setDroneTypes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Fetch drone types from API
useEffect(() => {
const fetchDroneTypes = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/drone-types');
if (!response.ok) {
throw new Error(`Failed to fetch drone types: ${response.statusText}`);
}
const result = await response.json();
if (result.success && result.data) {
setDroneTypes(result.data);
} else {
throw new Error('Invalid response format from drone types API');
}
} catch (err) {
console.error('Error fetching drone types:', err);
setError(err.message);
// Set fallback data on error
setDroneTypes(getFallbackDroneTypes());
} finally {
setLoading(false);
}
};
fetchDroneTypes();
}, []);
// Create a mapping object for quick lookups by ID
const droneTypeMap = useMemo(() => {
const map = {};
droneTypes.forEach(type => {
map[type.id] = type;
});
return map;
}, [droneTypes]);
// Get drone type info by ID with fallback
const getDroneTypeInfo = (droneTypeId) => {
const typeInfo = droneTypeMap[droneTypeId];
if (typeInfo) {
return {
...typeInfo,
// Add visual styling based on threat level
...getVisualStyling(typeInfo)
};
}
// Fallback for unknown types
return {
id: droneTypeId,
name: 'Unknown',
category: 'Unknown',
threat_level: 'medium',
description: `Unknown drone type ${droneTypeId}`,
...getVisualStyling({ threat_level: 'medium' })
};
};
// Get drone types by category
const getDroneTypesByCategory = (category) => {
return droneTypes.filter(type => type.category === category);
};
// Get drone types by threat level
const getDroneTypesByThreatLevel = (threatLevel) => {
return droneTypes.filter(type => type.threat_level === threatLevel);
};
// Get all categories
const getCategories = () => {
const categories = new Set(droneTypes.map(type => type.category));
return Array.from(categories);
};
// Get all threat levels
const getThreatLevels = () => {
const levels = new Set(droneTypes.map(type => type.threat_level));
return Array.from(levels);
};
return {
droneTypes,
droneTypeMap,
loading,
error,
getDroneTypeInfo,
getDroneTypesByCategory,
getDroneTypesByThreatLevel,
getCategories,
getThreatLevels
};
};
/**
* Get visual styling based on threat level and category
*/
const getVisualStyling = (typeInfo) => {
const isMilitary = typeInfo.category?.includes('Military') ||
typeInfo.threat_level === 'critical';
const isWarning = isMilitary || typeInfo.threat_level === 'high';
// Base styling
let styling = {
warning: isWarning,
icon: isMilitary ? '⚠️' : '🛩️'
};
// Color scheme based on threat level
switch (typeInfo.threat_level) {
case 'critical':
styling = {
...styling,
color: 'red',
bgColor: 'bg-red-100',
textColor: 'text-red-800',
borderColor: 'border-red-300'
};
break;
case 'high':
styling = {
...styling,
color: 'orange',
bgColor: 'bg-orange-100',
textColor: 'text-orange-800',
borderColor: 'border-orange-300'
};
break;
case 'medium':
styling = {
...styling,
color: 'yellow',
bgColor: 'bg-yellow-100',
textColor: 'text-yellow-800',
borderColor: 'border-yellow-300'
};
break;
case 'low':
default:
styling = {
...styling,
color: 'gray',
bgColor: 'bg-gray-100',
textColor: 'text-gray-800',
borderColor: 'border-gray-300'
};
break;
}
return styling;
};
/**
* Fallback drone types for when API is unavailable
* This ensures the app continues working even if the API is down
*/
const getFallbackDroneTypes = () => {
return [
{ id: 0, name: 'None', category: 'Unknown', threat_level: 'low' },
{ id: 1, name: 'Unknown', category: 'Unknown', threat_level: 'medium' },
{ id: 2, name: 'Orlan', category: 'Military/Reconnaissance', threat_level: 'critical' },
{ id: 3, name: 'Zala', category: 'Military/Surveillance', threat_level: 'critical' },
{ id: 13, name: 'DJI', category: 'Commercial/Professional', threat_level: 'low' }
];
};

34
client/src/i18n/index.js Normal file
View File

@@ -0,0 +1,34 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Import translation files
import en from './locales/en.json';
import sv from './locales/sv.json';
const resources = {
en: {
translation: en
},
sv: {
translation: sv
}
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
lng: 'en', // default language
fallbackLng: 'en',
interpolation: {
escapeValue: false // React already does escaping
},
detection: {
order: ['localStorage', 'navigator', 'htmlTag'],
caches: ['localStorage']
}
});
export default i18n;

View File

@@ -0,0 +1,142 @@
{
"app": {
"title": "UAM-ILS Drone Detection System",
"subtitle": "Real-time Unmanned Aerial Vehicle Monitoring"
},
"navigation": {
"dashboard": "Dashboard",
"detections": "Detections",
"devices": "Devices",
"alerts": "Alerts",
"settings": "Settings",
"logout": "Logout"
},
"auth": {
"login": "Login",
"username": "Username",
"password": "Password",
"loginButton": "Sign In",
"loginError": "Invalid credentials. Please try again.",
"sessionExpired": "Your session has expired. Please log in again.",
"accessDenied": "Access denied. Please contact support.",
"loggingIn": "Signing in...",
"logout": "Logout",
"logoutConfirm": "Are you sure you want to log out?"
},
"dashboard": {
"title": "System Overview",
"activeDetectors": "Active Detectors",
"recentDetections": "Recent Detections",
"threatLevel": "Threat Level",
"systemStatus": "System Status",
"online": "Online",
"offline": "Offline",
"maintenance": "Maintenance"
},
"detections": {
"title": "Drone Detections",
"noDetections": "No detections found",
"loadingDetections": "Loading detections...",
"filterByType": "Filter by Type",
"filterByThreat": "Filter by Threat Level",
"allTypes": "All Types",
"allThreats": "All Threat Levels",
"timestamp": "Timestamp",
"location": "Location",
"droneType": "Drone Type",
"threatLevel": "Threat Level",
"distance": "Distance",
"altitude": "Altitude",
"confidence": "Confidence",
"actions": "Actions",
"viewDetails": "View Details",
"deleteDetection": "Delete Detection",
"confirmDelete": "Are you sure you want to delete this detection?"
},
"devices": {
"title": "Detection Devices",
"noDevices": "No devices configured",
"loadingDevices": "Loading devices...",
"addDevice": "Add Device",
"deviceId": "Device ID",
"deviceName": "Device Name",
"status": "Status",
"lastSeen": "Last Seen",
"location": "Location",
"actions": "Actions",
"edit": "Edit",
"delete": "Delete",
"activate": "Activate",
"deactivate": "Deactivate"
},
"alerts": {
"title": "Alert Configuration",
"noAlerts": "No alert rules configured",
"loadingAlerts": "Loading alert rules...",
"addAlert": "Add Alert Rule",
"ruleName": "Rule Name",
"conditions": "Conditions",
"actions": "Actions",
"enabled": "Enabled",
"disabled": "Disabled"
},
"settings": {
"title": "Settings",
"general": "General",
"notifications": "Notifications",
"language": "Language",
"theme": "Theme",
"timezone": "Timezone",
"save": "Save Changes",
"cancel": "Cancel",
"saved": "Settings saved successfully",
"error": "Failed to save settings"
},
"common": {
"loading": "Loading...",
"error": "An error occurred",
"retry": "Retry",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"remove": "Remove",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
"ok": "OK",
"close": "Close",
"search": "Search",
"filter": "Filter",
"clear": "Clear",
"refresh": "Refresh",
"export": "Export",
"import": "Import"
},
"errors": {
"networkError": "Network connection error. Please check your internet connection.",
"serverError": "Server error. Please try again later.",
"notFound": "The requested resource was not found.",
"unauthorized": "You are not authorized to access this resource.",
"forbidden": "Access to this resource is forbidden.",
"validationError": "Please check your input and try again.",
"sessionExpired": "Your session has expired. Please log in again.",
"unknownError": "An unknown error occurred. Please try again."
},
"droneTypes": {
"unknown": "Unknown",
"consumer": "Consumer",
"commercial": "Commercial",
"military": "Military",
"surveillance": "Surveillance",
"racing": "Racing",
"educational": "Educational"
},
"threatLevels": {
"low": "Low",
"medium": "Medium",
"high": "High",
"critical": "Critical"
}
}

View File

@@ -0,0 +1,142 @@
{
"app": {
"title": "UAM-ILS Drönardetektionssystem",
"subtitle": "Realtidsövervakning av obemannade luftfarkoster"
},
"navigation": {
"dashboard": "Översikt",
"detections": "Detekteringar",
"devices": "Enheter",
"alerts": "Larm",
"settings": "Inställningar",
"logout": "Logga ut"
},
"auth": {
"login": "Logga in",
"username": "Användarnamn",
"password": "Lösenord",
"loginButton": "Logga in",
"loginError": "Ogiltiga inloggningsuppgifter. Försök igen.",
"sessionExpired": "Din session har löpt ut. Vänligen logga in igen.",
"accessDenied": "Åtkomst nekad. Vänligen kontakta support.",
"loggingIn": "Loggar in...",
"logout": "Logga ut",
"logoutConfirm": "Är du säker på att du vill logga ut?"
},
"dashboard": {
"title": "Systemöversikt",
"activeDetectors": "Aktiva detektorer",
"recentDetections": "Senaste detekteringar",
"threatLevel": "Hotnivå",
"systemStatus": "Systemstatus",
"online": "Online",
"offline": "Offline",
"maintenance": "Underhåll"
},
"detections": {
"title": "Drönardetekteringar",
"noDetections": "Inga detekteringar hittades",
"loadingDetections": "Laddar detekteringar...",
"filterByType": "Filtrera efter typ",
"filterByThreat": "Filtrera efter hotnivå",
"allTypes": "Alla typer",
"allThreats": "Alla hotnivåer",
"timestamp": "Tidsstämpel",
"location": "Plats",
"droneType": "Drönartyp",
"threatLevel": "Hotnivå",
"distance": "Avstånd",
"altitude": "Höjd",
"confidence": "Säkerhet",
"actions": "Åtgärder",
"viewDetails": "Visa detaljer",
"deleteDetection": "Ta bort detektion",
"confirmDelete": "Är du säker på att du vill ta bort denna detektion?"
},
"devices": {
"title": "Detektionsenheter",
"noDevices": "Inga enheter konfigurerade",
"loadingDevices": "Laddar enheter...",
"addDevice": "Lägg till enhet",
"deviceId": "Enhets-ID",
"deviceName": "Enhetsnamn",
"status": "Status",
"lastSeen": "Senast sedd",
"location": "Plats",
"actions": "Åtgärder",
"edit": "Redigera",
"delete": "Ta bort",
"activate": "Aktivera",
"deactivate": "Inaktivera"
},
"alerts": {
"title": "Larmkonfiguration",
"noAlerts": "Inga larmregler konfigurerade",
"loadingAlerts": "Laddar larmregler...",
"addAlert": "Lägg till larmregel",
"ruleName": "Regelnamn",
"conditions": "Villkor",
"actions": "Åtgärder",
"enabled": "Aktiverad",
"disabled": "Inaktiverad"
},
"settings": {
"title": "Inställningar",
"general": "Allmänt",
"notifications": "Notifieringar",
"language": "Språk",
"theme": "Tema",
"timezone": "Tidszon",
"save": "Spara ändringar",
"cancel": "Avbryt",
"saved": "Inställningar sparade framgångsrikt",
"error": "Misslyckades att spara inställningar"
},
"common": {
"loading": "Laddar...",
"error": "Ett fel uppstod",
"retry": "Försök igen",
"cancel": "Avbryt",
"save": "Spara",
"delete": "Ta bort",
"edit": "Redigera",
"add": "Lägg till",
"remove": "Ta bort",
"confirm": "Bekräfta",
"yes": "Ja",
"no": "Nej",
"ok": "OK",
"close": "Stäng",
"search": "Sök",
"filter": "Filtrera",
"clear": "Rensa",
"refresh": "Uppdatera",
"export": "Exportera",
"import": "Importera"
},
"errors": {
"networkError": "Nätverksanslutningsfel. Vänligen kontrollera din internetanslutning.",
"serverError": "Serverfel. Vänligen försök igen senare.",
"notFound": "Den begärda resursen hittades inte.",
"unauthorized": "Du är inte behörig att komma åt denna resurs.",
"forbidden": "Åtkomst till denna resurs är förbjuden.",
"validationError": "Vänligen kontrollera din inmatning och försök igen.",
"sessionExpired": "Din session har löpt ut. Vänligen logga in igen.",
"unknownError": "Ett okänt fel uppstod. Vänligen försök igen."
},
"droneTypes": {
"unknown": "Okänd",
"consumer": "Konsument",
"commercial": "Kommersiell",
"military": "Militär",
"surveillance": "Övervakning",
"racing": "Racing",
"educational": "Utbildning"
},
"threatLevels": {
"low": "Låg",
"medium": "Medium",
"high": "Hög",
"critical": "Kritisk"
}
}

View File

@@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import './i18n' // Initialize i18n
// Suppress browser extension errors in development
if (process.env.NODE_ENV === 'development') {

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
import { useSocket } from '../contexts/SocketContext';
import MovementAlertsPanel from '../components/MovementAlertsPanel';
import api from '../services/api';
import { formatFrequency } from '../utils/formatFrequency';
import { t } from '../utils/tempTranslations'; // Temporary translation system
import {
ServerIcon,
ExclamationTriangleIcon,
@@ -71,7 +73,7 @@ const Dashboard = () => {
const stats = [
{
id: 1,
name: 'Total Devices',
name: t('dashboard.totalDetections'),
stat: overview?.summary?.total_devices || 0,
icon: ServerIcon,
change: null,
@@ -80,7 +82,7 @@ const Dashboard = () => {
},
{
id: 2,
name: 'Online Devices',
name: t('dashboard.connectedDevices'),
stat: overview?.summary?.online_devices || 0,
icon: SignalIcon,
change: null,
@@ -89,7 +91,7 @@ const Dashboard = () => {
},
{
id: 3,
name: 'Recent Detections',
name: t('dashboard.recentDetections'),
stat: overview?.summary?.recent_detections || 0,
icon: ExclamationTriangleIcon,
change: null,
@@ -98,7 +100,7 @@ const Dashboard = () => {
},
{
id: 4,
name: 'Unique Drones',
name: t('dashboard.activeAlerts'),
stat: overview?.summary?.unique_drones_detected || 0,
icon: EyeIcon,
change: null,
@@ -108,9 +110,9 @@ const Dashboard = () => {
];
const deviceStatusData = [
{ name: 'Online', value: overview?.device_status?.online || 0, color: '#22c55e' },
{ name: 'Offline', value: overview?.device_status?.offline || 0, color: '#ef4444' },
{ name: 'Inactive', value: overview?.device_status?.inactive || 0, color: '#6b7280' }
{ name: t('dashboard.online'), value: overview?.device_status?.online || 0, color: '#22c55e' },
{ name: t('dashboard.offline'), value: overview?.device_status?.offline || 0, color: '#ef4444' },
{ name: t('dashboard.inactive'), value: overview?.device_status?.inactive || 0, color: '#6b7280' }
];
return (
@@ -119,7 +121,7 @@ const Dashboard = () => {
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">
System Overview
{t('dashboard.title')}
</h3>
</div>
<div className="flex items-center space-x-4">
@@ -164,7 +166,7 @@ const Dashboard = () => {
{/* Detection Timeline */}
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Detections Timeline (24h)
{t('dashboard.detectionsTimeline24h')}
</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
@@ -235,7 +237,7 @@ const Dashboard = () => {
{deviceActivity.length > 0 && (
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Device Activity (24h)
{t('dashboard.deviceActivity24h')}
</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
@@ -261,7 +263,7 @@ const Dashboard = () => {
{/* Recent Activity */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Recent Activity</h3>
<h3 className="text-lg font-medium text-gray-900">{t('dashboard.recentActivity')}</h3>
</div>
<div className="divide-y divide-gray-200 max-h-96 overflow-y-auto">
{recentActivity.map((activity, index) => (
@@ -273,9 +275,9 @@ const Dashboard = () => {
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900">
{activity.type === 'detection' ? (
<>Drone {activity.data.drone_id} detected by {activity.data.device_name}</>
<>{t('dashboard.droneDetected')} {activity.data.drone_id} {activity.data.device_name}</>
) : (
<>Heartbeat from {activity.data.device_name}</>
<>{t('dashboard.heartbeatFrom')} {activity.data.device_name}</>
)}
</p>
<p className="text-xs text-gray-500">
@@ -287,7 +289,7 @@ const Dashboard = () => {
))}
{recentActivity.length === 0 && (
<div className="px-6 py-8 text-center text-gray-500">
No recent activity
{t('dashboard.noRecentActivity')}
</div>
)}
</div>
@@ -296,14 +298,14 @@ const Dashboard = () => {
{/* Real-time Detections */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">Live Detections</h3>
<h3 className="text-lg font-medium text-gray-900">{t('dashboard.liveDetections')}</h3>
<div className={`flex items-center space-x-2 px-2 py-1 rounded-full text-xs ${
connected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
<div className={`w-2 h-2 rounded-full ${
connected ? 'bg-green-400' : 'bg-red-400'
}`} />
<span>{connected ? 'Live' : 'Disconnected'}</span>
<span>{connected ? t('dashboard.live') : t('dashboard.disconnected')}</span>
</div>
</div>
<div className="divide-y divide-gray-200 max-h-96 overflow-y-auto">
@@ -313,12 +315,12 @@ const Dashboard = () => {
<div className="flex-shrink-0 w-2 h-2 rounded-full bg-red-400 animate-pulse" />
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900">
Drone {detection.drone_id} detected
{t('dashboard.droneDetected')} {detection.drone_id}
</p>
<p className="text-xs text-gray-500">
{detection.device.name || `Device ${detection.device_id}`}
RSSI: {detection.rssi}dBm
Freq: {detection.freq}MHz
Freq: {formatFrequency(detection.freq)}
</p>
<p className="text-xs text-gray-500">
{format(new Date(detection.server_timestamp), 'HH:mm:ss')}
@@ -329,7 +331,7 @@ const Dashboard = () => {
))}
{recentDetections.length === 0 && (
<div className="px-6 py-8 text-center text-gray-500">
No recent detections
{t('dashboard.noRecentDetections')}
</div>
)}
</div>
@@ -345,14 +347,14 @@ const Dashboard = () => {
{/* Movement Summary Stats */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Movement Tracking
{t('dashboard.movementTracking')}
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<div>
<div className="font-medium text-red-900">Critical Alerts</div>
<div className="text-sm text-red-700">Very close approaches</div>
<div className="font-medium text-red-900">{t('dashboard.criticalAlerts')}</div>
<div className="text-sm text-red-700">{t('dashboard.veryCloseApproaches')}</div>
</div>
<div className="text-2xl font-bold text-red-600">
{movementAlerts.filter(a => a.analysis.alertLevel >= 3).length}
@@ -361,8 +363,8 @@ const Dashboard = () => {
<div className="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
<div>
<div className="font-medium text-orange-900">High Priority</div>
<div className="text-sm text-orange-700">Approaching drones</div>
<div className="font-medium text-orange-900">{t('dashboard.highPriority')}</div>
<div className="text-sm text-orange-700">{t('dashboard.approachingDrones')}</div>
</div>
<div className="text-2xl font-bold text-orange-600">
{movementAlerts.filter(a => a.analysis.alertLevel === 2).length}
@@ -371,8 +373,8 @@ const Dashboard = () => {
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div>
<div className="font-medium text-blue-900">Medium Priority</div>
<div className="text-sm text-blue-700">Movement changes</div>
<div className="font-medium text-blue-900">{t('dashboard.mediumPriority')}</div>
<div className="text-sm text-blue-700">{t('dashboard.movementChanges')}</div>
</div>
<div className="text-2xl font-bold text-blue-600">
{movementAlerts.filter(a => a.analysis.alertLevel === 1).length}
@@ -382,15 +384,15 @@ const Dashboard = () => {
<div className="pt-4 border-t border-gray-200">
<div className="text-sm text-gray-600">
<div className="flex justify-between">
<span>Total Tracked:</span>
<span className="font-medium">{movementAlerts.length} events</span>
<span>{t('dashboard.totalTracked')}:</span>
<span className="font-medium">{movementAlerts.length} {t('dashboard.events')}</span>
</div>
<div className="flex justify-between mt-1">
<span>Last Alert:</span>
<span>{t('dashboard.lastAlert')}:</span>
<span className="font-medium">
{movementAlerts.length > 0
? format(new Date(movementAlerts[0].timestamp), 'HH:mm:ss')
: 'None'
: t('dashboard.none')
}
</span>
</div>

View File

@@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import api from '../services/api';
import { format } from 'date-fns';
import { formatFrequency } from '../utils/formatFrequency';
import { useTranslation } from '../utils/tempTranslations';
import {
BugAntIcon,
ExclamationTriangleIcon,
@@ -12,6 +14,7 @@ import {
} from '@heroicons/react/24/outline';
const Debug = () => {
const { t } = useTranslation();
const [debugData, setDebugData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -47,11 +50,11 @@ const Debug = () => {
setPagination(response.data.pagination);
setDebugInfo(response.data.debug_info);
} else {
setError(response.data.message || 'Failed to fetch debug data');
setError(response.data.message || t('debug.noDetectionsFound'));
}
} catch (err) {
console.error('Error fetching debug data:', err);
setError(err.response?.data?.message || 'Failed to fetch debug data');
setError(err.response?.data?.message || t('debug.noDetectionsFound'));
} finally {
setLoading(false);
}
@@ -96,11 +99,11 @@ const Debug = () => {
setShowPayloadModal(true);
} else {
console.error('No payload data found for detection:', detectionId);
alert('No raw payload data found for this detection');
alert(t('debug.payloadViewer.noPayloadData'));
}
} catch (err) {
console.error('Error fetching payload:', err);
alert('Failed to fetch payload data');
alert(t('debug.payloadViewer.failedToFetch'));
} finally {
setPayloadLoading(false);
}
@@ -145,7 +148,7 @@ const Debug = () => {
<div className="flex">
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error</h3>
<h3 className="text-sm font-medium text-red-800">{t('common.error')}</h3>
<div className="mt-2 text-sm text-red-700">{error}</div>
</div>
</div>
@@ -160,9 +163,9 @@ const Debug = () => {
<div className="flex items-center">
<BugAntIcon className="h-8 w-8 text-orange-500 mr-3" />
<div>
<h1 className="text-2xl font-bold text-gray-900">Debug Console</h1>
<h1 className="text-2xl font-bold text-gray-900">{t('debug.title')}</h1>
<p className="text-sm text-gray-500">
Admin-only access to all detection data including drone type 0 (None)
{t('debug.subtitle')}
</p>
</div>
</div>
@@ -174,10 +177,10 @@ const Debug = () => {
<div className="flex">
<InformationCircleIcon className="h-5 w-5 text-yellow-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">Debug Information</h3>
<h3 className="text-sm font-medium text-yellow-800">{t('debug.debugInformation')}</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>{debugInfo.message}</p>
<p className="mt-1">Total None detections: <strong>{debugInfo.total_none_detections}</strong></p>
<p className="mt-1">{t('debug.totalNoneDetections', { count: debugInfo.total_none_detections })}</p>
</div>
</div>
</div>
@@ -186,56 +189,56 @@ const Debug = () => {
{/* Filters */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Filters</h3>
<h3 className="text-lg font-medium text-gray-900 mb-4">{t('debug.filters')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Drone Type
{t('debug.droneType')}
</label>
<select
value={filters.drone_type}
onChange={(e) => handleFilterChange('drone_type', e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">All Types</option>
<option value="0">0 - None (Debug)</option>
<option value="1">1 - Unknown</option>
<option value="2">2 - Orlan</option>
<option value="3">3 - Zala</option>
<option value="4">4 - Eleron</option>
<option value="5">5 - Zala Lancet</option>
<option value="6">6 - Lancet</option>
<option value="7">7 - FPV CrossFire</option>
<option value="8">8 - FPV ELRS</option>
<option value="9">9 - Maybe Orlan</option>
<option value="10">10 - Maybe Zala</option>
<option value="11">11 - Maybe Lancet</option>
<option value="12">12 - Maybe Eleron</option>
<option value="13">13 - DJI</option>
<option value="14">14 - Supercam</option>
<option value="15">15 - Maybe Supercam</option>
<option value="16">16 - REB</option>
<option value="17">17 - Crypto Orlan</option>
<option value="18">18 - DJI Enterprise</option>
<option value="">{t('debug.allTypes')}</option>
<option value="0">{t('debug.droneTypeOptions.none')}</option>
<option value="1">{t('debug.droneTypeOptions.unknown')}</option>
<option value="2">{t('debug.droneTypeOptions.orlan')}</option>
<option value="3">{t('debug.droneTypeOptions.zala')}</option>
<option value="4">{t('debug.droneTypeOptions.eleron')}</option>
<option value="5">{t('debug.droneTypeOptions.zalaLancet')}</option>
<option value="6">{t('debug.droneTypeOptions.lancet')}</option>
<option value="7">{t('debug.droneTypeOptions.fpvCrossFire')}</option>
<option value="8">{t('debug.droneTypeOptions.fpvElrs')}</option>
<option value="9">{t('debug.droneTypeOptions.maybeOrlan')}</option>
<option value="10">{t('debug.droneTypeOptions.maybeZala')}</option>
<option value="11">{t('debug.droneTypeOptions.maybeLancet')}</option>
<option value="12">{t('debug.droneTypeOptions.maybeEleron')}</option>
<option value="13">{t('debug.droneTypeOptions.dji')}</option>
<option value="14">{t('debug.droneTypeOptions.supercam')}</option>
<option value="15">{t('debug.droneTypeOptions.maybeSupercam')}</option>
<option value="16">{t('debug.droneTypeOptions.reb')}</option>
<option value="17">{t('debug.droneTypeOptions.cryptoOrlan')}</option>
<option value="18">{t('debug.droneTypeOptions.djiEnterprise')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Device ID
{t('debug.deviceId')}
</label>
<input
type="number"
value={filters.device_id}
onChange={(e) => handleFilterChange('device_id', e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Filter by device ID"
placeholder={t('debug.filterByDeviceId')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Results per page
{t('debug.resultsPerPage')}
</label>
<select
value={filters.limit}
@@ -254,16 +257,16 @@ const Debug = () => {
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">
Debug Detections ({pagination.total || 0})
{t('debug.debugDetections', { count: pagination.total || 0 })}
</h3>
</div>
{debugData.length === 0 ? (
<div className="text-center py-12">
<BugAntIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No debug data</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('debug.noDebugData')}</h3>
<p className="mt-1 text-sm text-gray-500">
No detections found matching the current filters.
{t('debug.noDetectionsFound')}
</p>
</div>
) : (
@@ -273,25 +276,25 @@ const Debug = () => {
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
ID / Time
{t('debug.idTime')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Device
{t('debug.device')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Drone Type
{t('debug.droneType')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
RSSI / Freq
{t('debug.rssiFreq')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Threat Level
{t('debug.threatLevel')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Debug
{t('debug.debug')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
{t('debug.actions')}
</th>
</tr>
</thead>
@@ -320,7 +323,7 @@ const Debug = () => {
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{detection.rssi} dBm</div>
<div className="text-sm text-gray-500">{detection.freq} MHz</div>
<div className="text-sm text-gray-500">{formatFrequency(detection.freq)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{detection.threat_level ? (
@@ -345,7 +348,7 @@ const Debug = () => {
className="inline-flex items-center px-3 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
<DocumentTextIcon className="h-4 w-4 mr-1" />
{payloadLoading ? 'Loading...' : 'View Payload'}
{payloadLoading ? t('common.loading') : t('debug.viewPayload')}
</button>
</td>
</tr>
@@ -418,7 +421,7 @@ const Debug = () => {
<div className="flex items-center">
<DocumentTextIcon className="h-6 w-6 text-blue-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">
Raw Payload Data
{t('debug.payloadViewer.title')}
</h3>
</div>
<button
@@ -431,24 +434,24 @@ const Debug = () => {
{/* Detection Info */}
<div className="mt-4 bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-2">Detection Information</h4>
<h4 className="font-medium text-gray-900 mb-2">{t('alerts.detectionDetails')}</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Detection ID:</span>
<span className="text-gray-600">{t('debug.payloadViewer.detectionId')}</span>
<span className="ml-2 font-mono">{selectedPayload.id}</span>
</div>
<div>
<span className="text-gray-600">Device ID:</span>
<span className="text-gray-600">{t('debug.payloadViewer.deviceId')}</span>
<span className="ml-2 font-mono">{selectedPayload.deviceId}</span>
</div>
<div>
<span className="text-gray-600">Server Timestamp:</span>
<span className="text-gray-600">{t('debug.payloadViewer.timestamp')}</span>
<span className="ml-2 font-mono">
{format(new Date(selectedPayload.timestamp), 'yyyy-MM-dd HH:mm:ss')}
</span>
</div>
<div>
<span className="text-gray-600">Drone Type:</span>
<span className="text-gray-600">{t('debug.droneType')}</span>
<span className="ml-2 font-mono">{selectedPayload.processedData.drone_type}</span>
</div>
</div>
@@ -456,7 +459,7 @@ const Debug = () => {
{/* Processed Data */}
<div className="mt-4">
<h4 className="font-medium text-gray-900 mb-2">Processed Data</h4>
<h4 className="font-medium text-gray-900 mb-2">{t('debug.payloadViewer.processedData')}</h4>
<div className="bg-gray-100 rounded-lg p-4 font-mono text-sm overflow-x-auto">
<pre className="whitespace-pre-wrap">
{JSON.stringify(selectedPayload.processedData, null, 2)}
@@ -466,7 +469,7 @@ const Debug = () => {
{/* Raw Payload */}
<div className="mt-4">
<h4 className="font-medium text-gray-900 mb-2">Raw Payload from Detector</h4>
<h4 className="font-medium text-gray-900 mb-2">{t('debug.payloadViewer.rawPayload')}</h4>
{selectedPayload.rawPayload ? (
<div className="bg-black text-green-400 rounded-lg p-4 font-mono text-sm overflow-x-auto max-h-96 overflow-y-auto">
<pre className="whitespace-pre-wrap">
@@ -493,7 +496,7 @@ const Debug = () => {
onClick={closePayloadModal}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Close
{t('common.close')}
</button>
<button
onClick={() => {
@@ -502,7 +505,7 @@ const Debug = () => {
}}
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Copy to Clipboard
{t('common.copy')}
</button>
</div>
</div>

View File

@@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import api from '../services/api';
import { format } from 'date-fns';
import { formatFrequency } from '../utils/formatFrequency';
import { useTranslation } from '../utils/tempTranslations';
import {
MagnifyingGlassIcon,
FunnelIcon,
@@ -8,6 +10,7 @@ import {
} from '@heroicons/react/24/outline';
const Detections = () => {
const { t } = useTranslation();
const [detections, setDetections] = useState([]);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState({});
@@ -38,8 +41,8 @@ const Detections = () => {
const response = await api.get(`/detections?${params}`);
console.log('✅ Detections response:', response.data);
setDetections(response.data.detections || []);
setPagination(response.data.pagination || {});
setDetections(response.data.data?.detections || []);
setPagination(response.data.data?.pagination || {});
} catch (error) {
console.error('❌ Error fetching detections:', error);
setDetections([]); // Ensure detections is always an array
@@ -80,10 +83,10 @@ const Detections = () => {
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">
Drone Detections
{t('detections.title')}
</h3>
<p className="mt-1 text-sm text-gray-500">
History of all drone detections from your devices
{t('detections.description')}
</p>
</div>
<button
@@ -91,7 +94,7 @@ const Detections = () => {
className="btn btn-secondary flex items-center space-x-2"
>
<FunnelIcon className="h-4 w-4" />
<span>Filters</span>
<span>{t('detections.filters')}</span>
</button>
</div>
@@ -101,12 +104,12 @@ const Detections = () => {
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Device ID
{t('detections.deviceId')}
</label>
<input
type="number"
type="text"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Device ID"
placeholder={t('detections.deviceIdPlaceholder')}
value={filters.device_id}
onChange={(e) => handleFilterChange('device_id', e.target.value)}
/>
@@ -114,12 +117,12 @@ const Detections = () => {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Drone ID
{t('detections.droneId')}
</label>
<input
type="number"
type="text"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Drone ID"
placeholder={t('detections.droneIdPlaceholder')}
value={filters.drone_id}
onChange={(e) => handleFilterChange('drone_id', e.target.value)}
/>
@@ -127,7 +130,7 @@ const Detections = () => {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date
{t('detections.startDate')}
</label>
<input
type="datetime-local"
@@ -139,7 +142,7 @@ const Detections = () => {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date
{t('detections.endDate')}
</label>
<input
type="datetime-local"
@@ -155,7 +158,7 @@ const Detections = () => {
onClick={clearFilters}
className="btn btn-secondary"
>
Clear Filters
{t('detections.clearFilters')}
</button>
</div>
</div>
@@ -173,14 +176,14 @@ const Detections = () => {
<table className="table">
<thead>
<tr>
<th>Device</th>
<th>Drone ID</th>
<th>Type</th>
<th>Frequency</th>
<th>RSSI</th>
<th>Location</th>
<th>Detected At</th>
<th>Actions</th>
<th>{t('detections.device')}</th>
<th>{t('detections.droneId')}</th>
<th>{t('detections.type')}</th>
<th>{t('detections.frequency')}</th>
<th>{t('detections.rssi')}</th>
<th>{t('detections.location')}</th>
<th>{t('detections.detectedAt')}</th>
<th>{t('detections.actions')}</th>
</tr>
</thead>
<tbody>
@@ -213,7 +216,7 @@ const Detections = () => {
</td>
<td>
<span className="text-sm text-gray-900">
{detection.freq} MHz
{formatFrequency(detection.freq)}
</span>
</td>
<td>
@@ -229,7 +232,7 @@ const Detections = () => {
{detection.device?.location_description ||
(detection.geo_lat && detection.geo_lon ?
`${detection.geo_lat}, ${detection.geo_lon}` :
'Unknown')}
t('detections.unknown'))}
</div>
</td>
<td>
@@ -260,9 +263,9 @@ const Detections = () => {
{detections.length === 0 && !loading && (
<div className="text-center py-12">
<MagnifyingGlassIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No detections found</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('detections.noDetections')}</h3>
<p className="mt-1 text-sm text-gray-500">
Try adjusting your search filters.
{t('detections.tryAdjustingFilters')}
</p>
</div>
)}
@@ -276,29 +279,29 @@ const Detections = () => {
disabled={filters.offset === 0}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Previous
{t('detections.previous')}
</button>
<button
onClick={() => handlePageChange(filters.offset + filters.limit)}
disabled={filters.offset + filters.limit >= pagination.total}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Next
{t('detections.next')}
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing{' '}
{t('detections.showing')} {' '}
<span className="font-medium">{filters.offset + 1}</span>
{' '}to{' '}
{' '}{t('detections.to')}{' '}
<span className="font-medium">
{Math.min(filters.offset + filters.limit, pagination.total)}
</span>
{' '}of{' '}
{' '}{t('detections.of')}{' '}
<span className="font-medium">{pagination.total}</span>
{' '}results
{' '}{t('detections.results')}
</p>
</div>
@@ -309,14 +312,14 @@ const Detections = () => {
disabled={filters.offset === 0}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
Previous
{t('detections.previous')}
</button>
<button
onClick={() => handlePageChange(filters.offset + filters.limit)}
disabled={filters.offset + filters.limit >= pagination.total}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
Next
{t('detections.next')}
</button>
</nav>
</div>

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../services/api';
import { format } from 'date-fns';
import { t } from '../utils/tempTranslations';
import {
PlusIcon,
PencilIcon,
@@ -62,7 +63,7 @@ const Devices = () => {
};
const handleRejectDevice = async (deviceId) => {
if (window.confirm('Are you sure you want to reject this device?')) {
if (window.confirm(t('devices.confirmReject'))) {
try {
await api.post(`/devices/${deviceId}/approve`, { approved: false });
fetchDevices();
@@ -72,7 +73,7 @@ const Devices = () => {
alert('Your session has expired. Please log in again.');
return;
}
alert('Error rejecting device: ' + (error.response?.data?.message || error.message));
alert(t('devices.errorRejecting') + ' ' + (error.response?.data?.message || error.message));
}
}
};
@@ -102,12 +103,12 @@ const Devices = () => {
};
const handleDeleteDevice = async (deviceId) => {
if (window.confirm('Are you sure you want to deactivate this device?')) {
if (window.confirm(t('devices.confirmDelete'))) {
try {
await api.delete(`/devices/${deviceId}`);
fetchDevices();
} catch (error) {
console.error('Error deleting device:', error);
console.error(t('devices.errorDeleting'), error);
}
}
};
@@ -124,13 +125,13 @@ const Devices = () => {
};
const getSignalStrength = (lastHeartbeat) => {
if (!lastHeartbeat) return 'Unknown';
if (!lastHeartbeat) return t('devices.unknown');
const timeSince = (new Date() - new Date(lastHeartbeat)) / 1000 / 60; // minutes
if (timeSince < 5) return 'Strong';
if (timeSince < 15) return 'Good';
if (timeSince < 60) return 'Weak';
return 'Lost';
if (timeSince < 5) return t('devices.signalStrong');
if (timeSince < 15) return t('devices.signalGood');
if (timeSince < 60) return t('devices.signalWeak');
return t('devices.signalLost');
};
const filteredDevices = devices.filter(device => {
@@ -145,6 +146,7 @@ const Devices = () => {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
<span className="ml-4 text-gray-600">{t('devices.loading')}</span>
</div>
);
}
@@ -154,13 +156,13 @@ const Devices = () => {
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">
Devices
{t('devices.title')}
</h3>
<p className="mt-1 text-sm text-gray-500">
Manage your drone detection devices
{t('devices.description')}
{pendingCount > 0 && (
<span className="ml-2 px-2 py-1 text-xs font-medium bg-yellow-200 text-yellow-800 rounded-full">
{pendingCount} pending approval
{pendingCount} {t('devices.pendingApproval')}
</span>
)}
</p>
@@ -170,7 +172,7 @@ const Devices = () => {
className="btn btn-primary flex items-center space-x-2"
>
<PlusIcon className="h-4 w-4" />
<span>Add Device</span>
<span>{t('devices.addDevice')}</span>
</button>
</div>
@@ -185,7 +187,7 @@ const Devices = () => {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
All Devices ({devices.length})
{t('devices.allDevices')} ({devices.length})
</button>
<button
onClick={() => setFilter('approved')}
@@ -195,7 +197,7 @@ const Devices = () => {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Approved ({devices.filter(d => d.is_approved).length})
{t('devices.approved')} ({devices.filter(d => d.is_approved).length})
</button>
<button
onClick={() => setFilter('pending')}
@@ -205,7 +207,7 @@ const Devices = () => {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Pending Approval ({pendingCount})
{t('devices.pendingApprovalTab')} ({pendingCount})
{pendingCount > 0 && (
<span className="absolute -top-1 -right-1 h-2 w-2 bg-yellow-400 rounded-full"></span>
)}
@@ -226,11 +228,11 @@ const Devices = () => {
device.stats?.status === 'online' ? 'bg-green-400' : 'bg-red-400'
}`} />
<h4 className="text-lg font-medium text-gray-900">
{device.name || `Device ${device.id}`}
{device.name || `${t('devices.device')} ${device.id}`}
</h4>
{!device.is_approved && (
<span className="px-2 py-1 text-xs font-medium bg-yellow-200 text-yellow-800 rounded-full">
Needs Approval
{t('devices.needsApproval')}
</span>
)}
</div>
@@ -252,25 +254,25 @@ const Devices = () => {
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Status</span>
<span className="text-sm text-gray-500">{t('devices.status')}</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
getStatusColor(device.stats?.status)
}`}>
{device.stats?.status || 'Unknown'}
{device.stats?.status ? t(`devices.${device.stats.status}`) : t('devices.unknown')}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Approval</span>
<span className="text-sm text-gray-500">{t('devices.approval')}</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
device.is_approved ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{device.is_approved ? 'Approved' : 'Pending'}
{device.is_approved ? t('devices.approved') : t('devices.pending')}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Device ID</span>
<span className="text-sm text-gray-500">{t('devices.deviceId')}</span>
<span className="text-sm font-medium text-gray-900">
{device.id}
</span>
@@ -278,7 +280,7 @@ const Devices = () => {
{device.location_description && (
<div className="flex items-start justify-between">
<span className="text-sm text-gray-500">Location</span>
<span className="text-sm text-gray-500">{t('devices.location')}</span>
<span className="text-sm text-gray-900 text-right">
{device.location_description}
</span>
@@ -287,7 +289,7 @@ const Devices = () => {
{(device.geo_lat && device.geo_lon) && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Coordinates</span>
<span className="text-sm text-gray-500">{t('devices.coordinates')}</span>
<span className="text-sm text-gray-900">
{device.geo_lat}, {device.geo_lon}
</span>
@@ -295,7 +297,7 @@ const Devices = () => {
)}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Signal</span>
<span className="text-sm text-gray-500">{t('devices.signal')}</span>
<span className="text-sm text-gray-900">
{getSignalStrength(device.last_heartbeat)}
</span>
@@ -303,7 +305,7 @@ const Devices = () => {
{device.stats && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Detections (24h)</span>
<span className="text-sm text-gray-500">{t('devices.detections24h')}</span>
<span className={`text-sm font-medium ${
device.stats.detections_24h > 0 ? 'text-red-600' : 'text-green-600'
}`}>
@@ -314,7 +316,7 @@ const Devices = () => {
{device.last_heartbeat && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Last Seen</span>
<span className="text-sm text-gray-500">{t('devices.lastSeen')}</span>
<span className="text-sm text-gray-900">
{format(new Date(device.last_heartbeat), 'MMM dd, HH:mm')}
</span>
@@ -323,7 +325,7 @@ const Devices = () => {
{device.firmware_version && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Firmware</span>
<span className="text-sm text-gray-500">{t('devices.firmware')}</span>
<span className="text-sm text-gray-900">
{device.firmware_version}
</span>
@@ -339,13 +341,13 @@ const Devices = () => {
onClick={() => handleApproveDevice(device.id)}
className="flex-1 text-xs bg-green-100 text-green-700 py-2 px-3 rounded hover:bg-green-200 transition-colors font-medium"
>
Approve Device
{t('devices.approveDevice')}
</button>
<button
onClick={() => handleRejectDevice(device.id)}
className="flex-1 text-xs bg-red-100 text-red-700 py-2 px-3 rounded hover:bg-red-200 transition-colors font-medium"
>
Reject
{t('devices.reject')}
</button>
</div>
) : null}
@@ -354,13 +356,13 @@ const Devices = () => {
onClick={() => handleViewDetails(device)}
className="flex-1 text-xs bg-gray-100 text-gray-700 py-2 px-3 rounded hover:bg-gray-200 transition-colors"
>
View Details
{t('devices.viewDetails')}
</button>
<button
onClick={() => handleViewOnMap(device)}
className="flex-1 text-xs bg-primary-100 text-primary-700 py-2 px-3 rounded hover:bg-primary-200 transition-colors"
>
View on Map
{t('devices.viewOnMap')}
</button>
</div>
</div>
@@ -373,14 +375,14 @@ const Devices = () => {
<div className="text-center py-12">
<ServerIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
No {filter === 'all' ? '' : filter} devices
{filter === 'all' ? t('devices.noDevices') : `${t('devices.noDevicesFiltered').replace('the current filter', filter)}`}
</h3>
<p className="mt-1 text-sm text-gray-500">
{filter === 'pending'
? 'No devices are currently pending approval.'
? t('devices.noDevicesPending')
: filter === 'approved'
? 'No devices have been approved yet.'
: 'No devices match the current filter.'
? t('devices.noDevicesApproved')
: t('devices.noDevicesFiltered')
}
</p>
</div>
@@ -389,9 +391,9 @@ const Devices = () => {
{devices.length === 0 && (
<div className="text-center py-12">
<ServerIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No devices</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('devices.noDevices')}</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by adding your first drone detection device.
{t('devices.noDevicesDescription')}
</p>
<div className="mt-6">
<button
@@ -424,7 +426,7 @@ const Devices = () => {
<div className="mt-3">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">
Device Details
{t('devices.deviceDetails')}
</h3>
<button
onClick={() => setShowDetailsModal(false)}
@@ -436,64 +438,64 @@ const Devices = () => {
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="font-medium text-gray-700">Device ID:</span>
<span className="font-medium text-gray-700">{t('devices.deviceId')}:</span>
<span className="text-gray-900">{selectedDevice.id}</span>
</div>
<div className="flex justify-between">
<span className="font-medium text-gray-700">Name:</span>
<span className="text-gray-900">{selectedDevice.name || 'Unnamed'}</span>
<span className="font-medium text-gray-700">{t('devices.name')}:</span>
<span className="text-gray-900">{selectedDevice.name || t('devices.unnamed')}</span>
</div>
<div className="flex justify-between">
<span className="font-medium text-gray-700">Status:</span>
<span className="font-medium text-gray-700">{t('devices.status')}:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
getStatusColor(selectedDevice.stats?.status)
}`}>
{selectedDevice.stats?.status || 'Unknown'}
{selectedDevice.stats?.status ? t(`devices.${selectedDevice.stats.status}`) : t('devices.unknown')}
</span>
</div>
<div className="flex justify-between">
<span className="font-medium text-gray-700">Approved:</span>
<span className="font-medium text-gray-700">{t('devices.approved')}:</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
selectedDevice.is_approved ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{selectedDevice.is_approved ? 'Yes' : 'Pending'}
{selectedDevice.is_approved ? t('devices.yes') : t('devices.pending')}
</span>
</div>
{selectedDevice.location_description && (
<div className="flex justify-between">
<span className="font-medium text-gray-700">Location:</span>
<span className="font-medium text-gray-700">{t('devices.location')}:</span>
<span className="text-gray-900 text-right">{selectedDevice.location_description}</span>
</div>
)}
{(selectedDevice.geo_lat && selectedDevice.geo_lon) && (
<div className="flex justify-between">
<span className="font-medium text-gray-700">Coordinates:</span>
<span className="font-medium text-gray-700">{t('devices.coordinates')}:</span>
<span className="text-gray-900">{selectedDevice.geo_lat}, {selectedDevice.geo_lon}</span>
</div>
)}
{selectedDevice.last_heartbeat && (
<div className="flex justify-between">
<span className="font-medium text-gray-700">Last Heartbeat:</span>
<span className="font-medium text-gray-700">{t('devices.lastHeartbeat')}:</span>
<span className="text-gray-900">{format(new Date(selectedDevice.last_heartbeat), 'MMM dd, yyyy HH:mm')}</span>
</div>
)}
{selectedDevice.firmware_version && (
<div className="flex justify-between">
<span className="font-medium text-gray-700">Firmware:</span>
<span className="font-medium text-gray-700">{t('devices.firmware')}:</span>
<span className="text-gray-900">{selectedDevice.firmware_version}</span>
</div>
)}
{selectedDevice.stats && (
<div className="flex justify-between">
<span className="font-medium text-gray-700">Detections (24h):</span>
<span className="font-medium text-gray-700">{t('devices.detections24h')}:</span>
<span className={`font-medium ${
selectedDevice.stats.detections_24h > 0 ? 'text-red-600' : 'text-green-600'
}`}>
@@ -504,7 +506,7 @@ const Devices = () => {
{selectedDevice.created_at && (
<div className="flex justify-between">
<span className="font-medium text-gray-700">Created:</span>
<span className="font-medium text-gray-700">{t('alerts.created')}:</span>
<span className="text-gray-900">{format(new Date(selectedDevice.created_at), 'MMM dd, yyyy HH:mm')}</span>
</div>
)}
@@ -519,7 +521,7 @@ const Devices = () => {
}}
className="flex-1 bg-green-100 text-green-700 py-2 px-3 rounded hover:bg-green-200 transition-colors text-sm font-medium"
>
Approve
{t('devices.approve')}
</button>
)}
@@ -528,7 +530,7 @@ const Devices = () => {
onClick={() => handleViewOnMap(selectedDevice)}
className="flex-1 bg-primary-100 text-primary-700 py-2 px-3 rounded hover:bg-primary-200 transition-colors text-sm font-medium"
>
View on Map
{t('devices.viewOnMap')}
</button>
)}
@@ -536,7 +538,7 @@ const Devices = () => {
onClick={() => setShowDetailsModal(false)}
className="flex-1 bg-gray-100 text-gray-700 py-2 px-3 rounded hover:bg-gray-200 transition-colors text-sm font-medium"
>
Close
{t('devices.close')}
</button>
</div>
</div>
@@ -584,13 +586,21 @@ const DeviceModal = ({ device, onClose, onSave }) => {
await api.put(`/devices/${device.id}`, updateData);
} else {
// Create new device - include all fields, convert empty strings to null
// Create new device - include all fields, handle empty values properly
const createData = { ...formData };
// Convert empty strings to null for numeric fields, remove empty strings for optional string fields
Object.keys(createData).forEach(key => {
if (createData[key] === '') {
createData[key] = null;
if (['geo_lat', 'geo_lon', 'heartbeat_interval'].includes(key)) {
createData[key] = null;
} else if (['firmware_version', 'notes', 'location_description', 'name'].includes(key)) {
// Remove empty optional string fields instead of sending empty strings
delete createData[key];
}
}
});
await api.post('/devices', createData);
}
onSave();
@@ -639,13 +649,17 @@ const DeviceModal = ({ device, onClose, onSave }) => {
Device ID *
</label>
<input
type="number"
type="text"
name="id"
required
placeholder="e.g. device-001 or sensor-alpha-123"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.id}
onChange={handleChange}
/>
<p className="text-xs text-gray-500 mt-1">
Enter a unique identifier for the device (letters, numbers, dashes allowed)
</p>
</div>
)}

View File

@@ -4,6 +4,7 @@ import { useAuth } from '../contexts/AuthContext';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import api from '../services/api';
import { t } from '../utils/tempTranslations';
const Login = () => {
const [credentials, setCredentials] = useState({
@@ -42,7 +43,7 @@ const Login = () => {
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading...</p>
<p className="mt-4 text-gray-600">{t('common.loading')}</p>
</div>
</div>
);
@@ -91,16 +92,38 @@ const Login = () => {
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 bg-primary-600 rounded-lg flex items-center justify-center">
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
{/* Display tenant logo if available, otherwise show default icon */}
{tenantConfig?.branding?.logo_url ? (
<div className="mx-auto h-16 w-auto flex items-center justify-center">
<img
src={tenantConfig.branding.logo_url}
alt={`${tenantConfig.tenant_name || 'Company'} Logo`}
className="h-16 w-auto max-w-48 object-contain"
onError={(e) => {
// Fallback to default icon if logo fails to load
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
{/* Hidden fallback icon */}
<div className="hidden mx-auto h-12 w-12 bg-primary-600 rounded-lg items-center justify-center">
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
</div>
) : (
<div className="mx-auto h-12 w-12 bg-primary-600 rounded-lg flex items-center justify-center">
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
)}
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
{tenantConfig?.tenant_name || 'Drone Detection System'}
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Sign in to your account
{t('auth.signIn')}
</p>
{tenantConfig?.auth_provider && (
<p className="mt-1 text-center text-xs text-gray-500">
@@ -115,7 +138,7 @@ const Login = () => {
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
Username or Email
{t('auth.username')}
</label>
<input
id="username"
@@ -123,7 +146,7 @@ const Login = () => {
type="text"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Username or Email"
placeholder={t('auth.username')}
value={credentials.username}
onChange={handleChange}
disabled={loading}
@@ -131,7 +154,7 @@ const Login = () => {
</div>
<div className="relative">
<label htmlFor="password" className="sr-only">
Password
{t('auth.password')}
</label>
<input
id="password"
@@ -139,7 +162,7 @@ const Login = () => {
type={showPassword ? 'text' : 'password'}
required
className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Password"
placeholder={t('auth.password')}
value={credentials.password}
onChange={handleChange}
disabled={loading}
@@ -167,7 +190,7 @@ const Login = () => {
{loading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
'Sign in'
t('auth.signIn')
)}
</button>
</div>

View File

@@ -6,6 +6,8 @@ import L from 'leaflet'; // For divIcon and other Leaflet utilities
import { useSocket } from '../contexts/SocketContext';
import api from '../services/api';
import { format } from 'date-fns';
import { formatFrequency } from '../utils/formatFrequency';
import { t } from '../utils/tempTranslations';
import {
ServerIcon,
ExclamationTriangleIcon,
@@ -147,7 +149,7 @@ const MapView = () => {
10: "DJI Mavic",
11: "DJI Phantom",
20: "DJI Mini",
99: "Unknown"
99: t('map.unknown')
});
}
};
@@ -324,10 +326,10 @@ const MapView = () => {
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">
Device Map
{t('map.title')}
</h3>
<p className="mt-1 text-sm text-gray-500">
Real-time view of all devices and drone detections
{t('map.description')}
</p>
</div>
@@ -339,7 +341,7 @@ const MapView = () => {
onChange={(e) => setShowDroneDetections(e.target.checked)}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Show Drone Detections</span>
<span className="text-sm text-gray-700">{t('map.showDroneDetections')}</span>
</label>
{droneDetectionHistory.length > 0 && (
@@ -411,7 +413,8 @@ const MapView = () => {
return Object.entries(detectionsByDetector).flatMap(([deviceId, detections]) => {
// Find the detector device for these detections
const detectorDevice = devices.find(d => d.id === parseInt(deviceId));
// Compare as strings since device IDs are stored as strings
const detectorDevice = devices.find(d => d.id === deviceId);
if (!detectorDevice || !detectorDevice.geo_lat || !detectorDevice.geo_lon) {
console.warn('MapView: No device found or missing coordinates for device_id:', deviceId);
return [];
@@ -608,51 +611,51 @@ const MapView = () => {
{/* Map Legend - Fixed positioning and visibility */}
<div className="absolute bottom-4 left-4 bg-white rounded-lg p-3 shadow-lg text-xs border border-gray-200 z-[1000] max-w-xs">
<div className="font-semibold mb-2 text-gray-800">Map Legend</div>
<div className="font-semibold mb-2 text-gray-800">{t('map.mapLegend')}</div>
<div className="space-y-1.5">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-green-500 rounded-full border border-green-600"></div>
<span className="text-gray-700">Device Online</span>
<span className="text-gray-700">{t('map.deviceOnline')}</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-red-500 rounded-full border border-red-600"></div>
<span className="text-gray-700">Device Detecting</span>
<span className="text-gray-700">{t('map.deviceDetecting')}</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-gray-500 rounded-full border border-gray-600"></div>
<span className="text-gray-700">Device Offline</span>
<span className="text-gray-700">{t('map.deviceOffline')}</span>
</div>
{showDroneDetections && (
<>
<div className="border-t border-gray-200 mt-2 pt-2">
<div className="font-medium text-gray-800 mb-1">Drone Detection Rings:</div>
<div className="text-xs text-gray-600 mb-2">Rings show estimated detection range based on RSSI</div>
<div className="font-medium text-gray-800 mb-1">{t('map.droneDetectionRings')}:</div>
<div className="text-xs text-gray-600 mb-2">{t('map.ringsDescription')}</div>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 border-2 border-red-600 rounded-full bg-red-600 bg-opacity-10"></div>
<span className="text-gray-700">Orlan/Military (Always Critical)</span>
<span className="text-gray-700">{t('map.orlanMilitary')}</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 border-2 border-red-500 rounded-full bg-red-500 bg-opacity-10"></div>
<span className="text-gray-700">Close Range (&gt;-60dBm)</span>
<span className="text-gray-700">{t('map.closeRange')}</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 border-2 border-orange-500 rounded-full bg-orange-500 bg-opacity-10"></div>
<span className="text-gray-700">Medium Range (-60 to -70dBm)</span>
<span className="text-gray-700">{t('map.mediumRange')}</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 border-2 border-green-500 rounded-full bg-green-500 bg-opacity-10"></div>
<span className="text-gray-700">Far Range (&lt;-70dBm)</span>
<span className="text-gray-700">{t('map.farRange')}</span>
</div>
<div className="border-t border-gray-200 mt-2 pt-2">
<div className="text-xs text-gray-600 mb-1">Multiple Drones at Same Detector:</div>
<div className="text-xs text-gray-500 mb-1"> Different colors to distinguish drones</div>
<div className="text-xs text-gray-500 mb-1"> Different dash patterns</div>
<div className="text-xs text-gray-500 mb-1"> Drone ID labels shown</div>
<div className="text-xs text-gray-500 mb-1"> Slight position offsets for visibility</div>
<div className="text-xs text-gray-600 mb-1">{t('map.multipleDrones')}:</div>
<div className="text-xs text-gray-500 mb-1">{t('map.differentColors')}</div>
<div className="text-xs text-gray-500 mb-1">{t('map.differentPatterns')}</div>
<div className="text-xs text-gray-500 mb-1">{t('map.droneLabels')}</div>
<div className="text-xs text-gray-500 mb-1">{t('map.positionOffsets')}</div>
</div>
<div className="text-xs text-gray-500 mt-2">
Ring size = estimated distance from detector
{t('map.ringSize')}
</div>
</>
)}
@@ -664,7 +667,7 @@ const MapView = () => {
{/* Device List */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Device Status</h3>
<h3 className="text-lg font-medium text-gray-900">{t('map.deviceStatus')}</h3>
</div>
<div className="divide-y divide-gray-200">
{devices.map(device => {
@@ -740,7 +743,7 @@ const DevicePopup = ({ device, status, detections }) => (
</div>
{detections.slice(0, 3).map((detection, index) => (
<div key={index} className="text-xs text-gray-600">
Drone {detection.drone_id} {detection.freq}MHz {detection.rssi}dBm
Drone {detection.drone_id} {formatFrequency(detection.freq)} {detection.rssi}dBm
</div>
))}
</div>
@@ -786,14 +789,14 @@ const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-red-700 flex items-center space-x-1">
<span>🚨</span>
<span>Drone Detection Details</span>
<span>{t('map.droneDetectionDetails')}</span>
</h4>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
age < 1 ? 'bg-red-100 text-red-800' :
age < 3 ? 'bg-orange-100 text-orange-800' :
'bg-gray-100 text-gray-800'
}`}>
{age < 1 ? 'LIVE' : `${Math.round(age)}m ago`}
{ age < 1 ? t('map.live') : `${Math.round(age)}m ago`}
</span>
</div>
@@ -802,11 +805,11 @@ const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory
<div className="bg-gray-50 rounded-lg p-2">
<div className="grid grid-cols-2 gap-2">
<div>
<span className="font-medium text-gray-700">Drone ID:</span>
<span className="font-medium text-gray-700">{t('map.droneId')}:</span>
<div className="text-gray-900 font-mono">{detection.drone_id}</div>
</div>
<div>
<span className="font-medium text-gray-700">Type:</span>
<span className="font-medium text-gray-700">{t('map.type')}:</span>
<div className="text-gray-900">
{droneTypes[detection.drone_type] || `Type ${detection.drone_type}`}
</div>
@@ -820,7 +823,7 @@ const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory
<div className="grid grid-cols-2 gap-2 mt-2">
<div>
<span className="font-medium text-gray-700">RSSI:</span>
<span className="font-medium text-gray-700">{t('map.rssi')}:</span>
<div className={`font-mono ${
detection.rssi > -50 ? 'text-red-600' :
detection.rssi > -70 ? 'text-orange-600' :
@@ -830,18 +833,18 @@ const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory
</div>
</div>
<div>
<span className="font-medium text-gray-700">Frequency:</span>
<div className="text-gray-900">{detection.freq}MHz</div>
<span className="font-medium text-gray-700">{t('map.frequency')}:</span>
<div className="text-gray-900">{formatFrequency(detection.freq)}</div>
</div>
</div>
</div>
{/* Detection Timeline */}
<div className="border-t border-gray-200 pt-2">
<span className="font-medium text-gray-700 block mb-2">Detection Timeline:</span>
<span className="font-medium text-gray-700 block mb-2">{t('map.detectionTimeline')}:</span>
<div className="text-xs space-y-1">
<div className="flex justify-between">
<span className="text-gray-600">First detected:</span>
<span className="text-gray-600">{t('map.firstDetected')}:</span>
<span className="font-mono text-gray-900">
{(() => {
const timestamp = firstDetection.device_timestamp || firstDetection.timestamp || firstDetection.server_timestamp;
@@ -852,12 +855,12 @@ const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory
} catch (e) {
console.warn('Invalid firstDetection timestamp:', timestamp, e);
}
return 'Unknown';
return t('map.unknown');
})()}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Latest detection:</span>
<span className="text-gray-600">{t('map.latestDetection')}:</span>
<span className="font-mono text-gray-900">
{(() => {
const timestamp = detection.device_timestamp || detection.timestamp || detection.server_timestamp;
@@ -868,13 +871,13 @@ const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory
} catch (e) {
console.warn('Invalid detection timestamp:', timestamp, e);
}
return 'Unknown';
return t('map.unknown');
})()}
</span>
</div>
{droneHistory.length > 1 && (
<div className="flex justify-between">
<span className="text-gray-600">Total detections:</span>
<span className="text-gray-600">{t('map.totalDetections')}:</span>
<span className="font-medium text-gray-900">{droneHistory.length}</span>
</div>
)}
@@ -884,7 +887,7 @@ const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory
{/* Movement Analysis */}
{movementTrend && (
<div className="border-t border-gray-200 pt-2">
<span className="font-medium text-gray-700 block mb-2">Movement Analysis:</span>
<span className="font-medium text-gray-700 block mb-2">{t('map.movementAnalysis')}:</span>
<div className="text-xs space-y-2">
<div className={`px-2 py-1 rounded ${
movementTrend.trend === 'APPROACHING' ? 'bg-red-100 text-red-800' :
@@ -892,19 +895,19 @@ const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory
'bg-yellow-100 text-yellow-800'
}`}>
<div className="font-medium">
{movementTrend.trend === 'APPROACHING' ? '⚠️ APPROACHING' :
movementTrend.trend === 'RETREATING' ? '✅ RETREATING' :
'➡️ STABLE POSITION'}
{movementTrend.trend === 'APPROACHING' ? `⚠️ ${t('map.approaching')}` :
movementTrend.trend === 'RETREATING' ? `${t('map.retreating')}` :
`➡️ ${t('map.stablePosition')}`}
</div>
<div className="mt-1">
RSSI change: {movementTrend.change > 0 ? '+' : ''}{typeof movementTrend.change === 'number' ? movementTrend.change.toFixed(1) : 'N/A'}dB
over {typeof movementTrend.duration === 'number' ? movementTrend.duration.toFixed(1) : 'N/A'} minutes
{t('map.rssiChange')}: {movementTrend.change > 0 ? '+' : ''}{typeof movementTrend.change === 'number' ? movementTrend.change.toFixed(1) : 'N/A'}dB
{t('map.over')} {typeof movementTrend.duration === 'number' ? movementTrend.duration.toFixed(1) : 'N/A'} {t('map.minutes')}
</div>
</div>
{/* Signal Strength History Graph (simplified) */}
<div className="bg-gray-50 rounded p-2">
<div className="text-gray-600 mb-1">Signal Strength Trend:</div>
<div className="text-gray-600 mb-1">{t('map.signalStrengthTrend')}:</div>
<div className="flex items-end space-x-1 h-8">
{droneHistory.slice(0, 8).reverse().map((hist, idx) => {
const height = Math.max(10, Math.min(32, (hist.rssi + 100) / 2)); // Scale -100 to 0 dBm to 10-32px
@@ -932,7 +935,7 @@ const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory
);
})}
</div>
<div className="text-xs text-gray-500 mt-1">Last 8 detections (oldest to newest)</div>
<div className="text-xs text-gray-500 mt-1">{t('map.lastDetections')}</div>
</div>
</div>
</div>
@@ -940,26 +943,26 @@ const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory
{/* Current Detection Details */}
<div className="border-t border-gray-200 pt-2">
<span className="font-medium text-gray-700 block mb-2">Current Detection:</span>
<span className="font-medium text-gray-700 block mb-2">{t('map.currentDetection')}:</span>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-gray-600">Confidence:</span>
<span className="text-gray-600">{t('map.confidence')}:</span>
<div className="text-gray-900">
{typeof detection.confidence_level === 'number' ? (detection.confidence_level * 100).toFixed(0) : 'N/A'}%
</div>
</div>
<div>
<span className="text-gray-600">Duration:</span>
<span className="text-gray-600">{t('map.duration')}:</span>
<div className="text-gray-900">
{typeof detection.signal_duration === 'number' ? (detection.signal_duration / 1000).toFixed(1) : 'N/A'}s
</div>
</div>
<div>
<span className="text-gray-600">Detector:</span>
<div className="text-gray-900">Device {detection.device_id}</div>
<span className="text-gray-600">{t('map.detector')}:</span>
<div className="text-gray-900">{t('map.deviceName')} {detection.device_id}</div>
</div>
<div>
<span className="text-gray-600">Location:</span>
<span className="text-gray-600">{t('map.location')}:</span>
<div className="text-gray-900 font-mono">
{typeof detection.geo_lat === 'number' ? detection.geo_lat.toFixed(4) : 'N/A'}, {typeof detection.geo_lon === 'number' ? detection.geo_lon.toFixed(4) : 'N/A'}
</div>
@@ -970,7 +973,7 @@ const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory
{/* Legacy movement analysis from detection */}
{detection.movement_analysis && (
<div className="border-t border-gray-200 pt-2">
<span className="font-medium text-gray-700 block mb-1">Real-time Analysis:</span>
<span className="font-medium text-gray-700 block mb-1">{t('map.realTimeAnalysis')}:</span>
<div className="text-xs space-y-1">
<div className={`px-2 py-1 rounded ${
detection.movement_analysis.alertLevel >= 3 ? 'bg-red-100 text-red-800' :
@@ -983,13 +986,15 @@ const DroneDetectionPopup = ({ detection, age, droneTypes, droneDetectionHistory
{detection.movement_analysis.rssiTrend && (
<div className="flex items-center space-x-2 mt-1">
<span className="text-gray-600">Instant trend:</span>
<span className="text-gray-600">{t('map.instantTrend')}:</span>
<span className={`font-medium ${
detection.movement_analysis.rssiTrend.trend === 'STRENGTHENING' ? 'text-red-600' :
detection.movement_analysis.rssiTrend.trend === 'WEAKENING' ? 'text-green-600' :
'text-gray-600'
}`}>
{detection.movement_analysis.rssiTrend.trend}
{detection.movement_analysis.rssiTrend.trend === 'STRENGTHENING' ? t('map.strengthening') :
detection.movement_analysis.rssiTrend.trend === 'WEAKENING' ? t('map.weakening') :
detection.movement_analysis.rssiTrend.trend}
{detection.movement_analysis.rssiTrend.change !== 0 && (
<span className="ml-1">
({detection.movement_analysis.rssiTrend.change > 0 ? '+' : ''}{typeof detection.movement_analysis.rssiTrend.change === 'number' ? detection.movement_analysis.rssiTrend.change.toFixed(1) : 'N/A'}dB)

View File

@@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react';
import { Navigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useTranslation } from '../utils/tempTranslations';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import api from '../services/api';
const Register = () => {
const { t } = useTranslation();
const [formData, setFormData] = useState({
username: '',
email: '',
@@ -31,11 +33,11 @@ const Register = () => {
// Security check: If registration is not enabled, show error
if (!response.data.data?.features?.registration) {
toast.error('Registration is not enabled for this tenant');
toast.error(t('register.registrationDisabled'));
}
} catch (error) {
console.error('Failed to fetch tenant config:', error);
toast.error('Failed to load authentication configuration');
toast.error(t('register.configLoadFailed'));
} finally {
setConfigLoading(false);
}
@@ -124,38 +126,38 @@ const Register = () => {
// Validation
if (!formData.username || !formData.email || !formData.password) {
toast.error('Please fill in all required fields');
toast.error(t('register.fillAllFields'));
return;
}
if (formData.password !== formData.confirmPassword) {
toast.error('Passwords do not match');
toast.error(t('register.passwordsMismatch'));
return;
}
if (formData.password.length < 8) {
toast.error('Password must be at least 8 characters long');
toast.error(t('register.passwordTooShort'));
return;
}
// Strong password validation
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/;
if (!passwordRegex.test(formData.password)) {
toast.error('Password must contain at least one lowercase letter, one uppercase letter, and one number');
toast.error(t('register.passwordRequirements'));
return;
}
// Username validation
const usernameRegex = /^[a-zA-Z0-9._-]+$/;
if (!usernameRegex.test(formData.username)) {
toast.error('Username can only contain letters, numbers, dots, underscores, and hyphens');
toast.error(t('register.usernameInvalid'));
return;
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
toast.error('Please enter a valid email address');
toast.error(t('register.emailInvalid'));
return;
}
@@ -163,7 +165,7 @@ const Register = () => {
if (formData.phone_number) {
const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/;
if (!phoneRegex.test(formData.phone_number.replace(/[\s\-\(\)]/g, ''))) {
toast.error('Please enter a valid phone number');
toast.error(t('register.phoneInvalid'));
return;
}
}
@@ -199,11 +201,33 @@ const Register = () => {
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 bg-primary-600 rounded-lg flex items-center justify-center">
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
{/* Display tenant logo if available, otherwise show default icon */}
{tenantConfig?.branding?.logo_url ? (
<div className="mx-auto h-16 w-auto flex items-center justify-center">
<img
src={tenantConfig.branding.logo_url}
alt={`${tenantConfig.tenant_name || 'Company'} Logo`}
className="h-16 w-auto max-w-48 object-contain"
onError={(e) => {
// Fallback to default icon if logo fails to load
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
{/* Hidden fallback icon */}
<div className="hidden mx-auto h-12 w-12 bg-primary-600 rounded-lg items-center justify-center">
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
</div>
) : (
<div className="mx-auto h-12 w-12 bg-primary-600 rounded-lg flex items-center justify-center">
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
)}
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>

View File

@@ -0,0 +1,278 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { formatDistanceToNow } from 'date-fns';
import api from '../services/api';
const SecurityLogs = () => {
const { user, isAuthenticated } = useAuth();
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({
level: 'all',
eventType: 'all',
timeRange: '24h',
search: ''
});
const [pagination, setPagination] = useState({
page: 1,
limit: 50,
total: 0
});
useEffect(() => {
if (isAuthenticated) {
loadSecurityLogs();
}
}, [isAuthenticated, filters, pagination.page]);
const loadSecurityLogs = async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: pagination.page,
limit: pagination.limit,
...filters
});
const response = await api.get(`/security-logs?${params}`);
const data = response.data.data || response.data;
setLogs(data.logs || []);
setPagination(prev => ({
...prev,
total: data.total || 0
}));
} catch (err) {
console.error('Failed to load security logs:', err);
setError(err.response?.data?.message || err.message);
} finally {
setLoading(false);
}
};
const getLogLevelBadge = (level) => {
const styles = {
'critical': 'bg-red-500 text-white px-2 py-1 rounded text-xs font-semibold',
'high': 'bg-orange-500 text-white px-2 py-1 rounded text-xs font-semibold',
'medium': 'bg-yellow-500 text-black px-2 py-1 rounded text-xs font-semibold',
'low': 'bg-blue-500 text-white px-2 py-1 rounded text-xs font-semibold',
'info': 'bg-gray-500 text-white px-2 py-1 rounded text-xs font-semibold'
};
return styles[level] || styles.info;
};
const getEventTypeIcon = (eventType) => {
const icons = {
'failed_login': '🚫',
'successful_login': '✅',
'suspicious_activity': '⚠️',
'country_alert': '🌍',
'brute_force': '🔨',
'account_lockout': '🔒',
'password_reset': '🔄',
'admin_action': '👤'
};
return icons[eventType] || '📋';
};
const formatMetadata = (metadata) => {
if (!metadata) return '';
const items = [];
if (metadata.ip_address) items.push(`IP: ${metadata.ip_address}`);
if (metadata.country) items.push(`Country: ${metadata.country}`);
if (metadata.user_agent) items.push(`Agent: ${metadata.user_agent.substring(0, 50)}...`);
return items.join(' | ');
};
const totalPages = Math.ceil(pagination.total / pagination.limit);
// Don't render if user is not authenticated
if (!isAuthenticated) {
return (
<div className="p-6">
<div className="text-center py-8 text-gray-500">
Please log in to view security logs
</div>
</div>
);
}
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2 text-gray-900">Security Logs</h1>
<p className="text-gray-600">Monitor security events for your account</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="text-red-800">{error}</div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg shadow mb-6">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Filters</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Security Level</label>
<select
value={filters.level}
onChange={(e) => setFilters(prev => ({ ...prev, level: e.target.value }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Levels</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="info">Info</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Event Type</label>
<select
value={filters.eventType}
onChange={(e) => setFilters(prev => ({ ...prev, eventType: e.target.value }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Events</option>
<option value="failed_login">Failed Logins</option>
<option value="successful_login">Successful Logins</option>
<option value="suspicious_activity">Suspicious Activity</option>
<option value="country_alert">Country Alerts</option>
<option value="brute_force">Brute Force</option>
<option value="account_lockout">Account Lockouts</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Time Range</label>
<select
value={filters.timeRange}
onChange={(e) => setFilters(prev => ({ ...prev, timeRange: e.target.value }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
>
<option value="1h">Last Hour</option>
<option value="24h">Last 24 Hours</option>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
<option value="all">All Time</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Search</label>
<input
type="text"
placeholder="IP, username..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
{/* Security Logs Table */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900">Security Events</h3>
<span className="text-sm text-gray-500">
{pagination.total} total events
</span>
</div>
</div>
<div className="p-6">
{loading ? (
<div className="flex justify-center py-8">
<div className="text-gray-500">Loading security logs...</div>
</div>
) : logs.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No security logs found matching your criteria
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left p-3 text-sm font-medium text-gray-500 uppercase tracking-wider">Time</th>
<th className="text-left p-3 text-sm font-medium text-gray-500 uppercase tracking-wider">Level</th>
<th className="text-left p-3 text-sm font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th className="text-left p-3 text-sm font-medium text-gray-500 uppercase tracking-wider">Message</th>
<th className="text-left p-3 text-sm font-medium text-gray-500 uppercase tracking-wider">Details</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="p-3 text-sm">
<div>{new Date(log.timestamp).toLocaleString()}</div>
<div className="text-xs text-gray-500">
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
</div>
</td>
<td className="p-3">
<span className={getLogLevelBadge(log.level)}>
{log.level.toUpperCase()}
</span>
</td>
<td className="p-3">
<div className="flex items-center gap-2">
<span>{getEventTypeIcon(log.event_type)}</span>
<span className="text-sm">{log.event_type.replace('_', ' ').toUpperCase()}</span>
</div>
</td>
<td className="p-3 text-sm max-w-md">
<div className="truncate" title={log.message}>
{log.message}
</div>
</td>
<td className="p-3 text-xs text-gray-600 max-w-md">
<div className="truncate" title={formatMetadata(log.metadata)}>
{formatMetadata(log.metadata)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-between items-center mt-6">
<div className="text-sm text-gray-500">
Page {pagination.page} of {totalPages}
</div>
<div className="flex gap-2">
<button
onClick={() => setPagination(prev => ({ ...prev, page: Math.max(1, prev.page - 1) }))}
disabled={pagination.page === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded disabled:opacity-50 hover:bg-gray-50"
>
Previous
</button>
<button
onClick={() => setPagination(prev => ({ ...prev, page: Math.min(totalPages, prev.page + 1) }))}
disabled={pagination.page === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded disabled:opacity-50 hover:bg-gray-50"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default SecurityLogs;

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
import toast from 'react-hot-toast';
import { useTranslation } from '../utils/tempTranslations';
import {
CogIcon,
ShieldCheckIcon,
@@ -16,49 +17,50 @@ import {
} from '@heroicons/react/24/outline';
import { hasPermission, canAccessSettings } from '../utils/rbac';
// Define tabs outside component to ensure stability
const ALL_TABS = [
{
id: 'general',
name: 'General',
icon: CogIcon,
permission: 'tenant.view'
},
{
id: 'branding',
name: 'Branding',
icon: PaintBrushIcon,
permission: 'branding.view'
},
{
id: 'security',
name: 'Security',
icon: ShieldCheckIcon,
permission: 'security.view'
},
{
id: 'authentication',
name: 'Authentication',
icon: KeyIcon,
permission: 'auth.view'
},
{
id: 'users',
name: 'Users',
icon: UserGroupIcon,
permission: 'users.view'
},
];
const Settings = () => {
const { user } = useAuth();
const { t } = useTranslation();
const [tenantConfig, setTenantConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Define tabs with translations inside component after useTranslation hook
const allTabs = [
{
id: 'general',
name: t('settings.general'),
icon: CogIcon,
permission: 'tenant.view'
},
{
id: 'branding',
name: t('settings.branding'),
icon: PaintBrushIcon,
permission: 'branding.view'
},
{
id: 'security',
name: t('settings.security'),
icon: ShieldCheckIcon,
permission: 'security.view'
},
{
id: 'authentication',
name: t('settings.authentication'),
icon: KeyIcon,
permission: 'auth.view'
},
{
id: 'users',
name: t('settings.users'),
icon: UserGroupIcon,
permission: 'users.view'
}
];
// Calculate available tabs
const availableTabs = user?.role
? ALL_TABS.filter(tab => hasPermission(user.role, tab.permission))
? allTabs.filter(tab => hasPermission(user.role, tab.permission))
: [];
// Set active tab - default to first available or general
@@ -80,7 +82,7 @@ const Settings = () => {
setTenantConfig(response.data.data);
} catch (error) {
console.error('Failed to fetch tenant config:', error);
toast.error('Failed to load tenant settings');
toast.error(t('settings.failedToLoad'));
} finally {
setLoading(false);
}
@@ -90,6 +92,7 @@ const Settings = () => {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
<span className="ml-4 text-gray-600">{t('settings.loading')}</span>
</div>
);
}
@@ -99,9 +102,9 @@ const Settings = () => {
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<ShieldCheckIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">Access Denied</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('settings.accessDenied')}</h3>
<p className="mt-1 text-sm text-gray-500">
You don't have permission to access tenant settings.
{t('settings.noPermission')}
</p>
</div>
</div>
@@ -115,7 +118,7 @@ const Settings = () => {
<div className="border-b border-gray-200">
<div className="sm:flex sm:items-baseline">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Tenant Settings
{t('settings.title')}
</h3>
<div className="mt-4 sm:mt-0 sm:ml-10">
<nav className="-mb-px flex space-x-8">
@@ -165,31 +168,35 @@ const Settings = () => {
};
// General Settings Component
const GeneralSettings = ({ tenantConfig }) => (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">General Information</h3>
<div className="mt-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Tenant Name</label>
<p className="mt-1 text-sm text-gray-900">{tenantConfig?.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Tenant ID</label>
<p className="mt-1 text-sm text-gray-500 font-mono">{tenantConfig?.slug}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Authentication Provider</label>
<p className="mt-1 text-sm text-gray-900 uppercase">{tenantConfig?.auth_provider}</p>
const GeneralSettings = ({ tenantConfig }) => {
const { t } = useTranslation();
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('settings.generalInformation')}</h3>
<div className="mt-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">{t('settings.tenantName')}</label>
<p className="mt-1 text-sm text-gray-900">{tenantConfig?.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">{t('settings.tenantId')}</label>
<p className="mt-1 text-sm text-gray-500 font-mono">{tenantConfig?.slug}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">{t('settings.authenticationProvider')}</label>
<p className="mt-1 text-sm text-gray-900 uppercase">{tenantConfig?.auth_provider}</p>
</div>
</div>
</div>
</div>
</div>
);
);
};
// Branding Settings Component
const BrandingSettings = ({ tenantConfig, onRefresh }) => {
const { user } = useAuth();
const { t } = useTranslation();
const [branding, setBranding] = useState({
logo_url: '',
primary_color: '#3B82F6',
@@ -212,10 +219,10 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
setSaving(true);
try {
await api.put('/tenant/branding', branding);
toast.success('Branding updated successfully');
toast.success(t('settings.brandingUpdated'));
if (onRefresh) onRefresh();
} catch (error) {
toast.error('Failed to update branding');
toast.error(t('settings.brandingUpdateFailed'));
} finally {
setSaving(false);
}
@@ -252,11 +259,15 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
if (response.data.success) {
setBranding(prev => ({ ...prev, logo_url: response.data.data.logo_url }));
setLogoPreview(null);
// Clear the file input to allow selecting the same file again
event.target.value = '';
toast.success('Logo uploaded successfully');
if (onRefresh) onRefresh();
}
} catch (error) {
toast.error('Failed to upload logo');
// Clear the file input on error too
event.target.value = '';
} finally {
setUploading(false);
}
@@ -271,13 +282,40 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
}
};
const handleLogoRemove = async () => {
if (!branding.logo_url) return;
// Confirm removal
if (!window.confirm(t('settings.confirmRemoveLogo'))) {
return;
}
setUploading(true);
try {
const response = await api.delete('/tenant/logo');
if (response.data.success) {
setBranding(prev => ({ ...prev, logo_url: null }));
setLogoPreview(null);
toast.success(t('settings.logoRemoved'));
if (onRefresh) onRefresh();
}
} catch (error) {
console.error('Error removing logo:', error);
toast.error(t('settings.logoRemoveFailed'));
} finally {
setUploading(false);
}
};
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Branding & Appearance</h3>
<div className="mt-5 space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700">Company Name</label>
<label className="block text-sm font-medium text-gray-700">{t('settings.companyName')}</label>
<input
type="text"
value={branding.company_name}
@@ -292,16 +330,38 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
{/* Current logo display */}
{branding.logo_url && (
<div className="mb-4">
<img
src={branding.logo_url.startsWith('http') ? branding.logo_url : `${api.defaults.baseURL.replace('/api', '')}${branding.logo_url}`}
alt="Current logo"
className="h-16 w-auto object-contain border border-gray-200 rounded p-2"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
<p className="text-xs text-gray-500 mt-1">Current logo</p>
<div className="mb-4 p-4 border border-gray-200 rounded-lg bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex-1">
<img
src={branding.logo_url.startsWith('http') ? branding.logo_url : `${api.defaults.baseURL.replace('/api', '')}${branding.logo_url}`}
alt="Current logo"
className="h-16 w-auto object-contain border border-gray-200 rounded p-2 bg-white"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
<p className="text-xs text-gray-500 mt-1">Current logo</p>
</div>
<div className="flex space-x-2 ml-4">
<button
type="button"
onClick={() => document.getElementById('logo-file-input').click()}
disabled={!canEdit || uploading}
className="px-3 py-1.5 text-xs font-medium text-primary-700 bg-primary-100 rounded hover:bg-primary-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('settings.changeLogo')}
</button>
<button
type="button"
onClick={handleLogoRemove}
disabled={!canEdit || uploading}
className="px-3 py-1.5 text-xs font-medium text-red-700 bg-red-100 rounded hover:bg-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('settings.removeLogo')}
</button>
</div>
</div>
</div>
)}
@@ -309,6 +369,7 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
<div className="flex items-center space-x-4">
<div className="flex-1">
<input
id="logo-file-input"
type="file"
accept="image/*"
disabled={!canEdit || uploading}
@@ -318,7 +379,7 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
}}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 disabled:opacity-50"
/>
<p className="text-xs text-gray-500 mt-1">PNG, JPG up to 5MB</p>
<p className="text-xs text-gray-500 mt-1">PNG, JPG up to 5MB {branding.logo_url ? '• Click "' + t('settings.changeLogo') + '" to replace current logo' : ''}</p>
</div>
{uploading && (
@@ -343,7 +404,7 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
{/* Manual URL input as fallback */}
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700">Or enter logo URL manually</label>
<label className="block text-sm font-medium text-gray-700">{t('settings.logoUrl')}</label>
<input
type="url"
value={branding.logo_url}
@@ -357,7 +418,7 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Primary Color</label>
<label className="block text-sm font-medium text-gray-700">{t('settings.primaryColor')}</label>
<div className="mt-1 flex">
<input
type="color"
@@ -377,7 +438,7 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Secondary Color</label>
<label className="block text-sm font-medium text-gray-700">{t('settings.secondaryColor')}</label>
<div className="mt-1 flex">
<input
type="color"
@@ -404,7 +465,7 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
disabled={saving}
className="bg-primary-600 text-white px-4 py-2 rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Branding'}
{saving ? t('settings.saving') : t('settings.saveBranding')}
</button>
) : (
<div className="text-sm text-gray-500 py-2">
@@ -421,6 +482,7 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
// Placeholder components for other tabs
const SecuritySettings = ({ tenantConfig, onRefresh }) => {
const { user } = useAuth();
const { t } = useTranslation();
const [securitySettings, setSecuritySettings] = useState({
ip_restriction_enabled: false,
ip_whitelist: [],
@@ -489,11 +551,11 @@ const SecuritySettings = ({ tenantConfig, onRefresh }) => {
try {
console.log('🔒 Sending security settings:', securitySettings);
await api.put('/tenant/security', securitySettings);
toast.success('Security settings updated successfully');
toast.success(t('settings.securityUpdated'));
if (onRefresh) onRefresh();
} catch (error) {
console.error('Failed to update security settings:', error);
toast.error('Failed to update security settings');
toast.error(t('settings.securityUpdateFailed'));
} finally {
setSaving(false);
}
@@ -514,7 +576,7 @@ const SecuritySettings = ({ tenantConfig, onRefresh }) => {
disabled={saving || !canEdit}
className="bg-primary-600 text-white px-4 py-2 rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Changes'}
{saving ? t('settings.saving') : t('settings.saveChanges')}
</button>
</div>

View File

@@ -49,15 +49,69 @@ api.interceptors.request.use(
api.interceptors.response.use(
(response) => response,
(error) => {
console.log('🚨 API Error Response:', {
status: error.response?.status,
data: error.response?.data,
config: { url: error.config?.url, method: error.config?.method }
});
if (error.response?.status === 401 || error.response?.status === 403) {
// Check if it's a token-related error
const errorMessage = error.response?.data?.message || '';
if (errorMessage.includes('token') || errorMessage.includes('expired') || error.response?.status === 401) {
console.warn('🔐 Token expired or invalid - logging out');
// Token expired or invalid - remove token and let ProtectedRoute handle navigation
const errorData = error.response.data;
const errorCode = errorData?.errorCode || errorData?.error;
// Show user-friendly error message based on error type
let userMessage = errorData?.message || 'Authentication error';
// Categorize errors for better user experience
switch (errorCode) {
case 'TOKEN_EXPIRED':
userMessage = 'Your session has expired. Please log in again.';
break;
case 'INVALID_TOKEN':
userMessage = 'Invalid authentication. Please log in again.';
break;
case 'USER_NOT_FOUND':
userMessage = 'Your account was not found. Please contact support.';
break;
case 'ACCOUNT_INACTIVE':
userMessage = 'Your account has been deactivated. Please contact support.';
break;
case 'PERMISSION_DENIED':
userMessage = errorData.message; // Use the detailed permission message from backend
break;
default:
userMessage = errorData?.message || 'Authentication failed';
}
console.warn('🔐 Authentication/Authorization Error:', userMessage);
// Dispatch error event for UI notification
window.dispatchEvent(new CustomEvent('authError', {
detail: {
message: userMessage,
errorCode,
type: error.response.status === 403 ? 'permission' : 'auth',
userRole: errorData?.userRole,
requiredRoles: errorData?.requiredRoles
}
}));
// Only redirect to login for authentication errors, not permission errors
if (error.response.status === 401 || errorData?.redirectToLogin === true) {
console.warn('🔐 Redirecting to login page');
// Clear authentication data
localStorage.removeItem('token');
// Force a state update by dispatching a custom event
localStorage.removeItem('user');
// Dispatch logout event for components that need to react
window.dispatchEvent(new CustomEvent('auth-logout'));
// Redirect to login page
const currentPath = window.location.pathname;
if (currentPath !== '/login' && currentPath !== '/') {
window.location.href = '/login';
}
}
}
return Promise.reject(error);

View File

@@ -0,0 +1,48 @@
/**
* Format frequency value with appropriate units (MHz or GHz)
* @param {number} frequency - Frequency value in MHz
* @returns {string} Formatted frequency string with appropriate units
*/
export const formatFrequency = (frequency) => {
if (!frequency && frequency !== 0) {
return 'N/A';
}
const freq = parseFloat(frequency);
// Convert to GHz if frequency is 1000 MHz or higher
if (freq >= 1000) {
const ghz = freq / 1000;
// Show one decimal place for GHz if needed
return ghz % 1 === 0 ? `${ghz} GHz` : `${ghz.toFixed(1)} GHz`;
}
// Show MHz for frequencies below 1000 MHz
return freq % 1 === 0 ? `${freq} MHz` : `${freq.toFixed(1)} MHz`;
};
/**
* Get frequency display info (value and unit separately)
* @param {number} frequency - Frequency value in MHz
* @returns {object} Object with value and unit properties
*/
export const getFrequencyInfo = (frequency) => {
if (!frequency && frequency !== 0) {
return { value: 'N/A', unit: '' };
}
const freq = parseFloat(frequency);
if (freq >= 1000) {
const ghz = freq / 1000;
return {
value: ghz % 1 === 0 ? ghz : ghz.toFixed(1),
unit: 'GHz'
};
}
return {
value: freq % 1 === 0 ? freq : freq.toFixed(1),
unit: 'MHz'
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
const { Device, Tenant } = require('./server/models');
async function createStockholmDevice() {
try {
// Find the uamils-ab tenant
const tenant = await Tenant.findOne({ where: { slug: 'uamils-ab' } });
if (!tenant) {
console.log('❌ Tenant uamils-ab not found');
return;
}
// Check if device "1941875381" already exists (from your test packet)
const existingDevice = await Device.findOne({ where: { id: '1941875381' } });
if (existingDevice) {
console.log('✅ Test device already exists');
console.log(` ID: ${existingDevice.id}`);
console.log(` Name: ${existingDevice.name}`);
console.log(` Approved: ${existingDevice.is_approved}`);
console.log(` Active: ${existingDevice.is_active}`);
console.log(` Tenant: ${existingDevice.tenant_id}`);
return;
}
// Create test device with the ID from your packet
const testDevice = await Device.create({
id: '1941875381',
name: 'Test Device 1941875381',
type: 'drone_detector',
location: 'Test Location',
description: 'Test drone detector device',
is_approved: true,
is_active: true,
tenant_id: tenant.id,
coordinates: JSON.stringify({
latitude: 0,
longitude: 0
}),
config: JSON.stringify({
detection_range: 25000,
alert_threshold: 5000,
frequency_bands: ['2.4GHz', '5.8GHz'],
sensitivity: 'high'
})
});
console.log('✅ Test device created successfully');
console.log(` ID: ${testDevice.id}`);
console.log(` Name: ${testDevice.name}`);
console.log(` Tenant: ${testDevice.tenant_id}`);
console.log(` Approved: ${testDevice.is_approved}`);
} catch (error) {
console.error('❌ Error creating Stockholm device:', error.message);
}
}
createStockholmDevice()
.then(() => {
console.log('✅ Test device setup completed');
process.exit(0);
})
.catch(error => {
console.error('❌ Setup failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,17 @@
# Data Retention Service Environment Variables
# Database Configuration
DB_HOST=postgres
DB_PORT=5432
DB_NAME=drone_detection
DB_USER=postgres
DB_PASSWORD=your_secure_password
# Service Configuration
NODE_ENV=production
# Set to 'true' to run cleanup immediately on startup (useful for testing)
IMMEDIATE_CLEANUP=false
# Logging level
LOG_LEVEL=info

View File

@@ -0,0 +1,28 @@
# Data Retention Service
FROM node:18-alpine
# Create app directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install only production dependencies
RUN npm install --only=production && npm cache clean --force# Copy source code
COPY . .
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S retention -u 1001
# Change ownership
RUN chown -R retention:nodejs /app
USER retention
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD node healthcheck.js
# Start the service
CMD ["node", "index.js"]

View File

@@ -0,0 +1,312 @@
# Data Retention Service
A lightweight, standalone microservice responsible for automated data cleanup based on tenant retention policies.
## Overview
This service runs as a separate Docker container and performs the following functions:
- **Automated Cleanup**: Daily scheduled cleanup at 2:00 AM UTC
- **Tenant-Aware**: Respects individual tenant retention policies
- **Lightweight**: Minimal resource footprint (~64-128MB RAM)
- **Resilient**: Continues operation even if individual tenant cleanups fail
- **Logged**: Comprehensive logging and health monitoring
## Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Main Backend │ │ Data Retention │ │ PostgreSQL │
│ Container │ │ Service │ │ Database │
│ │ │ │ │ │
│ • API Endpoints │ │ • Cron Jobs │◄──►│ • tenant data │
│ • Business Logic│ │ • Data Cleanup │ │ • detections │
│ • Rate Limiting │ │ • Health Check │ │ • heartbeats │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
## Features
### 🕒 Scheduled Operations
- Runs daily at 2:00 AM UTC via cron job
- Configurable immediate cleanup for development/testing
- Graceful shutdown handling
### 🏢 Multi-Tenant Support
- Processes all active tenants
- Respects individual retention policies:
- `-1` = Unlimited retention (no cleanup)
- `N` = Delete data older than N days
- Default: 90 days if not specified
### 🧹 Data Cleanup
- **Drone Detections**: Historical detection records
- **Heartbeats**: Device connectivity logs
- **Security Logs**: Audit trail entries (if applicable)
### 📊 Monitoring & Health
- Built-in health checks for Docker
- Memory usage monitoring
- Cleanup statistics tracking
- Error logging with tenant context
## Configuration
### Environment Variables
```bash
# Database Connection
DB_HOST=postgres # Database host
DB_PORT=5432 # Database port
DB_NAME=drone_detection # Database name
DB_USER=postgres # Database user
DB_PASSWORD=password # Database password
# Service Settings
NODE_ENV=production # Environment mode
IMMEDIATE_CLEANUP=false # Run cleanup on startup
LOG_LEVEL=info # Logging level
```
### Docker Compose Integration
```yaml
data-retention:
build:
context: ./data-retention-service
container_name: drone-detection-data-retention
restart: unless-stopped
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: drone_detection
DB_USER: postgres
DB_PASSWORD: your_secure_password
depends_on:
postgres:
condition: service_healthy
deploy:
resources:
limits:
memory: 128M
```
## Usage
### Start with Docker Compose
```bash
# Start all services including data retention
docker-compose up -d
# Start only data retention service
docker-compose up -d data-retention
# View logs
docker-compose logs -f data-retention
```
### Manual Container Build
```bash
# Build the container
cd data-retention-service
docker build -t data-retention-service .
# Run the container
docker run -d \
--name data-retention \
--env-file .env \
--network drone-network \
data-retention-service
```
### Development Mode
```bash
# Install dependencies
npm install
# Run with immediate cleanup
IMMEDIATE_CLEANUP=true npm start
# Run in development mode
npm run dev
```
## Logging Output
### Startup
```
🗂️ Starting Data Retention Service...
📅 Environment: production
💾 Database: postgres:5432/drone_detection
✅ Database connection established
⏰ Scheduled cleanup: Daily at 2:00 AM UTC
✅ Data Retention Service started successfully
```
### Cleanup Operation
```
🧹 Starting data retention cleanup...
⏰ Cleanup started at: 2024-09-23T02:00:00.123Z
🏢 Found 5 active tenants to process
🧹 Cleaning tenant acme-corp - removing data older than 90 days
✅ Tenant acme-corp: Deleted 1250 detections, 4500 heartbeats, 89 logs
⏭️ Skipping tenant enterprise-unlimited - unlimited retention
✅ Data retention cleanup completed
⏱️ Duration: 2347ms
📊 Deleted: 2100 detections, 8900 heartbeats, 156 logs
```
### Health Monitoring
```
💚 Health Check - Uptime: 3600s, Memory: 45MB, Last Cleanup: 2024-09-23T02:00:00.123Z
```
## API Integration
The main backend provides endpoints to interact with retention policies:
```bash
# Get current tenant limits and retention info
GET /api/tenant/limits
# Preview what would be deleted
GET /api/tenant/data-retention/preview
```
## Error Handling
### Tenant-Level Errors
- Service continues if individual tenant cleanup fails
- Errors logged with tenant context
- Failed tenants skipped, others processed normally
### Service-Level Errors
- Database connection issues cause service restart
- Health checks detect and report issues
- Graceful shutdown on container stop signals
### Example Error Log
```
❌ Error cleaning tenant problematic-tenant: SequelizeTimeoutError: Query timeout
⚠️ Errors encountered: 1
- problematic-tenant: Query timeout
```
## Performance
### Resource Usage
- **Memory**: 64-128MB typical usage
- **CPU**: Minimal, only during cleanup operations
- **Storage**: Logs rotate automatically
- **Network**: Database queries only
### Cleanup Performance
- Batch operations for efficiency
- Indexed database queries on timestamp fields
- Parallel tenant processing where possible
- Configurable batch sizes for large datasets
## Security
### Database Access
- Read/write access only to required tables
- Connection pooling with limits
- Prepared statements prevent SQL injection
### Container Security
- Non-root user execution
- Minimal base image (node:18-alpine)
- No exposed ports
- Isolated network access
## Monitoring
### Health Checks
```bash
# Docker health check
docker exec data-retention node healthcheck.js
# Container status
docker-compose ps data-retention
# Service logs
docker-compose logs -f data-retention
```
### Metrics
- Cleanup duration and frequency
- Records deleted per tenant
- Memory usage over time
- Error rates and types
## Troubleshooting
### Common Issues
**Service won't start**
```bash
# Check database connectivity
docker-compose logs postgres
docker-compose logs data-retention
# Verify environment variables
docker-compose config
```
**Cleanup not running**
```bash
# Check cron schedule
docker exec data-retention ps aux | grep cron
# Force immediate cleanup
docker exec data-retention node -e "
const service = require('./index.js');
service.performCleanup();
"
```
**High memory usage**
```bash
# Check cleanup frequency
docker stats data-retention
# Review tenant data volumes
docker exec data-retention node -e "
const { getModels } = require('./database');
// Check tenant data sizes
"
```
### Configuration Validation
```bash
# Test database connection
docker exec data-retention node healthcheck.js
# Verify tenant policies
docker exec -it data-retention node -e "
const { getModels } = require('./database');
(async () => {
const { Tenant } = await getModels();
const tenants = await Tenant.findAll();
console.log(tenants.map(t => ({
slug: t.slug,
retention: t.features?.data_retention_days
})));
})();
"
```
## Migration from Integrated Service
If upgrading from a version where data retention was part of the main backend:
1. **Deploy new container**: Add data retention service to docker-compose.yml
2. **Verify operation**: Check logs for successful startup and database connection
3. **Remove old code**: The integrated service code is automatically disabled
4. **Monitor transition**: Ensure cleanup operations continue normally
The service is designed to be backward compatible and will work with existing tenant configurations without changes.

View File

@@ -0,0 +1,237 @@
/**
* Database connection and models for Data Retention Service
*/
const { Sequelize, DataTypes } = require('sequelize');
let sequelize;
let models = {};
/**
* Initialize database connection
*/
async function initializeDatabase() {
// Database connection
sequelize = new Sequelize(
process.env.DB_NAME || 'drone_detection',
process.env.DB_USER || 'postgres',
process.env.DB_PASSWORD || 'password',
{
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
dialect: 'postgres',
logging: process.env.NODE_ENV === 'development' ? console.log : false,
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
}
}
);
// Test connection
await sequelize.authenticate();
// Define models
defineModels();
return sequelize;
}
/**
* Define database models
*/
function defineModels() {
// Tenant model
models.Tenant = sequelize.define('Tenant', {
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4
},
slug: {
type: DataTypes.STRING(50),
unique: true,
allowNull: false
},
name: {
type: DataTypes.STRING(100),
allowNull: false
},
features: {
type: DataTypes.JSONB,
defaultValue: {}
},
is_active: {
type: DataTypes.BOOLEAN,
defaultValue: true
}
}, {
tableName: 'tenants',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
});
// DroneDetection model
models.DroneDetection = sequelize.define('DroneDetection', {
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4
},
tenant_id: {
type: DataTypes.UUID,
allowNull: false
},
device_id: {
type: DataTypes.STRING(50),
allowNull: false
},
server_timestamp: {
type: DataTypes.DATE,
allowNull: false
},
drone_type: {
type: DataTypes.INTEGER,
allowNull: true
},
rssi: {
type: DataTypes.FLOAT,
allowNull: true
},
frequency: {
type: DataTypes.FLOAT,
allowNull: true
}
}, {
tableName: 'drone_detections',
timestamps: false,
indexes: [
{
fields: ['tenant_id', 'server_timestamp']
},
{
fields: ['server_timestamp']
}
]
});
// Heartbeat model
models.Heartbeat = sequelize.define('Heartbeat', {
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4
},
tenant_id: {
type: DataTypes.UUID,
allowNull: false
},
device_id: {
type: DataTypes.STRING(50),
allowNull: false
},
timestamp: {
type: DataTypes.DATE,
allowNull: false
},
status: {
type: DataTypes.STRING(20),
defaultValue: 'online'
}
}, {
tableName: 'heartbeats',
timestamps: false,
indexes: [
{
fields: ['tenant_id', 'timestamp']
},
{
fields: ['timestamp']
}
]
});
// SecurityLog model - IMPORTANT: Security logs have different retention policies (much longer)
models.SecurityLog = sequelize.define('SecurityLog', {
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4
},
tenant_id: {
type: DataTypes.UUID,
allowNull: true
},
event_type: {
type: DataTypes.STRING(50),
allowNull: false
},
severity: {
type: DataTypes.STRING(20),
allowNull: false
},
username: {
type: DataTypes.STRING(100),
allowNull: true
},
ip_address: {
type: DataTypes.INET,
allowNull: true
},
country_code: {
type: DataTypes.STRING(2),
allowNull: true
},
message: {
type: DataTypes.TEXT,
allowNull: false
},
created_at: {
type: DataTypes.DATE,
allowNull: false
}
}, {
tableName: 'security_logs',
timestamps: false,
indexes: [
{
fields: ['tenant_id', 'created_at']
},
{
fields: ['event_type', 'created_at']
},
{
fields: ['ip_address', 'created_at']
}
]
});
}
/**
* Get models
*/
async function getModels() {
if (!sequelize) {
await initializeDatabase();
}
return models;
}
/**
* Close database connection
*/
async function closeDatabase() {
if (sequelize) {
await sequelize.close();
}
}
module.exports = {
initializeDatabase,
getModels,
closeDatabase,
sequelize: () => sequelize
};

View File

@@ -0,0 +1,21 @@
/**
* Health check for Data Retention Service
*/
const { getModels } = require('./database');
async function healthCheck() {
try {
// Check database connection
const { Tenant } = await getModels();
await Tenant.findOne({ limit: 1 });
console.log('Health check passed');
process.exit(0);
} catch (error) {
console.error('Health check failed:', error);
process.exit(1);
}
}
healthCheck();

View File

@@ -0,0 +1,432 @@
/**
* Data Retention Service
* Standalone microservice for automated data cleanup
*/
const cron = require('node-cron');
const { Op } = require('sequelize');
const http = require('http');
const url = require('url');
// Initialize database connection
const { initializeDatabase, getModels } = require('./database');
class DataRetentionService {
constructor() {
this.isRunning = false;
this.lastCleanup = null;
this.cleanupStats = {
totalRuns: 0,
totalDetectionsDeleted: 0,
totalHeartbeatsDeleted: 0,
totalLogsDeleted: 0,
lastRunDuration: 0,
errors: []
};
}
/**
* Start the data retention cleanup service
*/
async start() {
console.log('🗂️ Starting Data Retention Service...');
console.log(`📅 Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`💾 Database: ${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`);
try {
// Initialize database connection
await initializeDatabase();
console.log('✅ Database connection established');
// Schedule daily cleanup at 2:00 AM UTC
cron.schedule('0 2 * * *', async () => {
await this.performCleanup();
}, {
scheduled: true,
timezone: "UTC"
});
console.log('⏰ Scheduled cleanup: Daily at 2:00 AM UTC');
// Start metrics HTTP server
this.startMetricsServer();
// Run immediate cleanup in development or if IMMEDIATE_CLEANUP is set
if (process.env.NODE_ENV === 'development' || process.env.IMMEDIATE_CLEANUP === 'true') {
console.log('🧹 Running immediate cleanup...');
setTimeout(() => this.performCleanup(), 5000);
}
// Health check endpoint simulation
setInterval(() => {
this.logHealthStatus();
}, 60000); // Every minute
console.log('✅ Data Retention Service started successfully');
} catch (error) {
console.error('❌ Failed to start Data Retention Service:', error);
process.exit(1);
}
}
/**
* Perform cleanup for all tenants
*/
async performCleanup() {
if (this.isRunning) {
console.log('⏳ Data retention cleanup already running, skipping...');
return;
}
this.isRunning = true;
const startTime = Date.now();
try {
console.log('🧹 Starting data retention cleanup...');
console.log(`⏰ Cleanup started at: ${new Date().toISOString()}`);
const { Tenant, DroneDetection, Heartbeat, SecurityLog } = await getModels();
// Get all active tenants with their retention policies
const tenants = await Tenant.findAll({
attributes: ['id', 'slug', 'features'],
where: {
is_active: true
}
});
console.log(`🏢 Found ${tenants.length} active tenants to process`);
let totalDetectionsDeleted = 0;
let totalHeartbeatsDeleted = 0;
let totalLogsDeleted = 0;
let errors = [];
for (const tenant of tenants) {
try {
const result = await this.cleanupTenant(tenant);
totalDetectionsDeleted += result.detections;
totalHeartbeatsDeleted += result.heartbeats;
totalLogsDeleted += result.logs;
} catch (error) {
console.error(`❌ Error cleaning tenant ${tenant.slug}:`, error);
errors.push({
tenantSlug: tenant.slug,
error: error.message,
timestamp: new Date().toISOString()
});
}
}
const duration = Date.now() - startTime;
this.lastCleanup = new Date();
this.cleanupStats.totalRuns++;
this.cleanupStats.totalDetectionsDeleted += totalDetectionsDeleted;
this.cleanupStats.totalHeartbeatsDeleted += totalHeartbeatsDeleted;
this.cleanupStats.totalLogsDeleted += totalLogsDeleted;
this.cleanupStats.lastRunDuration = duration;
this.cleanupStats.errors = errors;
console.log('✅ Data retention cleanup completed');
console.log(`⏱️ Duration: ${duration}ms`);
console.log(`📊 Deleted: ${totalDetectionsDeleted} detections, ${totalHeartbeatsDeleted} heartbeats, ${totalLogsDeleted} logs`);
if (errors.length > 0) {
console.log(`⚠️ Errors encountered: ${errors.length}`);
errors.forEach(err => console.log(` - ${err.tenantSlug}: ${err.error}`));
}
} catch (error) {
console.error('❌ Data retention cleanup failed:', error);
this.cleanupStats.errors.push({
error: error.message,
timestamp: new Date().toISOString()
});
} finally {
this.isRunning = false;
}
}
/**
* Clean up data for a specific tenant
*/
async cleanupTenant(tenant) {
const retentionDays = tenant.features?.data_retention_days;
// Skip if unlimited retention (-1)
if (retentionDays === -1) {
console.log(`⏭️ Skipping tenant ${tenant.slug} - unlimited retention`);
return { detections: 0, heartbeats: 0, logs: 0 };
}
// Default to 90 days if not specified
const effectiveRetentionDays = retentionDays || 90;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - effectiveRetentionDays);
console.log(`🧹 Cleaning tenant ${tenant.slug} - removing operational data older than ${effectiveRetentionDays} days (before ${cutoffDate.toISOString()})`);
console.log(`📋 Note: Security logs and audit trails are preserved and not subject to automatic cleanup`);
const { DroneDetection, Heartbeat } = await getModels();
// Clean up drone detections (operational data)
const deletedDetections = await DroneDetection.destroy({
where: {
tenant_id: tenant.id,
server_timestamp: {
[Op.lt]: cutoffDate
}
}
});
// Clean up heartbeats (operational data)
const deletedHeartbeats = await Heartbeat.destroy({
where: {
tenant_id: tenant.id,
timestamp: {
[Op.lt]: cutoffDate
}
}
});
// Clean up security logs - MUCH LONGER retention (7 years for compliance)
// Security logs should only be cleaned up after 7 years, not the standard retention period
let deletedLogs = 0;
try {
const securityLogCutoffDate = new Date();
securityLogCutoffDate.setFullYear(securityLogCutoffDate.getFullYear() - 7); // 7 years retention
deletedLogs = await SecurityLog.destroy({
where: {
tenant_id: tenant.id,
created_at: {
[Op.lt]: securityLogCutoffDate
}
}
});
if (deletedLogs > 0) {
console.log(`🔐 Cleaned ${deletedLogs} security logs older than 7 years for tenant ${tenant.slug}`);
}
} catch (error) {
console.log(`⚠️ Error cleaning security logs for tenant ${tenant.slug}: ${error.message}`);
}
console.log(`✅ Tenant ${tenant.slug}: Deleted ${deletedDetections} detections, ${deletedHeartbeats} heartbeats, ${deletedLogs} security logs (7yr retention)`);
return {
detections: deletedDetections,
heartbeats: deletedHeartbeats,
logs: deletedLogs
};
}
/**
* Log health status
*/
logHealthStatus() {
const memUsage = process.memoryUsage();
const uptime = process.uptime();
console.log(`💚 Health Check - Uptime: ${Math.floor(uptime)}s, Memory: ${Math.round(memUsage.heapUsed / 1024 / 1024)}MB, Last Cleanup: ${this.lastCleanup ? this.lastCleanup.toISOString() : 'Never'}`);
}
/**
* Get service statistics
*/
getStats() {
return {
...this.cleanupStats,
isRunning: this.isRunning,
lastCleanup: this.lastCleanup,
uptime: process.uptime(),
memoryUsage: process.memoryUsage(),
nextScheduledRun: '2:00 AM UTC daily'
};
}
/**
* Get detailed metrics for dashboard
*/
getMetrics() {
const uptime = Math.floor(process.uptime());
const memoryUsage = process.memoryUsage();
return {
service: {
name: 'data-retention-service',
version: '1.0.0',
status: 'running',
uptime: uptime,
uptimeFormatted: this.formatUptime(uptime)
},
performance: {
memoryUsage: {
heapUsed: Math.round(memoryUsage.heapUsed / 1024 / 1024),
heapTotal: Math.round(memoryUsage.heapTotal / 1024 / 1024),
external: Math.round(memoryUsage.external / 1024 / 1024),
rss: Math.round(memoryUsage.rss / 1024 / 1024)
},
cpuUsage: process.cpuUsage()
},
cleanup: {
lastRun: this.lastCleanup,
lastRunFormatted: this.lastCleanup ? new Date(this.lastCleanup).toLocaleString() : null,
isCurrentlyRunning: this.isRunning,
nextScheduledRun: '2:00 AM UTC daily',
stats: this.cleanupStats
},
schedule: {
cronExpression: '0 2 * * *',
timezone: 'UTC',
description: 'Daily cleanup at 2:00 AM UTC'
}
};
}
/**
* Format uptime in human readable format
*/
formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (days > 0) {
return `${days}d ${hours}h ${minutes}m ${secs}s`;
} else if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
} else {
return `${secs}s`;
}
}
/**
* Start HTTP server for metrics endpoint
*/
startMetricsServer() {
const port = process.env.METRICS_PORT || 3001;
const server = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url, true);
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Content-Type', 'application/json');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
if (req.method === 'GET') {
if (parsedUrl.pathname === '/metrics') {
// Detailed metrics for dashboard
res.writeHead(200);
res.end(JSON.stringify(this.getMetrics(), null, 2));
} else if (parsedUrl.pathname === '/health') {
// Simple health check
res.writeHead(200);
res.end(JSON.stringify({
status: 'healthy',
uptime: Math.floor(process.uptime()),
lastCleanup: this.lastCleanup,
isRunning: this.isRunning
}, null, 2));
} else if (parsedUrl.pathname === '/stats') {
// Basic stats
res.writeHead(200);
res.end(JSON.stringify(this.getStats(), null, 2));
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not found' }));
}
} else if (req.method === 'POST') {
if (parsedUrl.pathname === '/cleanup') {
// Manual cleanup trigger
if (this.isRunning) {
res.writeHead(409);
res.end(JSON.stringify({
error: 'Cleanup already in progress',
message: 'A cleanup operation is currently running. Please wait for it to complete.'
}));
return;
}
console.log('🧹 Manual cleanup triggered via HTTP API');
// Trigger cleanup asynchronously
this.performCleanup().then(() => {
console.log('✅ Manual cleanup completed successfully');
}).catch((error) => {
console.error('❌ Manual cleanup failed:', error);
});
res.writeHead(202);
res.end(JSON.stringify({
success: true,
message: 'Data retention cleanup initiated',
timestamp: new Date().toISOString()
}));
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not found' }));
}
} else {
res.writeHead(405);
res.end(JSON.stringify({ error: 'Method not allowed' }));
}
});
server.listen(port, '0.0.0.0', () => {
console.log(`📊 Metrics server listening on internal port ${port}`);
console.log(`📊 Endpoints: /health, /metrics, /stats`);
console.log(`🔒 Access restricted to Docker internal network only`);
});
return server;
}
/**
* Graceful shutdown
*/
async shutdown() {
console.log('🔄 Graceful shutdown initiated...');
// Wait for current cleanup to finish
while (this.isRunning) {
console.log('⏳ Waiting for cleanup to finish...');
await new Promise(resolve => setTimeout(resolve, 1000));
}
console.log('✅ Data Retention Service stopped');
process.exit(0);
}
}
// Initialize and start the service
const service = new DataRetentionService();
// Handle graceful shutdown
process.on('SIGTERM', () => service.shutdown());
process.on('SIGINT', () => service.shutdown());
// Start the service
service.start().catch(error => {
console.error('Failed to start service:', error);
process.exit(1);
});
module.exports = DataRetentionService;

View File

@@ -0,0 +1,25 @@
{
"name": "data-retention-service",
"version": "1.0.0",
"description": "Automated data retention cleanup service for drone detection system",
"main": "index.js",
"scripts": {
"start": "node index.js",
"health": "node healthcheck.js"
},
"dependencies": {
"pg": "^8.11.3",
"sequelize": "^6.32.1",
"node-cron": "^3.0.2"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"data-retention",
"cleanup",
"microservice"
],
"author": "Drone Detection System",
"license": "MIT"
}

95
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,95 @@
# Production Docker Compose Configuration
# This file provides production-specific settings with maximum security
services:
# Backend - Production Security
backend:
# Remove external port exposure - only accessible via reverse proxy
ports: []
expose:
- "3001" # Internal only
environment:
NODE_ENV: production
# Security settings
API_DEBUG: false
LOG_LEVEL: warn
# Session security
SESSION_SECURE: true
SESSION_SAME_SITE: strict
# Enhanced security headers
ENABLE_SECURITY_HEADERS: true
# PostgreSQL - Production Security
postgres:
# No external ports in production
ports: []
expose:
- "5432" # Internal only
environment:
# Production database settings
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # Must be set via environment
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256"
# Additional security
command: >
postgres
-c ssl=on
-c ssl_cert_file=/var/lib/postgresql/server.crt
-c ssl_key_file=/var/lib/postgresql/server.key
-c log_connections=on
-c log_disconnections=on
-c log_statement=all
# Redis - Production Security
redis:
# No external ports in production
ports: []
expose:
- "6379" # Internal only
command: >
redis-server
--appendonly yes
--requirepass ${REDIS_PASSWORD}
--maxmemory 256mb
--maxmemory-policy allkeys-lru
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD} # Must be set via environment
# Data Retention - Production Security
data-retention:
# No external ports in production
ports: []
expose:
- "3001" # Internal only
environment:
NODE_ENV: production
IMMEDIATE_CLEANUP: false
# Frontend - Production Optimization
frontend:
environment:
# Production optimizations
NGINX_WORKER_PROCESSES: auto
NGINX_WORKER_CONNECTIONS: 1024
# Management - Production Optimization
management:
environment:
# Production optimizations
NGINX_WORKER_PROCESSES: auto
NGINX_WORKER_CONNECTIONS: 1024
# Health Probe - Production Settings
healthprobe:
environment:
PROBE_FAILRATE: 5 # Lower failure rate in production
PROBE_INTERVAL_SECONDS: 300 # Less frequent in production
# Production-specific network settings
networks:
drone-network:
driver: bridge
driver_opts:
# Enhanced network security
com.docker.network.bridge.enable_icc: "false"
com.docker.network.bridge.enable_ip_masquerade: "true"
com.docker.network.driver.mtu: 1500

View File

@@ -5,8 +5,6 @@
# - Automatic SSL renewal
# - WebSocket support for Socket.IO
version: '3.8'
services:
# Nginx Reverse Proxy with SSL
nginx:

View File

@@ -1,5 +1,3 @@
version: '3.8'
services:
# PostgreSQL Database
postgres:

View File

@@ -1,5 +1,3 @@
version: '3.8'
services:
# PostgreSQL Database
postgres:
@@ -14,8 +12,10 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
- ./server/scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
ports:
- "5433:5432"
# SECURITY: No external ports - internal access only
# Remove this line for production: ports: - "5433:5432"
expose:
- "5432" # Internal port only
networks:
- drone-network
healthcheck:
@@ -32,8 +32,10 @@ services:
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "6380:6379"
# SECURITY: No external ports - internal access only
# Remove this line for production: ports: - "6380:6379"
expose:
- "6379" # Internal port only
networks:
- drone-network
healthcheck:
@@ -71,11 +73,15 @@ services:
STORE_RAW_PAYLOAD: ${STORE_RAW_PAYLOAD:-false}
RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS:-900000}
RATE_LIMIT_MAX_REQUESTS: ${RATE_LIMIT_MAX_REQUESTS:-1000}
SECURITY_LOG_DIR: /app/logs
DATA_RETENTION_HOST: data-retention
DATA_RETENTION_PORT: 3001
ports:
- "3002:3001"
volumes:
- ./server/logs:/app/logs
- ./debug_logs:/app/debug_logs
- ./uploads:/app/uploads
networks:
- drone-network
depends_on:
@@ -171,6 +177,41 @@ services:
- simulation
command: python drone_simulator.py --devices 5 --duration 3600
# Data Retention Service (Microservice)
data-retention:
build:
context: ./data-retention-service
dockerfile: Dockerfile
container_name: drone-detection-data-retention
restart: unless-stopped
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: ${DB_NAME:-drone_detection}
DB_USER: ${DB_USER:-postgres}
DB_PASSWORD: ${DB_PASSWORD:-your_secure_password}
NODE_ENV: ${NODE_ENV:-production}
IMMEDIATE_CLEANUP: ${IMMEDIATE_CLEANUP:-false}
METRICS_PORT: 3001
# No external ports exposed - internal access only
networks:
- drone-network
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "healthcheck.js"]
interval: 30s
timeout: 10s
retries: 3
# Resource limits for lightweight container
deploy:
resources:
limits:
memory: 128M
reservations:
memory: 64M
# Health Probe Simulator (Continuous Device Heartbeats)
healthprobe:
build:

View File

@@ -56,6 +56,20 @@ server {
proxy_read_timeout 86400;
}
# Proxy uploads requests to backend (for logos and other files)
location /uggla/uploads/ {
proxy_pass http://backend:3001/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache uploaded files for 1 month
add_header Cache-Control "public, max-age=2592000";
proxy_read_timeout 86400;
}
# WebSocket proxy for Socket.IO
location /uggla/socket.io/ {
proxy_pass http://backend:3001/socket.io/;

View File

@@ -67,9 +67,6 @@ class DroneDevice:
lon: float
category: str
last_heartbeat: float = 0
battery_level: int = 100
signal_strength: int = -45
temperature: float = 20.0
status: str = "active"
@dataclass
@@ -118,10 +115,7 @@ class SwedishDroneSimulator:
location=location["name"],
lat=location["lat"],
lon=location["lon"],
category=category,
battery_level=random.randint(75, 100),
signal_strength=random.randint(-60, -30),
temperature=random.uniform(15, 30)
category=category
)
devices.append(device)

View File

@@ -19,7 +19,10 @@
"@heroicons/react": "^2.0.18",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"react-hot-toast": "^2.4.1"
"react-hot-toast": "^2.4.1",
"react-i18next": "^13.5.0",
"i18next": "^23.7.8",
"i18next-browser-languagedetector": "^7.2.0"
},
"devDependencies": {
"@types/react": "^18.2.15",

View File

@@ -10,6 +10,7 @@ import Tenants from './pages/Tenants'
import TenantUsersPage from './pages/TenantUsersPage'
import Users from './pages/Users'
import System from './pages/System'
import SecurityLogs from './pages/SecurityLogs'
function App() {
return (
@@ -29,6 +30,7 @@ function App() {
<Route path="tenants/:tenantId/users" element={<TenantUsersPage />} />
<Route path="users" element={<Users />} />
<Route path="system" element={<System />} />
<Route path="security-logs" element={<SecurityLogs />} />
</Route>
</Routes>
<Toaster

View File

@@ -0,0 +1,360 @@
import React, { useState, useEffect } from 'react'
import api from '../services/api'
import toast from 'react-hot-toast'
import {
TrashIcon,
ServerIcon,
ChartBarIcon,
ClockIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
PlayIcon,
EyeIcon
} from '@heroicons/react/24/outline'
const DataRetentionMetrics = () => {
const [metrics, setMetrics] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [lastUpdate, setLastUpdate] = useState(null)
const [cleanupLoading, setCleanupLoading] = useState(false)
const [previewData, setPreviewData] = useState(null)
const [showPreview, setShowPreview] = useState(false)
useEffect(() => {
loadDataRetentionMetrics()
// Auto-refresh every 30 seconds
const interval = setInterval(loadDataRetentionMetrics, 30000)
return () => clearInterval(interval)
}, [])
const loadDataRetentionMetrics = async () => {
try {
setError(null)
const response = await api.get('/data-retention/status')
setMetrics(response.data)
setLastUpdate(new Date())
} catch (error) {
console.error('Error loading data retention metrics:', error)
setError(error.response?.data?.message || 'Failed to load data retention metrics')
} finally {
setLoading(false)
}
}
const loadCleanupPreview = async () => {
try {
setError(null)
const response = await api.get('/data-retention/stats')
setPreviewData(response.data.data)
setShowPreview(true)
} catch (error) {
console.error('Error loading cleanup preview:', error)
toast.error('Failed to load cleanup preview')
}
}
const executeCleanup = async () => {
if (!window.confirm('Are you sure you want to execute data retention cleanup? This will permanently delete old data according to each tenant\'s retention policy.')) {
return
}
setCleanupLoading(true)
try {
// Note: This endpoint would need to be implemented in the backend
const response = await api.post('/data-retention/cleanup')
toast.success('Data retention cleanup initiated successfully')
// Refresh metrics after cleanup
setTimeout(() => {
loadDataRetentionMetrics()
}, 2000)
} catch (error) {
console.error('Error executing cleanup:', error)
toast.error(error.response?.data?.message || 'Failed to execute cleanup')
} finally {
setCleanupLoading(false)
}
}
const formatUptime = (seconds) => {
if (!seconds) return 'Unknown'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h ${minutes}m`
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
const formatMemory = (mb) => {
if (!mb) return 'Unknown'
if (mb > 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${mb} MB`
}
if (loading) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center mb-4">
<TrashIcon className="h-6 w-6 text-purple-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900">Data Retention Service</h3>
</div>
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
)
}
if (error) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center mb-4">
<TrashIcon className="h-6 w-6 text-purple-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900">Data Retention Service</h3>
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 ml-2" />
</div>
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<ExclamationTriangleIcon className="h-5 w-5 text-red-400 mr-2 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-red-700 font-medium">Service Unavailable</p>
<p className="text-sm text-red-600 mt-1">{error}</p>
</div>
</div>
</div>
<button
onClick={loadDataRetentionMetrics}
className="mt-4 px-4 py-2 bg-red-100 text-red-700 rounded-md hover:bg-red-200 transition-colors text-sm"
>
Retry Connection
</button>
</div>
)
}
const isConnected = metrics?.service?.connected
const serviceHealth = metrics?.health
const serviceMetrics = metrics?.metrics
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<TrashIcon className="h-6 w-6 text-purple-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900">Data Retention Service</h3>
{isConnected ? (
<CheckCircleIcon className="h-5 w-5 text-green-500 ml-2" />
) : (
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 ml-2" />
)}
</div>
<div className="flex items-center gap-4">
<div className="text-sm text-gray-500">
{lastUpdate && `Updated ${lastUpdate.toLocaleTimeString()}`}
</div>
{/* Action Buttons */}
{isConnected && (
<div className="flex gap-2">
<button
onClick={loadCleanupPreview}
className="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors text-sm flex items-center gap-1"
>
<EyeIcon className="h-4 w-4" />
Preview Cleanup
</button>
<button
onClick={executeCleanup}
disabled={cleanupLoading}
className="px-3 py-1.5 bg-red-100 text-red-700 rounded-md hover:bg-red-200 transition-colors text-sm flex items-center gap-1 disabled:opacity-50"
>
{cleanupLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-700"></div>
) : (
<PlayIcon className="h-4 w-4" />
)}
{cleanupLoading ? 'Running...' : 'Run Cleanup'}
</button>
</div>
)}
</div>
</div>
{isConnected ? (
<div className="space-y-4">
{/* Service Status */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-green-50 rounded-lg p-4">
<div className="flex items-center">
<CheckCircleIcon className="h-5 w-5 text-green-600 mr-2" />
<div>
<p className="text-sm font-medium text-green-800">Status</p>
<p className="text-lg font-bold text-green-900">{serviceMetrics?.service?.status || 'Running'}</p>
</div>
</div>
</div>
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-center">
<ClockIcon className="h-5 w-5 text-blue-600 mr-2" />
<div>
<p className="text-sm font-medium text-blue-800">Uptime</p>
<p className="text-lg font-bold text-blue-900">
{formatUptime(serviceMetrics?.service?.uptime || serviceHealth?.uptime)}
</p>
</div>
</div>
</div>
<div className="bg-purple-50 rounded-lg p-4">
<div className="flex items-center">
<ServerIcon className="h-5 w-5 text-purple-600 mr-2" />
<div>
<p className="text-sm font-medium text-purple-800">Memory</p>
<p className="text-lg font-bold text-purple-900">
{formatMemory(serviceMetrics?.performance?.memoryUsage?.heapUsed)}
</p>
</div>
</div>
</div>
</div>
{/* Cleanup Information */}
{serviceMetrics?.cleanup && (
<div className="border-t pt-4">
<h4 className="font-medium text-gray-900 mb-3">Cleanup Operations</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600">Last Cleanup</p>
<p className="font-medium">
{serviceMetrics.cleanup.lastRunFormatted || 'Never'}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Next Scheduled</p>
<p className="font-medium">{serviceMetrics.cleanup.nextScheduledRun || '2:00 AM UTC daily'}</p>
</div>
</div>
{serviceMetrics.cleanup.stats && (
<div className="mt-3">
<p className="text-sm text-gray-600 mb-2">Last Cleanup Stats</p>
<div className="flex flex-wrap gap-4 text-sm">
<span className="bg-gray-100 px-2 py-1 rounded">
Detections: {serviceMetrics.cleanup.stats.totalDetections || 0}
</span>
<span className="bg-gray-100 px-2 py-1 rounded">
Heartbeats: {serviceMetrics.cleanup.stats.totalHeartbeats || 0}
</span>
<span className="bg-gray-100 px-2 py-1 rounded">
Logs: {serviceMetrics.cleanup.stats.totalLogs || 0}
</span>
</div>
</div>
)}
</div>
)}
{/* Schedule Information */}
{serviceMetrics?.schedule && (
<div className="border-t pt-4">
<h4 className="font-medium text-gray-900 mb-2">Schedule</h4>
<p className="text-sm text-gray-600">{serviceMetrics.schedule.description}</p>
<p className="text-xs text-gray-500 mt-1">
Cron: {serviceMetrics.schedule.cronExpression} ({serviceMetrics.schedule.timezone})
</p>
</div>
)}
</div>
) : (
<div className="text-center py-8">
<ExclamationTriangleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" />
<p className="text-gray-600">Data retention service is not connected</p>
<p className="text-sm text-gray-500 mt-1">
{metrics?.service?.error || 'Service health check failed'}
</p>
</div>
)}
{/* Cleanup Preview Modal */}
{showPreview && previewData && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium">Data Retention Cleanup Preview</h3>
<button
onClick={() => setShowPreview(false)}
className="text-gray-400 hover:text-gray-600"
>
<span className="sr-only">Close</span>
</button>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600">
This preview shows what data would be deleted based on each tenant's retention policy.
</p>
{previewData.tenants && previewData.tenants.map((tenant, index) => (
<div key={index} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{tenant.name}</h4>
<span className="text-sm text-gray-500">
{tenant.retentionDays === -1 ? 'Unlimited' : `${tenant.retentionDays} days`}
</span>
</div>
{tenant.retentionDays === -1 ? (
<p className="text-sm text-gray-600">No data will be deleted (unlimited retention)</p>
) : (
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="font-medium">Detections:</span>
<span className="ml-2">{tenant.toDelete?.detections || 0}</span>
</div>
<div>
<span className="font-medium">Heartbeats:</span>
<span className="ml-2">{tenant.toDelete?.heartbeats || 0}</span>
</div>
<div>
<span className="font-medium">Logs:</span>
<span className="ml-2">{tenant.toDelete?.logs || 0}</span>
</div>
</div>
)}
</div>
))}
<div className="flex justify-end gap-3 pt-4 border-t">
<button
onClick={() => setShowPreview(false)}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={() => {
setShowPreview(false)
executeCleanup()
}}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Execute Cleanup
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
export default DataRetentionMetrics

View File

@@ -1,23 +1,29 @@
import React from 'react'
import { Outlet, NavLink, useLocation } from 'react-router-dom'
// import { useTranslation } from 'react-i18next' // Commented out until Docker rebuild
import { useAuth } from '../contexts/AuthContext'
import LanguageSelector from './common/LanguageSelector'
import { t } from '../utils/tempTranslations' // Temporary translation system
import {
HomeIcon,
BuildingOfficeIcon,
UsersIcon,
CogIcon,
ShieldCheckIcon,
ArrowRightOnRectangleIcon
} from '@heroicons/react/24/outline'
const Layout = () => {
// const { t } = useTranslation() // Commented out until Docker rebuild
const { user, logout } = useAuth()
const location = useLocation()
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
{ name: 'Tenants', href: '/tenants', icon: BuildingOfficeIcon },
{ name: 'Users', href: '/users', icon: UsersIcon },
{ name: 'System', href: '/system', icon: CogIcon },
{ name: t('nav.dashboard'), href: '/dashboard', icon: HomeIcon },
{ name: t('nav.tenants'), href: '/tenants', icon: BuildingOfficeIcon },
{ name: t('nav.users'), href: '/users', icon: UsersIcon },
{ name: t('nav.security_logs'), href: '/security-logs', icon: ShieldCheckIcon },
{ name: t('nav.system'), href: '/system', icon: CogIcon },
]
return (
@@ -25,7 +31,7 @@ const Layout = () => {
{/* Sidebar */}
<div className="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg">
<div className="flex h-16 items-center justify-center border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">UAMILS Management</h1>
<h1 className="text-xl font-bold text-gray-900">{t('app.title')}</h1>
</div>
<nav className="mt-8 px-4 space-y-2">
@@ -73,7 +79,7 @@ const Layout = () => {
<button
onClick={logout}
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
title="Logout"
title={t('auth.logout')}
>
<ArrowRightOnRectangleIcon className="h-5 w-5" />
</button>
@@ -83,6 +89,14 @@ const Layout = () => {
{/* Main content */}
<div className="pl-64">
{/* Top header bar */}
<div className="bg-white border-b border-gray-200 px-4 py-3">
<div className="flex justify-between items-center">
<div></div> {/* Empty div for spacing */}
<LanguageSelector />
</div>
</div>
<div className="py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Outlet />

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import { XMarkIcon, EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
import toast from 'react-hot-toast'
import { t } from '../utils/tempTranslations' // Temporary translation system
const TenantModal = ({ isOpen, onClose, tenant = null, onSave }) => {
const [formData, setFormData] = useState({

View File

@@ -0,0 +1,72 @@
import React from 'react';
// import { useTranslation } from 'react-i18next'; // Commented out until Docker rebuild
import { changeLanguage, getCurrentLanguage } from '../../utils/tempTranslations'; // Temporary translation system
import { Menu, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { Globe, ChevronDown, Check } from 'lucide-react';
const languages = [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'sv', name: 'Svenska', flag: '🇸🇪' }
];
export default function LanguageSelector({ className = '' }) {
// const { i18n, t } = useTranslation(); // Commented out until Docker rebuild
const currentLanguageCode = getCurrentLanguage();
const currentLanguage = languages.find(lang => lang.code === currentLanguageCode) || languages[0];
const handleChangeLanguage = (languageCode) => {
changeLanguage(languageCode);
};
return (
<Menu as="div" className={`relative inline-block text-left ${className}`}>
<div>
<Menu.Button className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<Globe className="w-4 h-4 mr-2" />
<span className="mr-1">{currentLanguage.flag}</span>
<span>{currentLanguage.name}</span>
<ChevronDown className="w-4 h-4 ml-2" />
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-10 w-48 mt-2 origin-top-right bg-white border border-gray-300 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{languages.map((language) => (
<Menu.Item key={language.code}>
{({ active }) => (
<button
onClick={() => handleChangeLanguage(language.code)}
className={`${
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700'
} ${
language.code === currentLanguageCode ? 'bg-indigo-50 text-indigo-600' : ''
} group flex items-center px-4 py-2 text-sm w-full text-left`}
>
<span className="mr-3">{language.flag}</span>
<span>{language.name}</span>
{language.code === currentLanguageCode && (
<span className="ml-auto">
<Check className="w-4 h-4" />
</span>
)}
</button>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -17,21 +17,36 @@ export const AuthProvider = ({ children }) => {
const [loading, setLoading] = useState(true)
useEffect(() => {
// Check for existing token on app start
// Check for existing token on app start and validate it
checkAuthStatus()
}, [])
const checkAuthStatus = async () => {
const token = localStorage.getItem('management_token')
const savedUser = localStorage.getItem('management_user')
if (token && savedUser) {
try {
setUser(JSON.parse(savedUser))
} catch (error) {
console.error('Error parsing saved user:', error)
localStorage.removeItem('management_token')
localStorage.removeItem('management_user')
}
if (!token || !savedUser) {
setLoading(false)
return
}
setLoading(false)
}, [])
try {
// Validate token by making a simple API call
const response = await api.get('/management/tenants?limit=1')
// If successful, use saved user data
const parsedUser = JSON.parse(savedUser)
setUser(parsedUser)
console.log('✅ Management token validated for user:', parsedUser.username)
} catch (error) {
console.warn('🔓 Management token validation failed:', error.response?.status, error.response?.data?.message)
// Clear invalid auth data (but don't redirect here, let the api interceptor handle it)
localStorage.removeItem('management_token')
localStorage.removeItem('management_user')
setUser(null)
} finally {
setLoading(false)
}
}
const login = async (username, password) => {
try {
@@ -73,6 +88,7 @@ export const AuthProvider = ({ children }) => {
loading,
login,
logout,
checkAuthStatus,
isAuthenticated: !!user,
isAdmin: user?.role === 'admin' || user?.role === 'super_admin' || user?.role === 'platform_admin',
isSuperAdmin: user?.role === 'super_admin',

View File

@@ -0,0 +1,34 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Import translation files
import en from './locales/en.json';
import sv from './locales/sv.json';
const resources = {
en: {
translation: en
},
sv: {
translation: sv
}
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
lng: 'en', // default language
fallbackLng: 'en',
interpolation: {
escapeValue: false // React already does escaping
},
detection: {
order: ['localStorage', 'navigator', 'htmlTag'],
caches: ['localStorage']
}
});
export default i18n;

View File

@@ -0,0 +1,202 @@
{
"app": {
"title": "UAM-ILS Management Portal",
"subtitle": "Multi-Tenant Drone Detection System Administration"
},
"navigation": {
"dashboard": "Dashboard",
"tenants": "Tenants",
"users": "Users",
"system": "System",
"monitoring": "Monitoring",
"settings": "Settings",
"logout": "Logout"
},
"auth": {
"login": "Login",
"username": "Username",
"password": "Password",
"loginButton": "Sign In",
"loginError": "Invalid management credentials. Please try again.",
"sessionExpired": "Your management session has expired. Please log in again.",
"accessDenied": "Insufficient management privileges.",
"loggingIn": "Signing in...",
"logout": "Logout",
"logoutConfirm": "Are you sure you want to log out?"
},
"dashboard": {
"title": "System Overview",
"totalTenants": "Total Tenants",
"activeTenants": "Active Tenants",
"totalUsers": "Total Users",
"systemHealth": "System Health",
"recentActivity": "Recent Activity",
"systemMetrics": "System Metrics",
"memoryUsage": "Memory Usage",
"cpuUsage": "CPU Usage",
"diskUsage": "Disk Usage",
"networkTraffic": "Network Traffic"
},
"tenants": {
"title": "Tenant Management",
"noTenants": "No tenants configured",
"loadingTenants": "Loading tenants...",
"addTenant": "Add Tenant",
"editTenant": "Edit Tenant",
"deleteTenant": "Delete Tenant",
"tenantName": "Tenant Name",
"tenantSlug": "Tenant Slug",
"domain": "Domain",
"status": "Status",
"created": "Created",
"lastActivity": "Last Activity",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"suspended": "Suspended",
"edit": "Edit",
"delete": "Delete",
"activate": "Activate",
"deactivate": "Deactivate",
"suspend": "Suspend",
"viewDetails": "View Details",
"confirmDelete": "Are you sure you want to delete this tenant? This action cannot be undone."
},
"users": {
"title": "User Management",
"noUsers": "No users found",
"loadingUsers": "Loading users...",
"addUser": "Add User",
"editUser": "Edit User",
"deleteUser": "Delete User",
"username": "Username",
"email": "Email",
"role": "Role",
"tenant": "Tenant",
"status": "Status",
"lastLogin": "Last Login",
"created": "Created",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"admin": "Admin",
"user": "User",
"operator": "Operator",
"viewer": "Viewer"
},
"system": {
"title": "System Information",
"serverInfo": "Server Information",
"databaseInfo": "Database Information",
"containerInfo": "Container Information",
"version": "Version",
"uptime": "Uptime",
"platform": "Platform",
"nodeVersion": "Node.js Version",
"connections": "Database Connections",
"tables": "Tables",
"diskSpace": "Disk Space",
"memoryUsage": "Memory Usage",
"loadAverage": "Load Average",
"containers": "Containers",
"images": "Images",
"volumes": "Volumes",
"networks": "Networks"
},
"monitoring": {
"title": "System Monitoring",
"realTimeMetrics": "Real-time Metrics",
"alerts": "System Alerts",
"logs": "System Logs",
"performance": "Performance",
"security": "Security Events",
"noAlerts": "No active alerts",
"noLogs": "No recent logs",
"refresh": "Refresh",
"autoRefresh": "Auto Refresh",
"exportLogs": "Export Logs",
"clearLogs": "Clear Logs"
},
"settings": {
"title": "Management Settings",
"general": "General",
"security": "Security",
"notifications": "Notifications",
"language": "Language",
"timezone": "Timezone",
"theme": "Theme",
"sessionTimeout": "Session Timeout",
"passwordPolicy": "Password Policy",
"twoFactorAuth": "Two-Factor Authentication",
"auditLogging": "Audit Logging",
"save": "Save Changes",
"cancel": "Cancel",
"saved": "Settings saved successfully",
"error": "Failed to save settings"
},
"forms": {
"name": "Name",
"slug": "Slug",
"domain": "Domain",
"description": "Description",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"role": "Role",
"status": "Status",
"required": "Required",
"optional": "Optional",
"create": "Create",
"update": "Update",
"cancel": "Cancel",
"save": "Save",
"reset": "Reset"
},
"common": {
"loading": "Loading...",
"error": "An error occurred",
"retry": "Retry",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"remove": "Remove",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
"ok": "OK",
"close": "Close",
"search": "Search",
"filter": "Filter",
"clear": "Clear",
"refresh": "Refresh",
"export": "Export",
"import": "Import",
"view": "View",
"manage": "Manage"
},
"errors": {
"networkError": "Network connection error. Please check your internet connection.",
"serverError": "Server error. Please try again later.",
"notFound": "The requested resource was not found.",
"unauthorized": "You are not authorized to access this resource.",
"forbidden": "Access to this resource is forbidden.",
"validationError": "Please check your input and try again.",
"sessionExpired": "Your session has expired. Please log in again.",
"unknownError": "An unknown error occurred. Please try again.",
"tenantExists": "A tenant with this name or slug already exists.",
"userExists": "A user with this username or email already exists.",
"invalidCredentials": "Invalid credentials provided.",
"insufficientPrivileges": "Insufficient privileges to perform this action."
},
"success": {
"tenantCreated": "Tenant created successfully",
"tenantUpdated": "Tenant updated successfully",
"tenantDeleted": "Tenant deleted successfully",
"userCreated": "User created successfully",
"userUpdated": "User updated successfully",
"userDeleted": "User deleted successfully",
"settingsSaved": "Settings saved successfully"
}
}

View File

@@ -0,0 +1,202 @@
{
"app": {
"title": "UAM-ILS Förvaltningsportal",
"subtitle": "Administration av multi-tenant drönardetektionssystem"
},
"navigation": {
"dashboard": "Översikt",
"tenants": "Hyresgäster",
"users": "Användare",
"system": "System",
"monitoring": "Övervakning",
"settings": "Inställningar",
"logout": "Logga ut"
},
"auth": {
"login": "Logga in",
"username": "Användarnamn",
"password": "Lösenord",
"loginButton": "Logga in",
"loginError": "Ogiltiga förvaltningsuppgifter. Försök igen.",
"sessionExpired": "Din förvaltningssession har löpt ut. Vänligen logga in igen.",
"accessDenied": "Otillräckliga förvaltningsprivilegier.",
"loggingIn": "Loggar in...",
"logout": "Logga ut",
"logoutConfirm": "Är du säker på att du vill logga ut?"
},
"dashboard": {
"title": "Systemöversikt",
"totalTenants": "Totala hyresgäster",
"activeTenants": "Aktiva hyresgäster",
"totalUsers": "Totala användare",
"systemHealth": "Systemhälsa",
"recentActivity": "Senaste aktivitet",
"systemMetrics": "Systemmätningar",
"memoryUsage": "Minnesanvändning",
"cpuUsage": "CPU-användning",
"diskUsage": "Diskanvändning",
"networkTraffic": "Nätverkstrafik"
},
"tenants": {
"title": "Hyresgästhantering",
"noTenants": "Inga hyresgäster konfigurerade",
"loadingTenants": "Laddar hyresgäster...",
"addTenant": "Lägg till hyresgäst",
"editTenant": "Redigera hyresgäst",
"deleteTenant": "Ta bort hyresgäst",
"tenantName": "Hyresgästnamn",
"tenantSlug": "Hyresgästslug",
"domain": "Domän",
"status": "Status",
"created": "Skapad",
"lastActivity": "Senaste aktivitet",
"actions": "Åtgärder",
"active": "Aktiv",
"inactive": "Inaktiv",
"suspended": "Avstängd",
"edit": "Redigera",
"delete": "Ta bort",
"activate": "Aktivera",
"deactivate": "Inaktivera",
"suspend": "Stäng av",
"viewDetails": "Visa detaljer",
"confirmDelete": "Är du säker på att du vill ta bort denna hyresgäst? Denna åtgärd kan inte ångras."
},
"users": {
"title": "Användarhantering",
"noUsers": "Inga användare hittades",
"loadingUsers": "Laddar användare...",
"addUser": "Lägg till användare",
"editUser": "Redigera användare",
"deleteUser": "Ta bort användare",
"username": "Användarnamn",
"email": "E-post",
"role": "Roll",
"tenant": "Hyresgäst",
"status": "Status",
"lastLogin": "Senaste inloggning",
"created": "Skapad",
"actions": "Åtgärder",
"active": "Aktiv",
"inactive": "Inaktiv",
"admin": "Admin",
"user": "Användare",
"operator": "Operatör",
"viewer": "Betraktare"
},
"system": {
"title": "Systeminformation",
"serverInfo": "Serverinformation",
"databaseInfo": "Databasinformation",
"containerInfo": "Behållarinformation",
"version": "Version",
"uptime": "Drifttid",
"platform": "Plattform",
"nodeVersion": "Node.js Version",
"connections": "Databasanslutningar",
"tables": "Tabeller",
"diskSpace": "Diskutrymme",
"memoryUsage": "Minnesanvändning",
"loadAverage": "Belastningsgenomsnitt",
"containers": "Behållare",
"images": "Bilder",
"volumes": "Volymer",
"networks": "Nätverk"
},
"monitoring": {
"title": "Systemövervakning",
"realTimeMetrics": "Realtidsmätningar",
"alerts": "Systemlarm",
"logs": "Systemloggar",
"performance": "Prestanda",
"security": "Säkerhetshändelser",
"noAlerts": "Inga aktiva larm",
"noLogs": "Inga senaste loggar",
"refresh": "Uppdatera",
"autoRefresh": "Automatisk uppdatering",
"exportLogs": "Exportera loggar",
"clearLogs": "Rensa loggar"
},
"settings": {
"title": "Förvaltningsinställningar",
"general": "Allmänt",
"security": "Säkerhet",
"notifications": "Notifieringar",
"language": "Språk",
"timezone": "Tidszon",
"theme": "Tema",
"sessionTimeout": "Sessionstimeout",
"passwordPolicy": "Lösenordspolicy",
"twoFactorAuth": "Tvåfaktorsautentisering",
"auditLogging": "Revisionsloggning",
"save": "Spara ändringar",
"cancel": "Avbryt",
"saved": "Inställningar sparade framgångsrikt",
"error": "Misslyckades att spara inställningar"
},
"forms": {
"name": "Namn",
"slug": "Slug",
"domain": "Domän",
"description": "Beskrivning",
"email": "E-post",
"password": "Lösenord",
"confirmPassword": "Bekräfta lösenord",
"role": "Roll",
"status": "Status",
"required": "Obligatorisk",
"optional": "Valfri",
"create": "Skapa",
"update": "Uppdatera",
"cancel": "Avbryt",
"save": "Spara",
"reset": "Återställ"
},
"common": {
"loading": "Laddar...",
"error": "Ett fel uppstod",
"retry": "Försök igen",
"cancel": "Avbryt",
"save": "Spara",
"delete": "Ta bort",
"edit": "Redigera",
"add": "Lägg till",
"remove": "Ta bort",
"confirm": "Bekräfta",
"yes": "Ja",
"no": "Nej",
"ok": "OK",
"close": "Stäng",
"search": "Sök",
"filter": "Filtrera",
"clear": "Rensa",
"refresh": "Uppdatera",
"export": "Exportera",
"import": "Importera",
"view": "Visa",
"manage": "Hantera"
},
"errors": {
"networkError": "Nätverksanslutningsfel. Vänligen kontrollera din internetanslutning.",
"serverError": "Serverfel. Vänligen försök igen senare.",
"notFound": "Den begärda resursen hittades inte.",
"unauthorized": "Du är inte behörig att komma åt denna resurs.",
"forbidden": "Åtkomst till denna resurs är förbjuden.",
"validationError": "Vänligen kontrollera din inmatning och försök igen.",
"sessionExpired": "Din session har löpt ut. Vänligen logga in igen.",
"unknownError": "Ett okänt fel uppstod. Vänligen försök igen.",
"tenantExists": "En hyresgäst med detta namn eller slug finns redan.",
"userExists": "En användare med detta användarnamn eller e-post finns redan.",
"invalidCredentials": "Ogiltiga inloggningsuppgifter angivna.",
"insufficientPrivileges": "Otillräckliga privilegier för att utföra denna åtgärd."
},
"success": {
"tenantCreated": "Hyresgäst skapad framgångsrikt",
"tenantUpdated": "Hyresgäst uppdaterad framgångsrikt",
"tenantDeleted": "Hyresgäst borttagen framgångsrikt",
"userCreated": "Användare skapad framgångsrikt",
"userUpdated": "Användare uppdaterad framgångsrikt",
"userDeleted": "Användare borttagen framgångsrikt",
"settingsSaved": "Inställningar sparade framgångsrikt"
}
}

View File

@@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import './i18n' // Initialize i18n
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'
import api from '../services/api'
import { t } from '../utils/tempTranslations' // Temporary translation system
import { BuildingOfficeIcon, UsersIcon, ServerIcon, ChartBarIcon } from '@heroicons/react/24/outline'
const Dashboard = () => {
@@ -38,26 +39,26 @@ const Dashboard = () => {
const statCards = [
{
name: 'Total Tenants',
name: t('dashboard.totalTenants'),
value: stats.tenants,
icon: BuildingOfficeIcon,
color: 'bg-blue-500'
},
{
name: 'Total Users',
name: t('dashboard.totalUsers'),
value: stats.users,
icon: UsersIcon,
color: 'bg-green-500'
},
{
name: 'Active Sessions',
name: t('dashboard.activeSessions'),
value: stats.activeSessions,
icon: ChartBarIcon,
color: 'bg-yellow-500'
},
{
name: 'System Health',
value: stats.systemHealth === 'good' ? 'Good' : 'Issues',
name: t('dashboard.systemHealth'),
value: stats.systemHealth === 'good' ? t('dashboard.good') : t('dashboard.issues'),
icon: ServerIcon,
color: stats.systemHealth === 'good' ? 'bg-green-500' : 'bg-red-500'
}
@@ -74,8 +75,8 @@ const Dashboard = () => {
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Overview of your UAMILS system</p>
<h1 className="text-2xl font-bold text-gray-900">{t('dashboard.title')}</h1>
<p className="text-gray-600">{t('dashboard.description')}</p>
</div>
{/* Stats Grid */}
@@ -95,7 +96,7 @@ const Dashboard = () => {
))}
</div>
{/* Quick Actions */}
{/* Quick Actions and Recent Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quick Actions</h3>

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { t } from '../utils/tempTranslations' // Temporary translation system
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
const Login = () => {
@@ -40,10 +41,10 @@ const Login = () => {
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
UAMILS Management Portal
{t('auth.portalTitle')}
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Sign in to manage tenants and system configuration
{t('auth.loginDescription')}
</p>
</div>
@@ -51,7 +52,7 @@ const Login = () => {
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
Username
{t('auth.username')}
</label>
<input
id="username"
@@ -59,7 +60,7 @@ const Login = () => {
type="text"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Username"
placeholder={t('auth.username')}
value={formData.username}
onChange={handleInputChange}
disabled={loading}
@@ -67,7 +68,7 @@ const Login = () => {
</div>
<div className="relative">
<label htmlFor="password" className="sr-only">
Password
{t('auth.password')}
</label>
<input
id="password"
@@ -75,7 +76,7 @@ const Login = () => {
type={showPassword ? 'text' : 'password'}
required
className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
placeholder={t('auth.password')}
value={formData.password}
onChange={handleInputChange}
disabled={loading}
@@ -103,17 +104,17 @@ const Login = () => {
{loading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Signing in...
{t('auth.signingIn')}
</div>
) : (
'Sign in'
t('auth.signIn')
)}
</button>
</div>
<div className="text-center">
<p className="text-xs text-gray-500">
Admin access required. Default: admin / admin123
{t('auth.adminAccess')}
</p>
</div>
</form>

View File

@@ -0,0 +1,264 @@
import React, { useState, useEffect } from 'react';
import { formatDistanceToNow } from 'date-fns';
import api from '../services/api';
const SecurityLogs = () => {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({
level: 'all',
eventType: 'all',
timeRange: '24h',
search: ''
});
const [pagination, setPagination] = useState({
page: 1,
limit: 50,
total: 0
});
useEffect(() => {
loadSecurityLogs();
}, [filters, pagination.page]);
const loadSecurityLogs = async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: pagination.page,
limit: pagination.limit,
...filters
});
const response = await api.get(`/management/security-logs?${params}`);
const data = response.data;
setLogs(data.logs || []);
setPagination(prev => ({
...prev,
total: data.total || 0
}));
} catch (err) {
console.error('Failed to load security logs:', err);
setError(err.response?.data?.message || err.message);
} finally {
setLoading(false);
}
};
const getLogLevelBadge = (level) => {
const styles = {
'critical': 'bg-red-500 text-white px-2 py-1 rounded text-xs font-semibold',
'high': 'bg-orange-500 text-white px-2 py-1 rounded text-xs font-semibold',
'medium': 'bg-yellow-500 text-black px-2 py-1 rounded text-xs font-semibold',
'low': 'bg-blue-500 text-white px-2 py-1 rounded text-xs font-semibold',
'info': 'bg-gray-500 text-white px-2 py-1 rounded text-xs font-semibold'
};
return styles[level] || styles.info;
};
const getEventTypeIcon = (eventType) => {
const icons = {
'failed_login': '🚫',
'successful_login': '✅',
'suspicious_activity': '⚠️',
'country_alert': '🌍',
'brute_force': '🔨',
'account_lockout': '🔒',
'password_reset': '🔄',
'admin_action': '👤'
};
return icons[eventType] || '📋';
};
const formatMetadata = (metadata) => {
if (!metadata) return '';
const items = [];
if (metadata.ip_address) items.push(`IP: ${metadata.ip_address}`);
if (metadata.country) items.push(`Country: ${metadata.country}`);
if (metadata.user_agent) items.push(`Agent: ${metadata.user_agent.substring(0, 50)}...`);
if (metadata.tenant_slug) items.push(`Tenant: ${metadata.tenant_slug}`);
return items.join(' | ');
};
const totalPages = Math.ceil(pagination.total / pagination.limit);
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2 text-gray-900">Security Logs</h1>
<p className="text-gray-600">Monitor security events across all tenants</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="text-red-800">{error}</div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg shadow mb-6">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Filters</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Security Level</label>
<select
value={filters.level}
onChange={(e) => setFilters(prev => ({ ...prev, level: e.target.value }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Levels</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="info">Info</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Event Type</label>
<select
value={filters.eventType}
onChange={(e) => setFilters(prev => ({ ...prev, eventType: e.target.value }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Events</option>
<option value="failed_login">Failed Logins</option>
<option value="successful_login">Successful Logins</option>
<option value="suspicious_activity">Suspicious Activity</option>
<option value="country_alert">Country Alerts</option>
<option value="brute_force">Brute Force</option>
<option value="account_lockout">Account Lockouts</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Time Range</label>
<select
value={filters.timeRange}
onChange={(e) => setFilters(prev => ({ ...prev, timeRange: e.target.value }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
>
<option value="1h">Last Hour</option>
<option value="24h">Last 24 Hours</option>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
<option value="all">All Time</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Search</label>
<input
type="text"
placeholder="IP, username, tenant..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
{/* Security Logs Table */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900">Security Events</h3>
<span className="text-sm text-gray-500">
{pagination.total} total events
</span>
</div>
</div>
<div className="p-6">
{loading ? (
<div className="flex justify-center py-8">
<div className="text-gray-500">Loading security logs...</div>
</div>
) : logs.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No security logs found matching your criteria
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left p-3 text-sm font-medium text-gray-500 uppercase tracking-wider">Time</th>
<th className="text-left p-3 text-sm font-medium text-gray-500 uppercase tracking-wider">Level</th>
<th className="text-left p-3 text-sm font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th className="text-left p-3 text-sm font-medium text-gray-500 uppercase tracking-wider">Message</th>
<th className="text-left p-3 text-sm font-medium text-gray-500 uppercase tracking-wider">Details</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="p-3 text-sm">
<div>{new Date(log.timestamp).toLocaleString()}</div>
<div className="text-xs text-gray-500">
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
</div>
</td>
<td className="p-3">
<span className={getLogLevelBadge(log.level)}>
{log.level.toUpperCase()}
</span>
</td>
<td className="p-3">
<div className="flex items-center gap-2">
<span>{getEventTypeIcon(log.event_type)}</span>
<span className="text-sm">{log.event_type.replace('_', ' ').toUpperCase()}</span>
</div>
</td>
<td className="p-3 text-sm max-w-md">
<div className="truncate" title={log.message}>
{log.message}
</div>
</td>
<td className="p-3 text-xs text-gray-600 max-w-md">
<div className="truncate" title={formatMetadata(log.metadata)}>
{formatMetadata(log.metadata)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-between items-center mt-6">
<div className="text-sm text-gray-500">
Page {pagination.page} of {totalPages}
</div>
<div className="flex gap-2">
<button
onClick={() => setPagination(prev => ({ ...prev, page: Math.max(1, prev.page - 1) }))}
disabled={pagination.page === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded disabled:opacity-50 hover:bg-gray-50"
>
Previous
</button>
<button
onClick={() => setPagination(prev => ({ ...prev, page: Math.min(totalPages, prev.page + 1) }))}
disabled={pagination.page === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded disabled:opacity-50 hover:bg-gray-50"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default SecurityLogs;

View File

@@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react'
import api from '../services/api'
import toast from 'react-hot-toast'
import { t } from '../utils/tempTranslations' // Temporary translation system
import DataRetentionMetrics from '../components/DataRetentionMetrics'
import {
CogIcon,
ServerIcon,
@@ -33,7 +35,7 @@ const System = () => {
setLastUpdate(new Date())
} catch (error) {
console.error('Failed to load system info:', error)
toast.error('Failed to load system information')
toast.error(t('system.loadError'))
} finally {
setLoading(false)
}
@@ -290,14 +292,14 @@ const System = () => {
return (
<div className="text-center py-12">
<XCircleIcon className="mx-auto h-12 w-12 text-red-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No system information available</h3>
<p className="mt-1 text-sm text-gray-500">Unable to load system metrics.</p>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('system.noInformation')}</h3>
<p className="mt-1 text-sm text-gray-500">{t('system.noInformationDescription')}</p>
<div className="mt-6">
<button
onClick={loadSystemInfo}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Retry
{t('system.retry')}
</button>
</div>
</div>
@@ -308,11 +310,11 @@ const System = () => {
<div>
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">System Monitor</h1>
<p className="text-gray-600">Real-time system health and configuration monitoring</p>
<h1 className="text-2xl font-bold text-gray-900">{t('system.title')}</h1>
<p className="text-gray-600">{t('system.description')}</p>
{lastUpdate && (
<p className="text-xs text-gray-400 mt-1">
Last updated: {lastUpdate.toLocaleTimeString()}
{t('system.lastUpdated')}: {lastUpdate.toLocaleTimeString()}
</p>
)}
</div>
@@ -321,20 +323,20 @@ const System = () => {
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<CogIcon className="h-4 w-4 mr-2" />
Refresh
{t('common.refresh')}
</button>
</div>
{/* Platform Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatusCard title="Platform Status" icon={ServerIcon}>
<StatusCard title={t('system.platformStatus')} icon={ServerIcon}>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Version</span>
<span className="text-sm text-gray-500">{t('system.version')}</span>
<span className="text-sm font-medium">{systemInfo.platform.version}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Environment</span>
<span className="text-sm text-gray-500">{t('system.environment')}</span>
<span className="text-sm font-medium capitalize">{systemInfo.platform.environment}</span>
</div>
<div className="flex justify-between">
@@ -515,6 +517,11 @@ const System = () => {
</div>
</StatusCard>
</div>
{/* Data Retention Service */}
<div className="mb-8">
<DataRetentionMetrics />
</div>
</div>
)
}

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import api from '../services/api'
import toast from 'react-hot-toast'
import TenantModal from '../components/TenantModal'
import { t } from '../utils/tempTranslations' // Temporary translation system
import {
PlusIcon,
PencilIcon,
@@ -82,16 +83,16 @@ const Tenants = () => {
}
const deleteTenant = async (tenantId) => {
if (!confirm('Are you sure you want to delete this tenant? This action cannot be undone.')) {
if (!confirm(t('tenants.confirmDelete'))) {
return
}
try {
await api.delete(`/management/tenants/${tenantId}`)
toast.success('Tenant deleted successfully')
toast.success(t('tenants.deleteSuccess'))
loadTenants()
} catch (error) {
toast.error('Failed to delete tenant')
toast.error(t('tenants.deleteError'))
console.error('Error deleting tenant:', error)
}
}
@@ -99,8 +100,8 @@ const Tenants = () => {
const toggleTenantStatus = async (tenant) => {
const action = tenant.is_active ? 'deactivate' : 'activate'
const confirmMessage = tenant.is_active
? `Are you sure you want to deactivate "${tenant.name}"? Users will not be able to access this tenant.`
: `Are you sure you want to activate "${tenant.name}"?`
? t('tenants.confirmDeactivate', { name: tenant.name })
: t('tenants.confirmActivate', { name: tenant.name })
if (!confirm(confirmMessage)) {
return
@@ -108,10 +109,10 @@ const Tenants = () => {
try {
await api.post(`/management/tenants/${tenant.id}/${action}`)
toast.success(`Tenant ${action}d successfully`)
toast.success(t(`tenants.${action}Success`))
loadTenants()
} catch (error) {
toast.error(`Failed to ${action} tenant`)
toast.error(t(`tenants.${action}Error`))
console.error(`Error ${action}ing tenant:`, error)
}
}
@@ -166,7 +167,7 @@ const Tenants = () => {
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{isActive ? 'Active' : 'Inactive'}
{isActive ? t('common.active') : t('common.inactive')}
</span>
)
}
@@ -185,15 +186,15 @@ const Tenants = () => {
<div className="mb-8">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Tenants</h1>
<p className="text-gray-600">Manage organizations and their configurations</p>
<h1 className="text-2xl font-bold text-gray-900">{t('tenants.title')}</h1>
<p className="text-gray-600">{t('tenants.description')}</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2"
>
<PlusIcon className="h-5 w-5" />
<span>Create Tenant</span>
<span>{t('tenants.addTenant')}</span>
</button>
</div>
</div>
@@ -204,7 +205,7 @@ const Tenants = () => {
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Search tenants..."
placeholder={t('tenants.searchPlaceholder')}
value={searchTerm}
onChange={handleSearch}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg w-full max-w-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@@ -218,28 +219,28 @@ const Tenants = () => {
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tenant
{t('tenants.tenant')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Domain
{t('tenants.domain')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Auth Provider
{t('tenants.authProvider')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Subscription
{t('tenants.subscription')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Users
{t('tenants.users')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
{t('tenants.created')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
{t('tenants.status')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
{t('tenants.actions')}
</th>
</tr>
</thead>
@@ -295,14 +296,14 @@ const Tenants = () => {
<button
onClick={() => handleEditTenant(tenant)}
className="text-blue-600 hover:text-blue-900 p-1 rounded"
title="Edit"
title={t('tenants.edit')}
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => deleteTenant(tenant.id)}
className="text-red-600 hover:text-red-900 p-1 rounded"
title="Delete"
title={t('tenants.delete')}
>
<TrashIcon className="h-4 w-4" />
</button>
@@ -322,24 +323,24 @@ const Tenants = () => {
disabled={pagination.offset === 0}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Previous
{t('common.previous')}
</button>
<button
onClick={() => loadTenants(pagination.offset + pagination.limit, searchTerm)}
disabled={pagination.offset + pagination.limit >= pagination.total}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Next
{t('common.next')}
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{pagination.offset + 1}</span> to{' '}
<span className="font-medium">
{Math.min(pagination.offset + pagination.limit, pagination.total)}
</span>{' '}
of <span className="font-medium">{pagination.total}</span> results
{t('common.showingResults', {
start: pagination.offset + 1,
end: Math.min(pagination.offset + pagination.limit, pagination.total),
total: pagination.total
})}
</p>
</div>
</div>
@@ -349,15 +350,15 @@ const Tenants = () => {
{tenants.length === 0 && !loading && (
<div className="text-center py-12">
<BuildingOfficeIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No tenants</h3>
<p className="mt-1 text-sm text-gray-500">Get started by creating a new tenant.</p>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('tenants.noTenants')}</h3>
<p className="mt-1 text-sm text-gray-500">{t('tenants.noTenantsDescription')}</p>
<div className="mt-6">
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<PlusIcon className="h-5 w-5 mr-2" />
Create Tenant
{t('tenants.createTenant')}
</button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import api from '../services/api'
import toast from 'react-hot-toast'
import { t } from '../utils/tempTranslations' // Temporary translation system
import { UsersIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'
const Users = () => {
@@ -18,7 +19,7 @@ const Users = () => {
const response = await api.get('/management/users')
setUsers(response.data.data || [])
} catch (error) {
toast.error('Failed to load users')
toast.error(t('users.loadError'))
console.error('Error loading users:', error)
} finally {
setLoading(false)
@@ -55,8 +56,8 @@ const Users = () => {
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
<p className="text-gray-600">Manage user accounts across all tenants</p>
<h1 className="text-2xl font-bold text-gray-900">{t('users.title')}</h1>
<p className="text-gray-600">{t('users.description')}</p>
</div>
<div className="mb-6">
@@ -64,7 +65,7 @@ const Users = () => {
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Search users..."
placeholder={t('users.searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg w-full max-w-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@@ -77,19 +78,19 @@ const Users = () => {
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
{t('users.user')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
{t('users.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
{t('users.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
{t('users.lastLogin')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
{t('users.created')}
</th>
</tr>
</thead>
@@ -135,9 +136,9 @@ const Users = () => {
{filteredUsers.length === 0 && (
<div className="text-center py-12">
<UsersIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No users found</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('users.noUsers')}</h3>
<p className="mt-1 text-sm text-gray-500">
{searchTerm ? 'Try adjusting your search criteria.' : 'No users have been created yet.'}
{searchTerm ? t('users.noUsersSearch') : t('users.noUsersDescription')}
</p>
</div>
)}

View File

@@ -26,10 +26,73 @@ api.interceptors.request.use(
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.log('🚨 Management API Error:', {
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
config: {
url: error.config?.url,
method: error.config?.method
}
});
if (error.response?.status === 401 || error.response?.status === 403) {
const errorData = error.response.data;
const errorCode = errorData?.errorCode || errorData?.error;
console.warn('🔐 Management Authentication Error:', {
error: errorData?.error,
message: errorData?.message,
errorCode: errorCode,
redirectToLogin: errorData?.redirectToLogin
});
// Show user-friendly error message based on error type
let userMessage = errorData?.message || 'Authentication error';
switch (errorCode) {
case 'TOKEN_EXPIRED':
userMessage = 'Your management session has expired. Please log in again.';
break;
case 'INVALID_TOKEN':
userMessage = 'Invalid management authentication. Please log in again.';
break;
case 'INSUFFICIENT_PERMISSIONS':
userMessage = errorData.message; // Use detailed message from backend
break;
default:
if (errorData?.message?.includes('management token')) {
userMessage = 'Your management session has expired. Please log in again.';
}
}
// Show error message (you can integrate with your notification system)
console.error('Management Error:', userMessage);
// Clear authentication data
console.log('🧹 Clearing management authentication data...');
localStorage.removeItem('management_token')
localStorage.removeItem('management_user')
window.location.href = '/login'
// Only redirect to login for authentication errors, not permission errors
if (error.response.status === 401 || errorData?.redirectToLogin !== false) {
console.log('🔄 Redirecting to management login page...');
try {
if (window.location.pathname !== '/login') {
console.log('🔄 Current path:', window.location.pathname, '- redirecting...');
window.location.href = '/login';
} else {
console.log('🔄 Already on login page, skipping redirect');
}
} catch (e) {
console.error('Failed to redirect via location.href:', e);
window.location.replace('/login');
}
} else {
console.log('🚫 Permission error - not redirecting to login');
// For permission errors, you might want to show a modal or toast notification
// instead of redirecting
}
}
return Promise.reject(error)
}

View File

@@ -0,0 +1,318 @@
// Temporary translation system for management portal until Docker rebuild
const translations = {
en: {
nav: {
dashboard: 'Dashboard',
tenants: 'Tenants',
users: 'Users',
security_logs: 'Security Logs',
system: 'System'
},
navigation: {
dashboard: 'Dashboard',
tenants: 'Tenants',
users: 'Users',
system: 'System',
logout: 'Logout'
},
app: {
title: 'UAM-ILS Management Portal'
},
tenants: {
title: 'Tenants',
description: 'Manage organizations and their configurations',
noTenants: 'No tenants',
noTenantsDescription: 'Get started by creating a new tenant.',
loadingTenants: 'Loading tenants...',
addTenant: 'Create Tenant',
createTenant: 'Create Tenant',
editTenant: 'Edit Tenant',
deleteTenant: 'Delete Tenant',
searchPlaceholder: 'Search tenants...',
tenant: 'Tenant',
tenantName: 'Tenant Name',
tenantSlug: 'Tenant Slug',
domain: 'Domain',
authProvider: 'Auth Provider',
subscription: 'Subscription',
users: 'Users',
status: 'Status',
created: 'Created',
lastActivity: 'Last Activity',
actions: 'Actions',
active: 'Active',
inactive: 'Inactive',
suspended: 'Suspended',
edit: 'Edit',
delete: 'Delete',
viewUsers: 'View Users',
confirmDelete: 'Are you sure you want to delete this tenant? This action cannot be undone.',
confirmActivate: 'Are you sure you want to activate "{{name}}"?',
confirmDeactivate: 'Are you sure you want to deactivate "{{name}}"? Users will not be able to access this tenant.',
deleteSuccess: 'Tenant deleted successfully',
deleteError: 'Failed to delete tenant',
activateSuccess: 'Tenant activated successfully',
activateError: 'Failed to activate tenant',
deactivateSuccess: 'Tenant deactivated successfully',
deactivateError: 'Failed to deactivate tenant',
deleteWarning: 'This action cannot be undone and will remove all associated data.',
totalTenants: 'Total Tenants',
activeTenants: 'Active Tenants'
},
users: {
title: 'Users',
description: 'Manage user accounts across all tenants',
noUsers: 'No users found',
noUsersDescription: 'No users have been created yet.',
noUsersSearch: 'Try adjusting your search criteria.',
addUser: 'Add User',
editUser: 'Edit User',
deleteUser: 'Delete User',
searchPlaceholder: 'Search users...',
user: 'User',
username: 'Username',
email: 'Email',
role: 'Role',
tenant: 'Tenant',
status: 'Status',
lastLogin: 'Last Login',
created: 'Created',
actions: 'Actions',
loadError: 'Failed to load users'
},
dashboard: {
title: 'Dashboard',
description: 'Overview of your UAMILS system',
totalTenants: 'Total Tenants',
activeTenants: 'Active Tenants',
totalUsers: 'Total Users',
activeSessions: 'Active Sessions',
systemHealth: 'System Health',
good: 'Good',
issues: 'Issues'
},
system: {
title: 'System Monitor',
description: 'Real-time system health and configuration monitoring',
serverInfo: 'Server Information',
databaseInfo: 'Database Information',
version: 'Version',
environment: 'Environment',
uptime: 'Uptime',
platform: 'Platform',
platformStatus: 'Platform Status',
lastUpdated: 'Last updated',
loadError: 'Failed to load system information',
noInformation: 'No system information available',
noInformationDescription: 'Unable to load system metrics.',
retry: 'Retry'
},
common: {
loading: 'Loading...',
error: 'Error',
success: 'Success',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
search: 'Search',
filter: 'Filter',
refresh: 'Refresh',
previous: 'Previous',
next: 'Next',
active: 'Active',
inactive: 'Inactive',
showingResults: 'Showing {{start}} to {{end}} of {{total}} results',
yes: 'Yes',
no: 'No',
ok: 'OK',
close: 'Close'
},
auth: {
login: 'Login',
username: 'Username',
password: 'Password',
loginButton: 'Sign In',
logout: 'Logout',
portalTitle: 'UAMILS Management Portal',
loginDescription: 'Sign in to manage tenants and system configuration',
signIn: 'Sign in',
signingIn: 'Signing in...',
adminAccess: 'Admin access required. Default: admin / admin123'
}
},
sv: {
nav: {
dashboard: 'Översikt',
tenants: 'Hyresgäster',
users: 'Användare',
system: 'System'
},
navigation: {
dashboard: 'Översikt',
tenants: 'Hyresgäster',
users: 'Användare',
system: 'System',
logout: 'Logga ut'
},
app: {
title: 'UAM-ILS Förvaltningsportal'
},
tenants: {
title: 'Hyresgäster',
description: 'Hantera organisationer och deras konfigurationer',
noTenants: 'Inga hyresgäster',
noTenantsDescription: 'Kom igång genom att skapa en ny hyresgäst.',
loadingTenants: 'Laddar hyresgäster...',
addTenant: 'Skapa hyresgäst',
createTenant: 'Skapa hyresgäst',
editTenant: 'Redigera hyresgäst',
deleteTenant: 'Ta bort hyresgäst',
searchPlaceholder: 'Sök hyresgäster...',
tenant: 'Hyresgäst',
tenantName: 'Hyresgästnamn',
tenantSlug: 'Hyresgästslug',
domain: 'Domän',
authProvider: 'Auth-leverantör',
subscription: 'Prenumeration',
users: 'Användare',
status: 'Status',
created: 'Skapad',
lastActivity: 'Senaste aktivitet',
actions: 'Åtgärder',
active: 'Aktiv',
inactive: 'Inaktiv',
suspended: 'Avstängd',
edit: 'Redigera',
delete: 'Ta bort',
viewUsers: 'Visa användare',
confirmDelete: 'Är du säker på att du vill ta bort denna hyresgäst? Denna åtgärd kan inte ångras.',
confirmActivate: 'Är du säker på att du vill aktivera "{{name}}"?',
confirmDeactivate: 'Är du säker på att du vill deaktivera "{{name}}"? Användare kommer inte att kunna komma åt denna hyresgäst.',
deleteSuccess: 'Hyresgäst borttagen framgångsrikt',
deleteError: 'Misslyckades att ta bort hyresgäst',
activateSuccess: 'Hyresgäst aktiverad framgångsrikt',
activateError: 'Misslyckades att aktivera hyresgäst',
deactivateSuccess: 'Hyresgäst deaktiverad framgångsrikt',
deactivateError: 'Misslyckades att deaktivera hyresgäst',
deleteWarning: 'Denna åtgärd kan inte ångras och kommer att ta bort all associerad data.',
totalTenants: 'Totala hyresgäster',
activeTenants: 'Aktiva hyresgäster'
},
users: {
title: 'Användare',
description: 'Hantera användarkonton över alla hyresgäster',
noUsers: 'Inga användare hittades',
noUsersDescription: 'Inga användare har skapats ännu.',
noUsersSearch: 'Prova att justera dina sökkriterier.',
addUser: 'Lägg till användare',
editUser: 'Redigera användare',
deleteUser: 'Ta bort användare',
searchPlaceholder: 'Sök användare...',
user: 'Användare',
username: 'Användarnamn',
email: 'E-post',
role: 'Roll',
tenant: 'Hyresgäst',
status: 'Status',
lastLogin: 'Senaste inloggning',
created: 'Skapad',
actions: 'Åtgärder',
loadError: 'Misslyckades att ladda användare'
},
dashboard: {
title: 'Instrumentpanel',
description: 'Översikt av ditt UAMILS-system',
totalTenants: 'Totala hyresgäster',
activeTenants: 'Aktiva hyresgäster',
totalUsers: 'Totala användare',
activeSessions: 'Aktiva sessioner',
systemHealth: 'Systemhälsa',
good: 'Bra',
issues: 'Problem'
},
system: {
title: 'Systemövervakning',
description: 'Realtid systemhälsa och konfigurationsövervakning',
serverInfo: 'Serverinformation',
databaseInfo: 'Databasinformation',
version: 'Version',
environment: 'Miljö',
uptime: 'Drifttid',
platform: 'Plattform',
platformStatus: 'Plattformsstatus',
lastUpdated: 'Senast uppdaterad',
loadError: 'Misslyckades att ladda systeminformation',
noInformation: 'Ingen systeminformation tillgänglig',
noInformationDescription: 'Kunde inte ladda systemstatistik.',
retry: 'Försök igen'
},
common: {
loading: 'Laddar...',
error: 'Fel',
success: 'Framgång',
cancel: 'Avbryt',
save: 'Spara',
delete: 'Ta bort',
edit: 'Redigera',
add: 'Lägg till',
search: 'Sök',
filter: 'Filtrera',
refresh: 'Uppdatera',
previous: 'Föregående',
next: 'Nästa',
active: 'Aktiv',
inactive: 'Inaktiv',
showingResults: 'Visar {{start}} till {{end}} av {{total}} resultat',
yes: 'Ja',
no: 'Nej',
ok: 'OK',
close: 'Stäng'
},
auth: {
login: 'Logga in',
username: 'Användarnamn',
password: 'Lösenord',
loginButton: 'Logga in',
logout: 'Logga ut',
portalTitle: 'UAMILS Förvaltningsportal',
loginDescription: 'Logga in för att hantera hyresgäster och systemkonfiguration',
signIn: 'Logga in',
signingIn: 'Loggar in...',
adminAccess: 'Administratörsåtkomst krävs. Standard: admin / admin123'
}
}
};
let currentLanguage = localStorage.getItem('language') || 'en';
export const t = (key, params = {}) => {
const keys = key.split('.');
let value = translations[currentLanguage];
for (const k of keys) {
value = value?.[k];
}
let result = value || key;
// Simple parameter interpolation
if (params && typeof params === 'object') {
Object.keys(params).forEach(param => {
const placeholder = `{{${param}}}`;
result = result.replace(new RegExp(placeholder, 'g'), params[param]);
});
}
return result;
};
export const changeLanguage = (lang) => {
currentLanguage = lang;
localStorage.setItem('language', lang);
// Trigger a page refresh to update all components
window.location.reload();
};
export const getCurrentLanguage = () => currentLanguage;

View File

@@ -80,6 +80,30 @@ server {
proxy_read_timeout 60s;
}
# Upload routes for logos and other files
location /uploads/ {
# Add tenant header for backend
proxy_set_header X-Tenant-Subdomain $tenant;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
proxy_redirect off;
# Cache uploaded files for 1 month
proxy_cache_valid 200 30d;
add_header Cache-Control "public, max-age=2592000";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Authentication routes with stricter rate limiting
location /auth/ {
limit_req zone=auth burst=10 nodelay;

View File

@@ -1,13 +1,17 @@
# Backend Dockerfile for Drone Detection System
FROM node:18-alpine AS base
# Install system dependencies
# Install system dependencies and create user in one layer
RUN apk add --no-cache \
python3 \
make \
g++ \
curl \
dumb-init
dumb-init \
netcat-openbsd \
su-exec && \
addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Set working directory
WORKDIR /app
@@ -19,24 +23,18 @@ COPY package*.json ./
RUN npm install --only=production && \
npm cache clean --force
# Copy application code
COPY . .
# Create directories and copy files with proper ownership in one step
RUN mkdir -p logs uploads/logos
COPY --chown=nodejs:nodejs . .
COPY --chown=root:root docker-entrypoint.sh /usr/local/bin/
# Create logs directory
RUN mkdir -p logs
# Set all permissions in one layer
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && \
chmod -R 755 /app/uploads && \
chown -R nodejs:nodejs /app
# Create uploads directory for logos
RUN mkdir -p uploads/logos
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Set ownership
RUN chown -R nodejs:nodejs /app
# Switch to non-root user
USER nodejs
# Stay as root for the entrypoint (it will switch to nodejs user)
# USER nodejs (commented out - entrypoint will handle user switching)
# Expose port
EXPOSE 3001
@@ -45,8 +43,8 @@ EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3001/api/health || exit 1
# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# Use custom entrypoint that handles permissions and user switching
ENTRYPOINT ["docker-entrypoint.sh"]
# Start the application
CMD ["npm", "start"]

View File

@@ -0,0 +1,65 @@
const { Device, Tenant } = require('./models');
async function createTestDevice() {
try {
// Find the uamils-ab tenant
const tenant = await Tenant.findOne({ where: { slug: 'uamils-ab' } });
if (!tenant) {
console.log('❌ Tenant uamils-ab not found');
return;
}
// Check if device "1941875381" already exists
const existingDevice = await Device.findOne({ where: { id: '1941875381' } });
if (existingDevice) {
console.log('✅ Test device already exists');
console.log(` ID: ${existingDevice.id}`);
console.log(` Name: ${existingDevice.name}`);
console.log(` Approved: ${existingDevice.is_approved}`);
console.log(` Active: ${existingDevice.is_active}`);
console.log(` Tenant: ${existingDevice.tenant_id}`);
return;
}
// Create test device with the ID from your packet
const testDevice = await Device.create({
id: '1941875381',
name: 'Test Device 1941875381',
type: 'drone_detector',
location: 'Test Location',
description: 'Test drone detector device for API testing',
is_approved: true,
is_active: true,
tenant_id: tenant.id,
coordinates: JSON.stringify({
latitude: 0,
longitude: 0
}),
config: JSON.stringify({
detection_range: 25000,
alert_threshold: 5000,
frequency_bands: ['2.4GHz', '5.8GHz'],
sensitivity: 'high'
})
});
console.log('✅ Test device created successfully');
console.log(` ID: ${testDevice.id}`);
console.log(` Name: ${testDevice.name}`);
console.log(` Tenant: ${testDevice.tenant_id}`);
console.log(` Approved: ${testDevice.is_approved}`);
} catch (error) {
console.error('❌ Error creating test device:', error.message);
}
}
createTestDevice()
.then(() => {
console.log('✅ Test device setup completed');
process.exit(0);
})
.catch(error => {
console.error('❌ Setup failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,35 @@
#!/bin/sh
# This script runs as root to set up permissions, then switches to nodejs user
# Ensure uploads directory exists and has correct permissions
mkdir -p /app/uploads/logos
chown -R nodejs:nodejs /app/uploads
chmod -R 755 /app/uploads
# Wait for database to be ready
echo "Waiting for database to be ready..."
while ! nc -z postgres 5432; do
echo "Database not ready, waiting..."
sleep 1
done
echo "Database is ready!"
# Check if this is a fresh database by looking for the devices table
echo "Checking database state..."
# Always run database setup (includes migrations + seeding if needed)
echo "Running database setup and migrations..."
su-exec nodejs npm run db:setup
# Check if setup/migrations were successful
if [ $? -eq 0 ]; then
echo "Database initialization completed successfully"
# Set flag to indicate database is already initialized
export DB_INITIALIZED=true
else
echo "Database initialization failed"
exit 1
fi
# Switch to nodejs user and execute the command with dumb-init for signal handling
exec su-exec nodejs dumb-init -- "$@"

View File

@@ -90,6 +90,21 @@ app.use('/api/', limiter);
const ipRestriction = new IPRestrictionMiddleware();
app.use((req, res, next) => ipRestriction.checkIPRestriction(req, res, next));
// Tenant-specific API rate limiting (for authenticated endpoints)
const { enforceApiRateLimit } = require('./middleware/tenant-limits');
app.use('/api', (req, res, next) => {
// Skip tenant rate limiting for management and data-retention endpoints
if (req.path.startsWith('/management') || req.path.startsWith('/data-retention')) {
return next();
}
// Apply tenant rate limiting only to authenticated API endpoints
if (req.headers.authorization) {
return enforceApiRateLimit()(req, res, next);
}
next();
});
// Make io available to routes
app.use((req, res, next) => {
req.io = io;
@@ -125,7 +140,7 @@ app.use(errorHandler);
// Socket.IO initialization
initializeSocketHandlers(io);
const PORT = process.env.PORT || 3001;
const PORT = process.env.PORT || 5000;
// Migration runner
const runMigrations = async () => {
@@ -156,32 +171,37 @@ async function startServer() {
await sequelize.authenticate();
console.log('Database connected successfully.');
// Run migrations first
try {
await runMigrations();
} catch (migrationError) {
console.error('Migration error:', migrationError);
console.log('Continuing with database sync...');
}
// Always sync database in containerized environments or development
// Check if tables exist before syncing
// STEP 1: Sync database first to create base tables
try {
// Use alter: false to prevent destructive changes in production
await sequelize.sync({ force: false, alter: false });
console.log('Database synchronized.');
// Seed database with initial data
await seedDatabase();
} catch (syncError) {
console.error('Database sync error:', syncError);
// If sync fails, try force sync (this will drop and recreate tables)
console.log('Attempting force sync...');
await sequelize.sync({ force: true });
console.log('Database force synchronized.');
}
// STEP 2: Run migrations after tables exist (skip if DB_INITIALIZED is set)
if (!process.env.DB_INITIALIZED) {
try {
await runMigrations();
} catch (migrationError) {
console.error('Migration error:', migrationError);
throw migrationError; // Fatal error - don't continue
}
// Seed database with initial data
await seedDatabase();
// STEP 3: Seed database with initial data
try {
await seedDatabase();
} catch (seedError) {
console.error('Seeding error:', seedError);
throw seedError; // Fatal error - don't continue
}
} else {
console.log(' Database already initialized by setup script, skipping migrations and seeding');
}
server.listen(PORT, () => {
@@ -217,7 +237,7 @@ async function startServer() {
deviceHealthService.start();
console.log('🏥 Device health monitoring: ✅ Started');
// Graceful shutdown for device health service
// Graceful shutdown for services
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
deviceHealthService.stop();

View File

@@ -1,4 +1,5 @@
const jwt = require('jsonwebtoken');
const { createErrorResponse, getLanguageFromRequest } = require('../utils/i18n');
// Allow models to be injected for testing
let models = null;
@@ -15,16 +16,33 @@ function setModels(testModels) {
async function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!authHeader) {
const errorResponse = createErrorResponse(req, 401, 'NO_TOKEN');
errorResponse.json.redirectToLogin = true;
return res.status(errorResponse.status).json(errorResponse.json);
}
// Check for proper Bearer token format
if (!authHeader.startsWith('Bearer ')) {
const errorResponse = createErrorResponse(req, 401, 'INVALID_TOKEN');
errorResponse.json.redirectToLogin = true;
return res.status(errorResponse.status).json(errorResponse.json);
}
const token = authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({
success: false,
message: 'Access token required'
});
const errorResponse = createErrorResponse(req, 401, 'NO_TOKEN');
errorResponse.json.redirectToLogin = true;
return res.status(errorResponse.status).json(errorResponse.json);
}
try {
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is not set');
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Log what's in the token for debugging
@@ -48,14 +66,30 @@ async function authenticateToken(req, res, next) {
}]
});
if (!user || !user.is_active) {
return res.status(401).json({
success: false,
message: 'Invalid or inactive user'
});
if (!user) {
const errorResponse = createErrorResponse(req, 401, 'USER_NOT_FOUND');
errorResponse.json.redirectToLogin = true;
return res.status(errorResponse.status).json(errorResponse.json);
}
req.user = user;
if (!user.is_active) {
const errorResponse = createErrorResponse(req, 401, 'ACCOUNT_DEACTIVATED');
errorResponse.json.redirectToLogin = true;
return res.status(errorResponse.status).json(errorResponse.json);
}
// Set user context with expected properties for compatibility
req.user = {
id: user.id,
userId: user.id, // For backward compatibility
username: user.username,
email: user.email,
role: user.role,
is_active: user.is_active,
tenant_id: user.tenant_id,
tenant: user.tenant,
tenantId: tenantId || (user.tenant ? user.tenant.slug : undefined) // Include tenantId in user object
};
// Set tenant context - prefer JWT tenantId, fallback to user's tenant
if (tenantId) {
@@ -70,14 +104,40 @@ async function authenticateToken(req, res, next) {
next();
} catch (error) {
// Only log unexpected errors, not common JWT validation failures
// Log authentication errors for monitoring (but not in tests)
if (process.env.NODE_ENV !== 'test' || error.name === 'TypeError') {
console.error('Token verification error:', error);
console.error('🔐 Authentication error:', {
error: error.name,
message: error.message,
userAgent: req.headers['user-agent'],
ip: req.ip || req.connection.remoteAddress,
path: req.path
});
}
return res.status(401).json({
success: false,
message: 'Invalid token'
});
// Handle specific JWT errors with detailed responses
if (error.name === 'TokenExpiredError') {
const errorResponse = createErrorResponse(req, 401, 'TOKEN_EXPIRED');
errorResponse.json.redirectToLogin = true;
return res.status(errorResponse.status).json(errorResponse.json);
}
if (error.name === 'JsonWebTokenError') {
const errorResponse = createErrorResponse(req, 401, 'INVALID_TOKEN');
errorResponse.json.redirectToLogin = true;
return res.status(errorResponse.status).json(errorResponse.json);
}
if (error.name === 'NotBeforeError') {
const errorResponse = createErrorResponse(req, 401, 'TOKEN_NOT_ACTIVE');
errorResponse.json.redirectToLogin = true;
return res.status(errorResponse.status).json(errorResponse.json);
}
// Generic authentication error
const errorResponse = createErrorResponse(req, 401, 'AUTHENTICATION_FAILED');
errorResponse.json.redirectToLogin = true;
return res.status(errorResponse.status).json(errorResponse.json);
}
}
@@ -86,7 +146,10 @@ function requireRole(roles) {
if (!req.user) {
return res.status(401).json({
success: false,
message: 'Authentication required'
message: 'Authentication required',
error: 'NO_AUTH',
errorCode: 'AUTH_REQUIRED',
redirectToLogin: true
});
}
@@ -94,7 +157,12 @@ function requireRole(roles) {
if (!userRoles.includes(req.user.role)) {
return res.status(403).json({
success: false,
message: 'Insufficient permissions'
message: `Access denied. This action requires ${userRoles.join(' or ')} permissions, but you have ${req.user.role} permissions.`,
error: 'INSUFFICIENT_PERMISSIONS',
errorCode: 'PERMISSION_DENIED',
userRole: req.user.role,
requiredRoles: userRoles,
redirectToLogin: false // Don't redirect for permission issues
});
}

View File

@@ -5,6 +5,7 @@
const { Tenant } = require('../models');
const MultiTenantAuth = require('./multi-tenant-auth');
const securityLogger = require('./logger');
class IPRestrictionMiddleware {
constructor() {
@@ -168,27 +169,21 @@ class IPRestrictionMiddleware {
// Skip IP restrictions for management routes - they have their own access controls
if (path.startsWith('/api/management/')) {
console.log('🔍 IP Restriction - Skipping for management route:', path);
return next();
}
// Skip IP restrictions for auth config - users need to see login form and get proper error
if (path === '/api/auth/config') {
console.log('🔍 IP Restriction - Skipping for auth config route');
return next();
}
console.log('🔍 IP Restriction Check - Path:', req.path, 'Method:', req.method);
// Determine tenant (check req.tenant first for test contexts)
let tenantId = req.tenant;
if (!tenantId) {
tenantId = await this.multiAuth.determineTenant(req);
}
console.log('🔍 IP Restriction - Determined tenant:', tenantId);
if (!tenantId) {
console.log('🔍 IP Restriction - No tenant found, skipping IP check');
// No tenant found, continue without IP checking
return next();
}
@@ -200,32 +195,16 @@ class IPRestrictionMiddleware {
attributes: ['id', 'slug', 'ip_restriction_enabled', 'ip_whitelist', 'ip_restriction_message', 'updated_at']
});
if (!tenant) {
console.log('🔍 IP Restriction - Tenant not found in database:', tenantId);
return next();
}
console.log('🔍 IP Restriction - Tenant config (fresh from DB):', {
id: tenant.id,
slug: tenant.slug,
ip_restriction_enabled: tenant.ip_restriction_enabled,
ip_whitelist: tenant.ip_whitelist,
updated_at: tenant.updated_at
});
// Check if IP restrictions are enabled
if (!tenant.ip_restriction_enabled) {
console.log('🔍 IP Restriction - Restrictions disabled for tenant');
return next();
}
// Get client IP
const clientIP = this.getClientIP(req);
console.log('🔍 IP Restriction - Client IP:', clientIP);
console.log('🔍 IP Restriction - Request headers:', {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip'],
'remote-address': req.connection?.remoteAddress
});
// Parse allowed IPs (convert string to array)
let allowedIPs = [];
@@ -239,13 +218,15 @@ class IPRestrictionMiddleware {
// Check if IP is allowed
const isAllowed = this.isIPAllowed(clientIP, allowedIPs);
console.log('🔍 IP Restriction - Is IP allowed:', isAllowed, 'Allowed IPs:', allowedIPs);
if (!isAllowed) {
console.log(`🚫 IP Access Denied: ${clientIP} attempted to access tenant "${tenantId}"`);
// Log the access attempt for security auditing
console.log(`[SECURITY AUDIT] ${new Date().toISOString()} - IP ${clientIP} denied access to tenant ${tenantId} - User-Agent: ${req.headers['user-agent']}`);
securityLogger.logIPRestriction(
clientIP,
tenantId,
req.headers['user-agent'],
true // denied
);
return res.status(403).json({
success: false,
@@ -256,7 +237,6 @@ class IPRestrictionMiddleware {
}
// IP is allowed, continue
console.log(`✅ IP Access Allowed: ${clientIP} accessing tenant "${tenantId}"`);
next();
} catch (error) {

160
server/middleware/logger.js Normal file
View File

@@ -0,0 +1,160 @@
const fs = require('fs');
const path = require('path');
class SecurityLogger {
constructor() {
// Default to logs directory, but allow override via environment
this.logDir = process.env.SECURITY_LOG_DIR || path.join(__dirname, '..', 'logs');
this.logFile = path.join(this.logDir, 'security-audit.log');
// Ensure log directory exists
this.ensureLogDirectory();
// Initialize models reference (will be set when needed)
this.models = null;
}
// Set models reference for database logging
setModels(models) {
this.models = models;
}
ensureLogDirectory() {
try {
if (!fs.existsSync(this.logDir)) {
fs.mkdirSync(this.logDir, { recursive: true });
}
} catch (error) {
console.error('Failed to create log directory:', error.message);
// Fallback to console logging only
this.logFile = null;
}
}
async logSecurityEvent(level, message, metadata = {}) {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
level: level.toUpperCase(),
message,
...metadata
};
// Always log to console for immediate visibility
console.log(`[SECURITY AUDIT] ${timestamp} - ${message}`);
// Also log to file if available
if (this.logFile) {
try {
const logLine = JSON.stringify(logEntry) + '\n';
fs.appendFileSync(this.logFile, logLine);
} catch (error) {
console.error('Failed to write to security log file:', error.message);
}
}
// Store in database if models are available
if (this.models && this.models.AuditLog) {
try {
await this.models.AuditLog.create({
timestamp: new Date(),
level: level.toUpperCase(),
action: metadata.action || 'unknown',
message,
user_id: metadata.userId || null,
username: metadata.username || null,
tenant_id: metadata.tenantId || null,
tenant_slug: metadata.tenantSlug || null,
ip_address: metadata.ip || null,
user_agent: metadata.userAgent || null,
path: metadata.path || null,
metadata: metadata,
success: this.determineSuccess(level, metadata)
});
} catch (error) {
console.error('Failed to store audit log in database:', error.message);
}
}
}
determineSuccess(level, metadata) {
// Determine if the action was successful based on level and metadata
if (metadata.hasOwnProperty('success')) {
return metadata.success;
}
// Assume success for info level, failure for error/critical
switch (level.toUpperCase()) {
case 'INFO':
return true;
case 'WARNING':
return null; // Neutral
case 'ERROR':
case 'CRITICAL':
return false;
default:
return null;
}
}
logIPRestriction(ip, tenant, userAgent, denied = true) {
const action = denied ? 'denied access to' : 'granted access to';
this.logSecurityEvent('WARNING', `IP ${ip} ${action} tenant ${tenant}`, {
type: 'IP_RESTRICTION',
ip,
tenant,
userAgent: userAgent || 'unknown',
denied
});
}
logAuthFailure(reason, metadata = {}) {
this.logSecurityEvent('ERROR', `Authentication failure: ${reason}`, {
type: 'AUTH_FAILURE',
reason,
...metadata
});
}
logSuspiciousActivity(activity, metadata = {}) {
this.logSecurityEvent('CRITICAL', `Suspicious activity detected: ${activity}`, {
type: 'SUSPICIOUS_ACTIVITY',
activity,
...metadata
});
}
// Get recent security events for monitoring
getRecentEvents(count = 100) {
if (!this.logFile || !fs.existsSync(this.logFile)) {
return [];
}
try {
const content = fs.readFileSync(this.logFile, 'utf8');
const lines = content.trim().split('\n').filter(line => line);
return lines
.slice(-count)
.map(line => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
} catch (error) {
console.error('Failed to read security log file:', error.message);
return [];
}
}
}
// Singleton instance
const securityLogger = new SecurityLogger();
module.exports = {
SecurityLogger,
securityLogger
};

View File

@@ -30,6 +30,15 @@ class MultiTenantAuth {
this.models = models;
}
/**
* Check if a string is an IP address
*/
isIPAddress(str) {
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
return ipv4Regex.test(str) || ipv6Regex.test(str);
}
/**
* Initialize all authentication providers
*/
@@ -44,16 +53,8 @@ class MultiTenantAuth {
* Can be from subdomain, header, or JWT
*/
async determineTenant(req) {
console.log('🚀 DETERMINE TENANT FUNCTION START');
console.log('===== DETERMINE TENANT CALLED =====');
console.log('🏢 req.user:', req.user);
console.log('🏢 req.headers.host:', req.headers?.host);
console.log('🏢 req.url:', req.url);
console.log('🏢 req.path:', req.path);
// Method 1: From authenticated user (highest priority)
if (req.user && req.user.tenantId) {
console.log('🏢 Tenant from req.user.tenantId:', req.user.tenantId);
return req.user.tenantId;
}
@@ -78,29 +79,35 @@ class MultiTenantAuth {
// Method 4: x-forwarded-host header (for proxied requests)
const forwardedHost = req.headers['x-forwarded-host'];
console.log('🏢 x-forwarded-host header:', forwardedHost);
if (forwardedHost) {
const subdomain = forwardedHost.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'api' && !subdomain.includes(':')) {
console.log('🏢 Tenant from x-forwarded-host:', subdomain);
return subdomain;
}
}
// Method 5: Subdomain (tenant.yourapp.com)
const hostname = req.hostname || req.headers.host || '';
if (hostname && !hostname.startsWith('localhost')) {
const subdomain = hostname.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'api' && !subdomain.includes(':')) {
return subdomain;
// Remove port number if present
const hostWithoutPort = hostname.split(':')[0];
// Skip if localhost or IP address
if (hostname && !hostname.startsWith('localhost') && !this.isIPAddress(hostWithoutPort)) {
const hostParts = hostWithoutPort.split('.');
// Only treat as subdomain if there are at least 2 parts (subdomain.domain.com)
// and the first part is not a common root domain
if (hostParts.length >= 3) {
const subdomain = hostParts[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'api') {
return subdomain;
}
}
}
// Method 6: URL path (/tenant2/api/...)
const pathSegments = (req.path || req.url || '').split('/').filter(segment => segment);
console.log('🏢 URL path segments:', pathSegments, 'from path:', req.path, 'or url:', req.url);
const urlPath = req.path || req.url || '';
const pathSegments = urlPath.split('/').filter(segment => segment);
if (pathSegments.length > 0 && pathSegments[0] !== 'api') {
console.log('🏢 Tenant from URL path:', pathSegments[0]);
return pathSegments[0];
}
@@ -111,11 +118,9 @@ class MultiTenantAuth {
// Return null for localhost without tenant info
if (hostname && hostname.startsWith('localhost')) {
console.log('🏢 Localhost detected, returning null');
return null;
}
console.log('🏢 No tenant determined, returning null');
// Default to null
return null;
}
@@ -146,28 +151,39 @@ class MultiTenantAuth {
async authenticate(req, res, next) {
try {
const tenantId = await this.determineTenant(req);
const authConfig = await this.getTenantAuthConfig(tenantId);
// Attach tenant info to request
req.tenant = { id: tenantId, authConfig };
// Route to appropriate authentication provider
switch (authConfig.type) {
case AuthProviders.LOCAL:
return this.authenticateLocal(req, res, next);
case AuthProviders.SAML:
return this.authenticateSAML(req, res, next);
case AuthProviders.OAUTH:
return this.authenticateOAuth(req, res, next);
case AuthProviders.LDAP:
return this.authenticateLDAP(req, res, next);
default:
return this.authenticateLocal(req, res, next);
// Check if tenant could be determined
if (!tenantId) {
return res.status(400).json({
success: false,
message: 'Unable to determine tenant'
});
}
// Check if tenant exists in database
const TenantModel = this.models ? this.models.Tenant : Tenant;
const tenant = await TenantModel.findOne({ where: { slug: tenantId } });
if (!tenant) {
return res.status(404).json({
success: false,
message: 'Tenant not found'
});
}
// Check if tenant is active
if (!tenant.is_active) {
return res.status(403).json({
success: false,
message: 'Tenant is not active'
});
}
// Attach tenant info to request (tests expect req.tenant to be the slug)
req.tenant = tenantId;
// Call next middleware
next();
} catch (error) {
console.error('Multi-tenant auth error:', error);
return res.status(500).json({

View File

@@ -32,7 +32,11 @@ const PERMISSIONS = {
'dashboard.view': 'View dashboard',
'devices.view': 'View devices',
'devices.manage': 'Add, edit, delete devices',
'devices.create': 'Create new devices',
'devices.update': 'Update existing devices',
'devices.delete': 'Delete devices',
'detections.view': 'View detections',
'detections.create': 'Create detections',
'alerts.view': 'View alerts',
'alerts.manage': 'Manage alert configurations',
'debug.access': 'Access debug information'
@@ -48,8 +52,8 @@ const ROLES = {
'users.view', 'users.create', 'users.edit', 'users.delete', 'users.manage_roles',
'auth.view', 'auth.edit',
'dashboard.view',
'devices.view', 'devices.manage',
'detections.view',
'devices.view', 'devices.create', 'devices.update', 'devices.delete',
'detections.view', 'detections.create',
'alerts.view', 'alerts.manage',
'debug.access'
],
@@ -58,7 +62,6 @@ const ROLES = {
'user_admin': [
'tenant.view',
'users.view', 'users.create', 'users.edit', 'users.delete', 'users.manage_roles',
'roles.read',
'dashboard.view',
'devices.view',
'detections.view',
@@ -74,16 +77,13 @@ const ROLES = {
'dashboard.view',
'devices.view',
'detections.view',
'alerts.view', 'alerts.create', 'alerts.edit',
'audit_logs.view'
'alerts.view', 'alerts.manage'
],
// Branding/marketing specialist
'branding_admin': [
'tenant.view',
'branding.view', 'branding.edit', 'branding.create',
'ui_customization.create',
'logo.upload',
'branding.view', 'branding.edit',
'dashboard.view',
'devices.view',
'detections.view',
@@ -94,7 +94,7 @@ const ROLES = {
'operator': [
'tenant.view',
'dashboard.view',
'devices.view', 'devices.manage', 'devices.update',
'devices.view', 'devices.create', 'devices.update',
'detections.view', 'detections.create',
'alerts.view', 'alerts.manage'
],
@@ -115,86 +115,84 @@ const ROLES = {
* @returns {boolean} - True if user has permission
*/
const hasPermission = (userRole, permission) => {
if (!userRole || !ROLES[userRole]) {
if (!userRole) {
return false;
}
return ROLES[userRole].includes(permission);
// Handle case-insensitive role lookup
const normalizedRole = userRole.toLowerCase();
if (!ROLES[normalizedRole]) {
return false;
}
return ROLES[normalizedRole].includes(permission);
};
/**
* Compatibility function for tests - converts resource.action format to permission
* Check permission using resource and action (for backwards compatibility)
* @param {string} userRole - The user's role
* @param {string} resource - The resource (e.g., 'devices', 'users')
* @param {string} action - The action (e.g., 'read', 'create', 'update', 'delete')
* @param {string} action - The action (e.g., 'create', 'read', 'update', 'delete')
* @returns {boolean} - True if user has permission
*/
const checkPermission = (userRole, resource, action) => {
// Normalize inputs to lowercase for case-insensitive comparison
const normalizedRole = userRole ? userRole.toLowerCase() : '';
const normalizedResource = resource ? resource.toLowerCase() : '';
const normalizedAction = action ? action.toLowerCase() : '';
// Map common actions to our permission system
const actionMap = {
'read': 'view',
'create': 'create',
'update': 'edit',
'delete': 'delete',
'manage': 'manage'
// Map resource + action to permission strings
const permissionMappings = {
// Device permissions
'devices.create': 'devices.create',
'devices.read': 'devices.view',
'devices.update': 'devices.update',
'devices.delete': 'devices.delete',
// User permissions
'users.create': 'users.create',
'users.read': 'users.view',
'users.update': 'users.edit',
'users.delete': 'users.delete',
// Tenant permissions
'tenants.create': 'tenant.edit',
'tenants.read': 'tenant.view',
'tenants.update': 'tenant.edit',
'tenants.delete': 'tenant.edit',
// Role permissions
'roles.read': 'users.manage_roles',
// Alert permissions
'alerts.create': 'alerts.manage',
'alerts.read': 'alerts.view',
'alerts.update': 'alerts.manage',
'alerts.delete': 'alerts.manage',
// Detection permissions
'detections.create': 'detections.create',
'detections.read': 'detections.view',
'detections.update': 'detections.view',
'detections.delete': 'detections.view',
// Security permissions
'ip_restrictions.read': 'security.view',
'ip_restrictions.update': 'security.edit',
'audit_logs.read': 'security.view',
// Branding permissions
'branding.update': 'branding.edit',
'ui_customization.create': 'branding.edit',
'logo.upload': 'branding.edit',
// Dashboard permissions
'dashboard.read': 'dashboard.view'
};
// Special cases for resource mapping
const resourceMap = {
'devices': 'devices',
'users': 'users',
'detections': 'detections',
'alerts': 'alerts',
'dashboard': 'dashboard',
'branding': 'branding',
'security': 'security',
'ip_restrictions': 'security',
'audit_logs': 'security',
'ui_customization': 'branding'
};
const permissionKey = `${resource}.${action}`;
const permission = permissionMappings[permissionKey];
const mappedResource = resourceMap[normalizedResource] || normalizedResource;
const mappedAction = actionMap[normalizedAction] || normalizedAction;
const permission = `${mappedResource}.${mappedAction}`;
if (!permission) {
return false; // Unknown permission
}
return hasPermission(normalizedRole, permission);
};
/**
* Compatibility function for tests - creates middleware for specific resource.action
* @param {string} resource - The resource (e.g., 'devices', 'users')
* @param {string} action - The action (e.g., 'read', 'create', 'update', 'delete')
* @returns {Function} - Express middleware function
*/
const requirePermission = (resource, action) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: 'User not authenticated'
});
}
if (!req.user.role) {
return res.status(403).json({
success: false,
message: 'Insufficient permissions'
});
}
if (!checkPermission(req.user.role, resource, action)) {
return res.status(403).json({
success: false,
message: 'Insufficient permissions'
});
}
next();
};
return hasPermission(userRole, permission);
};
/**
@@ -234,6 +232,42 @@ const getRoles = () => {
return Object.keys(ROLES);
};
/**
* Express middleware to check permissions based on resource and action
* @param {string} resource - The resource being accessed
* @param {string} action - The action being performed
* @returns {Function} - Express middleware function
*/
const requirePermission = (resource, action) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: 'User not authenticated'
});
}
if (!req.user.role) {
return res.status(403).json({
success: false,
message: 'Insufficient permissions'
});
}
const userRole = req.user.role;
const hasRequiredPermission = checkPermission(userRole, resource, action);
if (!hasRequiredPermission) {
return res.status(403).json({
success: false,
message: 'Insufficient permissions'
});
}
next();
};
};
/**
* Express middleware to check permissions
* @param {Array<string>} requiredPermissions - Required permissions
@@ -303,11 +337,11 @@ module.exports = {
ROLES,
hasPermission,
checkPermission,
requirePermission,
hasAnyPermission,
hasAllPermissions,
getPermissions,
getRoles,
requirePermission,
requirePermissions,
requireAnyPermission
};

View File

@@ -0,0 +1,354 @@
/**
* Tenant Limits Middleware
* Enforces tenant subscription limits for users, devices, API rate limits, etc.
*/
const { securityLogger } = require('./logger');
// Initialize multi-tenant auth
const MultiTenantAuth = require('./multi-tenant-auth');
const multiAuth = new MultiTenantAuth();
/**
* Redis-like in-memory store for rate limiting (replace with Redis in production)
*/
class RateLimitStore {
constructor() {
this.store = new Map();
this.cleanup();
}
get(key) {
const data = this.store.get(key);
if (!data) return null;
// Check if expired
if (Date.now() > data.expires) {
this.store.delete(key);
return null;
}
return data;
}
set(key, value, ttlMs) {
this.store.set(key, {
...value,
expires: Date.now() + ttlMs
});
}
delete(key) {
this.store.delete(key);
}
// Clean up expired entries every minute
cleanup() {
setInterval(() => {
const now = Date.now();
for (const [key, data] of this.store.entries()) {
if (now > data.expires) {
this.store.delete(key);
}
}
}, 60000);
}
}
const rateLimitStore = new RateLimitStore();
/**
* Get tenant and validate access
*/
async function getTenantFromRequest(req) {
const tenantId = await multiAuth.determineTenant(req);
if (!tenantId) {
throw new Error('Unable to determine tenant');
}
const { Tenant } = require('../models');
const tenant = await Tenant.findOne({ where: { slug: tenantId } });
if (!tenant) {
throw new Error('Tenant not found');
}
return tenant;
}
/**
* Check if tenant has reached user limit
*/
async function checkUserLimit(tenantId, excludeUserId = null) {
const { User } = require('../models');
const whereClause = { tenant_id: tenantId };
if (excludeUserId) {
whereClause.id = { [require('sequelize').Op.ne]: excludeUserId };
}
const userCount = await User.count({ where: whereClause });
return userCount;
}
/**
* Check if tenant has reached device limit
*/
async function checkDeviceLimit(tenantId, excludeDeviceId = null) {
const { Device } = require('../models');
const whereClause = { tenant_id: tenantId };
if (excludeDeviceId) {
whereClause.id = { [require('sequelize').Op.ne]: excludeDeviceId };
}
const deviceCount = await Device.count({ where: whereClause });
return deviceCount;
}
/**
* Middleware to enforce user creation limits
*/
function enforceUserLimit() {
return async (req, res, next) => {
try {
const tenant = await getTenantFromRequest(req);
const maxUsers = tenant.features?.max_users;
// -1 means unlimited
if (maxUsers === -1) {
return next();
}
const currentUserCount = await checkUserLimit(tenant.id);
if (currentUserCount >= maxUsers) {
securityLogger.logSecurityEvent('warning', 'User creation blocked due to tenant limit', {
action: 'user_creation_limit_exceeded',
tenantId: tenant.id,
tenantSlug: tenant.slug,
currentUserCount,
maxUsers,
userId: req.user?.id,
username: req.user?.username,
ip: req.ip,
userAgent: req.get('User-Agent')
});
return res.status(403).json({
success: false,
message: `Tenant has reached the maximum number of users (${maxUsers}). Please upgrade your subscription or remove existing users.`,
error_code: 'TENANT_USER_LIMIT_EXCEEDED',
current_count: currentUserCount,
max_allowed: maxUsers
});
}
next();
} catch (error) {
console.error('Error checking user limit:', error);
res.status(500).json({
success: false,
message: 'Failed to validate user limit'
});
}
};
}
/**
* Middleware to enforce device creation limits
*/
function enforceDeviceLimit() {
return async (req, res, next) => {
try {
const tenant = await getTenantFromRequest(req);
const maxDevices = tenant.features?.max_devices;
// -1 means unlimited
if (maxDevices === -1) {
return next();
}
const currentDeviceCount = await checkDeviceLimit(tenant.id);
if (currentDeviceCount >= maxDevices) {
securityLogger.logSecurityEvent('warning', 'Device creation blocked due to tenant limit', {
action: 'device_creation_limit_exceeded',
tenantId: tenant.id,
tenantSlug: tenant.slug,
currentDeviceCount,
maxDevices,
userId: req.user?.id,
username: req.user?.username,
ip: req.ip,
userAgent: req.get('User-Agent')
});
return res.status(403).json({
success: false,
message: `Tenant has reached the maximum number of devices (${maxDevices}). Please upgrade your subscription or remove existing devices.`,
error_code: 'TENANT_DEVICE_LIMIT_EXCEEDED',
current_count: currentDeviceCount,
max_allowed: maxDevices
});
}
next();
} catch (error) {
console.error('Error checking device limit:', error);
res.status(500).json({
success: false,
message: 'Failed to validate device limit'
});
}
};
}
/**
* Middleware to enforce API rate limits per tenant
* Tracks actual API requests (not page views) shared among all tenant users
*/
function enforceApiRateLimit(windowMs = 60000) { // Default 1 minute window
return async (req, res, next) => {
try {
const tenant = await getTenantFromRequest(req);
const maxRequests = tenant.features?.api_rate_limit;
// -1 means unlimited
if (maxRequests === -1) {
return next();
}
const key = `api_rate_limit:${tenant.id}`;
const now = Date.now();
const windowStart = now - windowMs;
// Get current rate limit data
let rateLimitData = rateLimitStore.get(key);
if (!rateLimitData) {
rateLimitData = {
requests: [],
totalRequests: 0
};
}
// Remove old requests outside the window
rateLimitData.requests = rateLimitData.requests.filter(timestamp => timestamp > windowStart);
// Check if limit exceeded
if (rateLimitData.requests.length >= maxRequests) {
const resetTime = rateLimitData.requests[0] + windowMs;
const retryAfter = Math.ceil((resetTime - now) / 1000);
securityLogger.logSecurityEvent('warning', 'API rate limit exceeded for tenant', {
action: 'api_rate_limit_exceeded',
tenantId: tenant.id,
tenantSlug: tenant.slug,
currentRequests: rateLimitData.requests.length,
maxRequests,
windowMs,
userId: req.user?.id,
username: req.user?.username,
endpoint: req.path,
method: req.method,
ip: req.ip,
userAgent: req.get('User-Agent')
});
res.set({
'X-RateLimit-Limit': maxRequests,
'X-RateLimit-Remaining': 0,
'X-RateLimit-Reset': Math.ceil(resetTime / 1000),
'Retry-After': retryAfter
});
return res.status(429).json({
success: false,
message: `API rate limit exceeded. Maximum ${maxRequests} requests per ${windowMs/1000} seconds for your tenant.`,
error_code: 'TENANT_API_RATE_LIMIT_EXCEEDED',
max_requests: maxRequests,
window_seconds: windowMs / 1000,
retry_after_seconds: retryAfter
});
}
// Add current request
rateLimitData.requests.push(now);
rateLimitData.totalRequests++;
// Store updated data
rateLimitStore.set(key, rateLimitData, windowMs);
// Set rate limit headers
res.set({
'X-RateLimit-Limit': maxRequests,
'X-RateLimit-Remaining': Math.max(0, maxRequests - rateLimitData.requests.length),
'X-RateLimit-Reset': Math.ceil((now + windowMs) / 1000)
});
next();
} catch (error) {
console.error('Error checking API rate limit:', error);
// Don't block on rate limit errors, but log them
next();
}
};
}
/**
* Get tenant limits status
*/
async function getTenantLimitsStatus(tenantId) {
try {
const { Tenant } = require('../models');
const tenant = await Tenant.findByPk(tenantId);
if (!tenant) {
throw new Error('Tenant not found');
}
const [userCount, deviceCount] = await Promise.all([
checkUserLimit(tenantId),
checkDeviceLimit(tenantId)
]);
const rateLimitKey = `api_rate_limit:${tenantId}`;
const rateLimitData = rateLimitStore.get(rateLimitKey);
const currentApiRequests = rateLimitData ? rateLimitData.requests.length : 0;
return {
users: {
current: userCount,
limit: tenant.features?.max_users || 0,
unlimited: tenant.features?.max_users === -1
},
devices: {
current: deviceCount,
limit: tenant.features?.max_devices || 0,
unlimited: tenant.features?.max_devices === -1
},
api_requests: {
current_minute: currentApiRequests,
limit_per_minute: tenant.features?.api_rate_limit || 0,
unlimited: tenant.features?.api_rate_limit === -1
},
data_retention: {
days: tenant.features?.data_retention_days || 90,
unlimited: tenant.features?.data_retention_days === -1
}
};
} catch (error) {
console.error('Error getting tenant limits status:', error);
throw error;
}
}
module.exports = {
enforceUserLimit,
enforceDeviceLimit,
enforceApiRateLimit,
getTenantLimitsStatus,
checkUserLimit,
checkDeviceLimit,
rateLimitStore
};

View File

@@ -1,8 +1,10 @@
function validateRequest(schema) {
function validateRequest(schema, source = 'body') {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
const dataToValidate = req[source];
const { error, value } = schema.validate(dataToValidate, {
abortEarly: false,
stripUnknown: true
stripUnknown: true,
convert: true // Enable type coercion (e.g., '123' -> 123)
});
if (error) {
@@ -12,15 +14,19 @@ function validateRequest(schema) {
value: detail.context.value
}));
// Create a message that includes the field names
const fieldNames = errorDetails.map(detail => detail.field);
const message = `Validation error for field${fieldNames.length > 1 ? 's' : ''}: ${fieldNames.join(', ')}`;
return res.status(400).json({
success: false,
message: 'Validation error',
errors: errorDetails
message: message,
details: errorDetails
});
}
// Replace req.body with validated and sanitized data
req.body = value;
// Replace the validated data source with validated and sanitized data
req[source] = value;
next();
};
}

View File

@@ -0,0 +1,862 @@
/**
* Initial Migration: Create all base tables
* This migration creates the core database structure
*/
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Create tenants table first (referenced by other tables)
try {
await queryInterface.createTable('tenants', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false
},
name: {
type: Sequelize.STRING,
allowNull: false,
comment: 'Organization or tenant name'
},
slug: {
type: Sequelize.STRING,
allowNull: false,
unique: true,
comment: 'URL-friendly identifier'
},
domain: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Domain for SSO integration'
},
subdomain: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Subdomain for multi-tenant routing'
},
subscription_type: {
type: Sequelize.ENUM('free', 'basic', 'premium', 'enterprise'),
defaultValue: 'basic',
allowNull: false,
comment: 'Subscription tier of the tenant'
},
is_active: {
type: Sequelize.BOOLEAN,
defaultValue: true,
comment: 'Whether tenant is active'
},
auth_provider: {
type: Sequelize.ENUM('local', 'saml', 'oauth', 'ldap', 'custom_sso'),
defaultValue: 'local',
comment: 'Primary authentication provider'
},
auth_config: {
type: Sequelize.JSONB,
allowNull: true,
comment: 'Authentication provider configuration'
},
user_mapping: {
type: Sequelize.JSONB,
allowNull: true,
comment: 'User attribute mapping from external provider'
},
role_mapping: {
type: Sequelize.JSONB,
allowNull: true,
comment: 'Role mapping from external provider to internal roles'
},
branding: {
type: Sequelize.JSONB,
allowNull: true,
comment: 'Tenant-specific branding'
},
features: {
type: Sequelize.JSONB,
defaultValue: {
max_devices: 10,
max_users: 5,
api_rate_limit: 1000,
data_retention_days: 90,
features: ['basic_detection', 'alerts', 'dashboard']
},
comment: 'Tenant feature limits and enabled features'
},
admin_email: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Primary admin email for this tenant'
},
admin_phone: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Primary admin phone for this tenant'
},
billing_email: {
type: Sequelize.STRING,
allowNull: true
},
payment_method_id: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Payment provider customer ID'
},
metadata: {
type: Sequelize.JSONB,
allowNull: true,
comment: 'Additional tenant metadata'
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
}
});
console.log('✅ Created tenants table');
} catch (error) {
if (error.parent?.code === '42P07') { // Table already exists
console.log('⚠️ Tenants table already exists, skipping...');
} else {
throw error;
}
}
// Create users table
await queryInterface.createTable('users', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false
},
username: {
type: Sequelize.STRING,
allowNull: false,
unique: true
},
email: {
type: Sequelize.STRING,
allowNull: true,
validate: {
isEmail: true
}
},
password_hash: {
type: Sequelize.STRING,
allowNull: false
},
first_name: {
type: Sequelize.STRING,
allowNull: true
},
last_name: {
type: Sequelize.STRING,
allowNull: true
},
phone_number: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Phone number for SMS alerts (include country code)'
},
role: {
type: Sequelize.ENUM('admin', 'operator', 'viewer'),
defaultValue: 'viewer',
allowNull: false
},
external_provider: {
type: Sequelize.ENUM('local', 'saml', 'oauth', 'ldap', 'custom_sso'),
defaultValue: 'local',
comment: 'Authentication provider used for this user'
},
external_id: {
type: Sequelize.STRING,
allowNull: true,
comment: 'User ID from external authentication provider'
},
sms_alerts_enabled: {
type: Sequelize.BOOLEAN,
defaultValue: false,
comment: 'Whether user wants to receive SMS alerts'
},
is_active: {
type: Sequelize.BOOLEAN,
defaultValue: true,
comment: 'Whether user is active'
},
email_alerts_enabled: {
type: Sequelize.BOOLEAN,
defaultValue: true,
comment: 'Whether user wants to receive email alerts'
},
last_login: {
type: Sequelize.DATE,
allowNull: true
},
timezone: {
type: Sequelize.STRING,
defaultValue: 'UTC',
comment: 'User timezone for alert scheduling'
},
tenant_id: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW,
allowNull: false
},
updated_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW,
allowNull: false
}
});
// Create devices table
await queryInterface.createTable('devices', {
id: {
type: Sequelize.STRING(255),
primaryKey: true,
allowNull: false,
comment: 'Unique device identifier'
},
name: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Human-readable device name'
},
geo_lat: {
type: Sequelize.DECIMAL(10, 8),
allowNull: true,
comment: 'Device latitude coordinate'
},
geo_lon: {
type: Sequelize.DECIMAL(11, 8),
allowNull: true,
comment: 'Device longitude coordinate'
},
location_description: {
type: Sequelize.TEXT,
allowNull: true,
comment: 'Human-readable location description'
},
is_active: {
type: Sequelize.BOOLEAN,
defaultValue: true,
comment: 'Whether the device is currently active'
},
last_heartbeat: {
type: Sequelize.DATE,
allowNull: true,
comment: 'Timestamp of last heartbeat received'
},
heartbeat_interval: {
type: Sequelize.INTEGER,
defaultValue: 300,
comment: 'Expected heartbeat interval in seconds'
},
firmware_version: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Device firmware version'
},
installation_date: {
type: Sequelize.DATE,
allowNull: true,
comment: 'When the device was installed'
},
notes: {
type: Sequelize.TEXT,
allowNull: true,
comment: 'Additional notes about the device'
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW,
allowNull: false
},
updated_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW,
allowNull: false
}
});
// Create heartbeats table
await queryInterface.createTable('heartbeats', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
device_id: {
type: Sequelize.STRING(255),
allowNull: false,
references: {
model: 'devices',
key: 'id'
},
comment: 'ID of the device sending heartbeat'
},
tenant_id: {
type: Sequelize.UUID,
allowNull: true, // Nullable for backward compatibility
references: {
model: 'tenants',
key: 'id'
},
},
device_key: {
type: Sequelize.STRING,
allowNull: true,
defaultValue: 'test-device-key',
comment: 'Unique key of the sensor from heartbeat message'
},
status: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Device status (online, offline, error, etc.)'
},
timestamp: {
type: Sequelize.DATE,
allowNull: true,
comment: 'Timestamp from device'
},
uptime: {
type: Sequelize.BIGINT,
allowNull: true,
comment: 'Device uptime in seconds'
},
memory_usage: {
type: Sequelize.FLOAT,
allowNull: true,
comment: 'Memory usage percentage'
},
cpu_usage: {
type: Sequelize.FLOAT,
allowNull: true,
comment: 'CPU usage percentage'
},
disk_usage: {
type: Sequelize.FLOAT,
allowNull: true,
comment: 'Disk usage percentage'
},
firmware_version: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Firmware version reported in heartbeat'
},
received_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW,
comment: 'When heartbeat was received by server'
},
raw_payload: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Complete raw payload received from detector (for debugging)'
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
}
});
// Create drone_detections table
await queryInterface.createTable('drone_detections', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
device_id: {
type: Sequelize.STRING(255),
allowNull: false,
references: {
model: 'devices',
key: 'id'
},
comment: 'ID of the detecting device'
},
drone_id: {
type: Sequelize.BIGINT,
allowNull: false,
comment: 'ID of the detected drone'
},
drone_type: {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Type of drone detected'
},
rssi: {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Signal strength in dBm'
},
freq: {
type: Sequelize.BIGINT,
allowNull: true,
comment: 'Frequency detected'
},
geo_lat: {
type: Sequelize.DECIMAL(10, 8),
allowNull: true,
comment: 'Latitude where detection occurred'
},
geo_lon: {
type: Sequelize.DECIMAL(11, 8),
allowNull: true,
comment: 'Longitude where detection occurred'
},
device_timestamp: {
type: Sequelize.BIGINT,
allowNull: true,
comment: 'Unix timestamp from the device'
},
server_timestamp: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW,
comment: 'When the detection was received by server'
},
confidence_level: {
type: Sequelize.DECIMAL(3, 2),
allowNull: true,
comment: 'Confidence level of detection (0.00-1.00)'
},
signal_duration: {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Duration of signal in milliseconds'
},
processed: {
type: Sequelize.BOOLEAN,
defaultValue: false,
comment: 'Whether this detection has been processed for alerts'
},
threat_level: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Assessed threat level based on RSSI and drone type'
},
estimated_distance: {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Estimated distance to drone in meters'
},
requires_action: {
type: Sequelize.BOOLEAN,
defaultValue: false,
comment: 'Whether this detection requires immediate security action'
},
raw_payload: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Complete raw payload received from detector (for debugging)'
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
}
});
// Create alert_rules table
await queryInterface.createTable('alert_rules', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
tenant_id: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
}
},
user_id: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
},
name: {
type: Sequelize.STRING,
allowNull: false
},
description: {
type: Sequelize.TEXT,
allowNull: true
},
device_ids: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Array of device IDs to monitor (null = all devices)'
},
drone_types: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Array of drone types to alert on (null = all types)'
},
min_rssi: {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Minimum RSSI threshold for alert'
},
max_rssi: {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Maximum RSSI threshold for alert'
},
frequency_ranges: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Array of frequency ranges to monitor [{min: 20, max: 30}]'
},
time_window: {
type: Sequelize.INTEGER,
defaultValue: 300,
comment: 'Time window in seconds to group detections'
},
min_detections: {
type: Sequelize.INTEGER,
defaultValue: 1,
comment: 'Minimum number of detections in time window to trigger alert'
},
cooldown_period: {
type: Sequelize.INTEGER,
defaultValue: 600,
comment: 'Cooldown period in seconds between alerts for same drone'
},
alert_channels: {
type: Sequelize.JSON,
defaultValue: ['sms'],
comment: 'Array of alert channels: sms, email, webhook'
},
sms_phone_number: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Phone number for SMS alerts'
},
webhook_url: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Webhook URL for custom integrations'
},
active_hours: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Active hours for alerts {start: "09:00", end: "17:00"}'
},
active_days: {
type: Sequelize.JSON,
defaultValue: [1, 2, 3, 4, 5, 6, 7],
comment: 'Active days of week (1=Monday, 7=Sunday)'
},
priority: {
type: Sequelize.ENUM('low', 'medium', 'high', 'critical'),
defaultValue: 'medium',
comment: 'Alert priority level'
},
min_threat_level: {
type: Sequelize.ENUM('monitoring', 'low', 'medium', 'high', 'critical'),
allowNull: true,
comment: 'Minimum threat level required to trigger alert'
},
is_active: {
type: Sequelize.BOOLEAN,
defaultValue: true
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
}
});
// Create alert_logs table
await queryInterface.createTable('alert_logs', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
alert_event_id: {
type: Sequelize.UUID,
allowNull: true,
comment: 'Groups related alerts (SMS, email, webhook) that are part of the same detection event'
},
alert_rule_id: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'alert_rules',
key: 'id'
}
},
detection_id: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'drone_detections',
key: 'id'
}
},
device_id: {
type: Sequelize.STRING(255),
allowNull: true,
references: {
model: 'devices',
key: 'id'
}
},
alert_type: {
type: Sequelize.ENUM('sms', 'email', 'webhook', 'push'),
allowNull: true,
defaultValue: 'sms'
},
recipient: {
type: Sequelize.STRING,
allowNull: true
},
message: {
type: Sequelize.TEXT,
allowNull: false
},
status: {
type: Sequelize.ENUM('pending', 'sent', 'failed', 'delivered'),
defaultValue: 'pending'
},
sent_at: {
type: Sequelize.DATE,
allowNull: true
},
delivered_at: {
type: Sequelize.DATE,
allowNull: true
},
error_message: {
type: Sequelize.TEXT,
allowNull: true
},
external_id: {
type: Sequelize.STRING,
allowNull: true
},
cost: {
type: Sequelize.DECIMAL(10, 4),
allowNull: true
},
retry_count: {
type: Sequelize.INTEGER,
defaultValue: 0
},
priority: {
type: Sequelize.ENUM('low', 'normal', 'medium', 'high', 'critical'),
defaultValue: 'normal'
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
}
});
// Create AuditLogs table
await queryInterface.createTable('audit_logs', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
tenant_id: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
}
},
user_id: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id'
}
},
action: {
type: Sequelize.STRING,
allowNull: false
},
resource_type: {
type: Sequelize.STRING,
allowNull: true
},
resource_id: {
type: Sequelize.STRING,
allowNull: true
},
details: {
type: Sequelize.JSON,
allowNull: true
},
ip_address: {
type: Sequelize.INET,
allowNull: true
},
user_agent: {
type: Sequelize.TEXT,
allowNull: true
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
}
});
// Create management_users table
await queryInterface.createTable('management_users', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
username: {
type: Sequelize.STRING,
allowNull: false,
unique: true
},
email: {
type: Sequelize.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true
}
},
first_name: {
type: Sequelize.STRING,
allowNull: true
},
last_name: {
type: Sequelize.STRING,
allowNull: true
},
login_attempts: {
type: Sequelize.INTEGER,
defaultValue: 0,
comment: 'Failed login attempt counter'
},
locked_until: {
type: Sequelize.DATE,
allowNull: true,
comment: 'Account lock expiration time'
},
two_factor_enabled: {
type: Sequelize.BOOLEAN,
defaultValue: false,
comment: 'Whether 2FA is enabled'
},
two_factor_secret: {
type: Sequelize.STRING,
allowNull: true,
comment: 'TOTP secret for 2FA'
},
api_access: {
type: Sequelize.BOOLEAN,
defaultValue: true,
comment: 'Whether user can access management API'
},
created_by: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Username of who created this management user'
},
notes: {
type: Sequelize.TEXT,
allowNull: true,
comment: 'Admin notes about this user'
},
password_hash: {
type: Sequelize.STRING,
allowNull: false
},
role: {
type: Sequelize.ENUM('super_admin', 'tenant_admin'),
defaultValue: 'tenant_admin',
allowNull: false
},
permissions: {
type: Sequelize.JSON,
allowNull: true,
defaultValue: []
},
is_active: {
type: Sequelize.BOOLEAN,
defaultValue: true
},
last_login: {
type: Sequelize.DATE,
allowNull: true
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
}
});
// Create basic indexes
await queryInterface.addIndex('devices', ['geo_lat', 'geo_lon']);
await queryInterface.addIndex('devices', ['is_active']);
await queryInterface.addIndex('heartbeats', ['device_id']);
await queryInterface.addIndex('heartbeats', ['received_at']);
await queryInterface.addIndex('drone_detections', ['device_id']);
await queryInterface.addIndex('drone_detections', ['drone_id']);
await queryInterface.addIndex('drone_detections', ['server_timestamp']);
await queryInterface.addIndex('alert_rules', ['user_id']);
await queryInterface.addIndex('alert_logs', ['alert_rule_id']);
await queryInterface.addIndex('audit_logs', ['tenant_id']);
await queryInterface.addIndex('audit_logs', ['user_id']);
await queryInterface.addIndex('audit_logs', ['created_at']);
},
async down(queryInterface, Sequelize) {
// Drop tables in reverse order due to foreign key constraints
await queryInterface.dropTable('audit_logs');
await queryInterface.dropTable('management_users');
await queryInterface.dropTable('alert_logs');
await queryInterface.dropTable('alert_rules');
await queryInterface.dropTable('drone_detections');
await queryInterface.dropTable('heartbeats');
await queryInterface.dropTable('devices');
await queryInterface.dropTable('users');
await queryInterface.dropTable('tenants');
}
};

View File

@@ -2,30 +2,48 @@
module.exports = {
async up(queryInterface, Sequelize) {
// Check if raw_payload column exists in drone_detections before adding
const droneDetectionsTable = await queryInterface.describeTable('drone_detections');
if (!droneDetectionsTable.raw_payload) {
await queryInterface.addColumn('drone_detections', 'raw_payload', {
type: Sequelize.JSON,
allowNull: true,
comment: 'Complete raw payload received from detector (for debugging)'
});
console.log('✅ Added raw_payload field to drone_detections table');
} else {
console.log('⏭️ raw_payload field already exists in drone_detections table');
}
try {
// Check if tables exist first
const tables = await queryInterface.showAllTables();
// Handle drone_detections table
if (!tables.includes('drone_detections')) {
console.log('⚠️ drone_detections table does not exist yet, skipping raw_payload migration for this table...');
} else {
// Check if raw_payload column exists in drone_detections before adding
const droneDetectionsTable = await queryInterface.describeTable('drone_detections');
if (!droneDetectionsTable.raw_payload) {
await queryInterface.addColumn('drone_detections', 'raw_payload', {
type: Sequelize.JSON,
allowNull: true,
comment: 'Complete raw payload received from detector (for debugging)'
});
console.log('✅ Added raw_payload field to drone_detections table');
} else {
console.log('⏭️ raw_payload field already exists in drone_detections table');
}
}
// Check if raw_payload column exists in heartbeats before adding
const heartbeatsTable = await queryInterface.describeTable('heartbeats');
if (!heartbeatsTable.raw_payload) {
await queryInterface.addColumn('heartbeats', 'raw_payload', {
type: Sequelize.JSON,
allowNull: true,
comment: 'Complete raw payload received from detector (for debugging)'
});
console.log('✅ Added raw_payload field to heartbeats table');
} else {
console.log('⏭️ raw_payload field already exists in heartbeats table');
// Handle heartbeats table
if (!tables.includes('heartbeats')) {
console.log('⚠️ heartbeats table does not exist yet, skipping raw_payload migration for this table...');
} else {
// Check if raw_payload column exists in heartbeats before adding
const heartbeatsTable = await queryInterface.describeTable('heartbeats');
if (!heartbeatsTable.raw_payload) {
await queryInterface.addColumn('heartbeats', 'raw_payload', {
type: Sequelize.JSON,
allowNull: true,
comment: 'Complete raw payload received from detector (for debugging)'
});
console.log('✅ Added raw_payload field to heartbeats table');
} else {
console.log('⏭️ raw_payload field already exists in heartbeats table');
}
}
} catch (error) {
console.log('⚠️ Migration skipped - tables may not exist yet:', error.message);
// Don't throw error, just skip this migration if tables don't exist
}
},

View File

@@ -168,7 +168,12 @@ module.exports = {
}
// Add tenant-related columns to users table (idempotent)
const usersTableDescription = await queryInterface.describeTable('users');
const tables = await queryInterface.showAllTables();
if (!tables.includes('users')) {
console.log('⚠️ Users table does not exist yet, skipping user tenant columns migration...');
} else {
const usersTableDescription = await queryInterface.describeTable('users');
if (!usersTableDescription.tenant_id) {
await queryInterface.addColumn('users', 'tenant_id', {
@@ -318,6 +323,8 @@ module.exports = {
} catch (error) {
console.log('Alert_rules table not found or already has tenant_id column');
}
} // Close the else block for users table check
console.log('✅ Multi-tenant support added successfully');
console.log('✅ Default tenant created for backward compatibility');

View File

@@ -7,8 +7,16 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
// Check if the columns already exist
const tableDescription = await queryInterface.describeTable('tenants');
try {
// Check if tenants table exists first
const tables = await queryInterface.showAllTables();
if (!tables.includes('tenants')) {
console.log('⚠️ Tenants table does not exist yet, skipping auth session config migration...');
return;
}
// Check if the columns already exist
const tableDescription = await queryInterface.describeTable('tenants');
// Add session configuration fields
if (!tableDescription.session_timeout) {
@@ -78,6 +86,10 @@ module.exports = {
} catch (error) {
console.log('⚠️ Auth provider enum already includes ad or error occurred:', error.message);
}
} catch (error) {
console.log('⚠️ Migration skipped - tables may not exist yet:', error.message);
// Don't throw error, just skip this migration if tables don't exist
}
},
down: async (queryInterface, Sequelize) => {

View File

@@ -7,8 +7,16 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
// Check if the columns already exist
const tableDescription = await queryInterface.describeTable('tenants');
try {
// Check if tenants table exists first
const tables = await queryInterface.showAllTables();
if (!tables.includes('tenants')) {
console.log('⚠️ Tenants table does not exist yet, skipping IP restrictions migration...');
return;
}
// Check if the columns already exist
const tableDescription = await queryInterface.describeTable('tenants');
if (!tableDescription.ip_whitelist) {
await queryInterface.addColumn('tenants', 'ip_whitelist', {
@@ -45,6 +53,10 @@ module.exports = {
} else {
console.log('⚠️ Column ip_restriction_message already exists, skipping...');
}
} catch (error) {
console.log('⚠️ Migration skipped - tables may not exist yet:', error.message);
// Don't throw error, just skip this migration if tables don't exist
}
},
down: async (queryInterface, Sequelize) => {

View File

@@ -7,13 +7,21 @@
module.exports = {
async up(queryInterface, Sequelize) {
// Check if tenant_id column already exists
const tableDescription = await queryInterface.describeTable('devices');
try {
// Check if devices table exists first
const tables = await queryInterface.showAllTables();
if (!tables.includes('devices')) {
console.log('⚠️ Devices table does not exist yet, skipping device tenant support migration...');
return;
}
// Check if tenant_id column already exists
const tableDescription = await queryInterface.describeTable('devices');
if (!tableDescription.tenant_id) {
// Add tenant_id column to devices table
await queryInterface.addColumn('devices', 'tenant_id', {
type: Sequelize.INTEGER,
type: Sequelize.UUID,
allowNull: true, // Nullable for backward compatibility
references: {
model: 'tenants',
@@ -62,6 +70,10 @@ module.exports = {
} else {
console.log('⚠️ Column tenant_id already exists in devices table, skipping...');
}
} catch (error) {
console.log('⚠️ Migration skipped - tables may not exist yet:', error.message);
// Don't throw error, just skip this migration if tables don't exist
}
},
async down(queryInterface, Sequelize) {

View File

@@ -7,23 +7,28 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('tenants', 'allow_registration', {
type: Sequelize.BOOLEAN,
defaultValue: false, // Default to false for security
allowNull: false,
comment: 'Whether self-registration is allowed for local auth'
});
// For existing tenants, you might want to enable registration for specific tenants
// Uncomment the line below to enable registration for all existing tenants (NOT RECOMMENDED for production)
// await queryInterface.sequelize.query("UPDATE tenants SET allow_registration = true WHERE auth_provider = 'local'");
// Check if the column already exists
const tableDescription = await queryInterface.describeTable('tenants');
console.log('✅ Added allow_registration field to tenants table');
console.log('⚠️ Registration is disabled by default for all tenants for security');
console.log('💡 To enable registration for a tenant, update the allow_registration field to true');
},
if (!tableDescription.allow_registration) {
await queryInterface.addColumn('tenants', 'allow_registration', {
type: Sequelize.BOOLEAN,
defaultValue: false, // Default to false for security
allowNull: false,
comment: 'Whether self-registration is allowed for local auth'
});
down: async (queryInterface, Sequelize) => {
// For existing tenants, you might want to enable registration for specific tenants
// Uncomment the line below to enable registration for all existing tenants (NOT RECOMMENDED for production)
// await queryInterface.sequelize.query("UPDATE tenants SET allow_registration = true WHERE auth_provider = 'local'");
console.log('✅ Added allow_registration field to tenants table');
console.log('⚠️ Registration is disabled by default for all tenants for security');
console.log('💡 To enable registration for a tenant, update the allow_registration field to true');
} else {
console.log('⚠️ Column allow_registration already exists, skipping...');
}
}, down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('tenants', 'allow_registration');
console.log('✅ Removed allow_registration field from tenants table');
}

View File

@@ -0,0 +1,21 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn('drone_detections', 'drone_id', {
type: Sequelize.BIGINT,
allowNull: false,
defaultValue: 999999,
comment: 'Detected drone identifier (BIGINT for large IDs)'
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.changeColumn('drone_detections', 'drone_id', {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 999999,
comment: 'Detected drone identifier'
});
}
};

View File

@@ -0,0 +1,91 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Add tenant_id column to drone_detections table
try {
await queryInterface.addColumn('drone_detections', 'tenant_id', {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
},
comment: 'Tenant ID for multi-tenant isolation'
});
console.log('✅ Added tenant_id column to drone_detections table');
} catch (error) {
if (error.original && error.original.code === '42701') {
// Column already exists
console.log(' tenant_id column already exists in drone_detections table');
} else {
throw error;
}
}
// Add index for better query performance
try {
await queryInterface.addIndex('drone_detections', ['tenant_id'], {
name: 'idx_drone_detections_tenant_id'
});
console.log('✅ Added index on tenant_id column');
} catch (error) {
if (error.original && error.original.code === '42P07') {
// Index already exists
console.log(' Index on tenant_id already exists');
} else {
throw error;
}
}
// Add foreign key constraint
try {
await queryInterface.addConstraint('drone_detections', {
fields: ['tenant_id'],
type: 'foreign key',
name: 'fk_drone_detections_tenant_id',
references: {
table: 'tenants',
field: 'id'
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE'
});
console.log('✅ Added foreign key constraint for tenant_id');
} catch (error) {
if (error.original && error.original.code === '42710') {
// Constraint already exists
console.log(' Foreign key constraint already exists');
} else {
throw error;
}
}
},
async down(queryInterface, Sequelize) {
// Remove foreign key constraint
try {
await queryInterface.removeConstraint('drone_detections', 'fk_drone_detections_tenant_id');
console.log('✅ Removed foreign key constraint for tenant_id');
} catch (error) {
console.log(' Foreign key constraint already removed or does not exist');
}
// Remove index
try {
await queryInterface.removeIndex('drone_detections', 'idx_drone_detections_tenant_id');
console.log('✅ Removed index on tenant_id column');
} catch (error) {
console.log(' Index already removed or does not exist');
}
// Remove tenant_id column
try {
await queryInterface.removeColumn('drone_detections', 'tenant_id');
console.log('✅ Removed tenant_id column from drone_detections table');
} catch (error) {
console.log(' tenant_id column already removed or does not exist');
}
}
};

View File

@@ -0,0 +1,78 @@
/**
* Migration: Add tenant_id to heartbeats table
* This migration adds tenant_id field to heartbeats for multi-tenant support
*/
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
try {
// Check if heartbeats table exists first
const tables = await queryInterface.showAllTables();
if (!tables.includes('heartbeats')) {
console.log('⚠️ Heartbeats table does not exist yet, skipping heartbeat tenant support migration...');
return;
}
// Check if tenant_id column already exists
const tableDescription = await queryInterface.describeTable('heartbeats');
if (!tableDescription.tenant_id) {
// Add index for tenant_id for better query performance
try {
await queryInterface.addIndex('heartbeats', ['tenant_id'], {
name: 'heartbeats_tenant_id_idx'
});
console.log('✅ Added index on heartbeats.tenant_id');
} catch (error) {
if (error.parent?.code === '42P07') { // Index already exists
console.log('⚠️ Index heartbeats_tenant_id already exists, skipping...');
} else {
throw error;
}
}
// Associate existing heartbeats with default tenant (backward compatibility)
const defaultTenant = await queryInterface.sequelize.query(
'SELECT id FROM tenants WHERE slug = :slug',
{
replacements: { slug: 'default' },
type: Sequelize.QueryTypes.SELECT
}
);
if (defaultTenant.length > 0) {
await queryInterface.sequelize.query(
'UPDATE heartbeats SET tenant_id = :tenantId WHERE tenant_id IS NULL',
{
replacements: { tenantId: defaultTenant[0].id },
type: Sequelize.QueryTypes.UPDATE
}
);
console.log('✅ Associated existing heartbeats with default tenant');
}
console.log('✅ Added tenant_id field to heartbeats table');
} else {
console.log('⚠️ Column tenant_id already exists in heartbeats table, skipping...');
}
} catch (error) {
console.log('⚠️ Migration skipped - tables may not exist yet:', error.message);
// Don't throw error, just skip this migration if tables don't exist
}
},
async down(queryInterface, Sequelize) {
// Remove index
try {
await queryInterface.removeIndex('heartbeats', 'heartbeats_tenant_id_idx');
} catch (error) {
console.log('⚠️ Index heartbeats_tenant_id_idx does not exist, skipping...');
}
// Remove column
await queryInterface.removeColumn('heartbeats', 'tenant_id');
console.log('✅ Removed tenant_id field from heartbeats table');
}
};

View File

@@ -0,0 +1,87 @@
/**
* Migration: Add alert_event_id to alert_logs table
* This migration adds alert_event_id field to group related alerts (SMS, email, webhook)
* that are part of the same detection event
*/
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
try {
// Check if alert_logs table exists first
const tables = await queryInterface.showAllTables();
if (!tables.includes('alert_logs')) {
console.log('⚠️ Alert_logs table does not exist yet, skipping alert event ID migration...');
return;
}
// Check if alert_event_id column already exists
const tableDescription = await queryInterface.describeTable('alert_logs');
if (!tableDescription.alert_event_id) {
// Add alert_event_id column
await queryInterface.addColumn('alert_logs', 'alert_event_id', {
type: Sequelize.UUID,
allowNull: true,
comment: 'Groups related alerts (SMS, email, webhook) that are part of the same detection event'
});
console.log('✅ Added alert_event_id column to alert_logs table');
// Add index for alert_event_id for better query performance
try {
await queryInterface.addIndex('alert_logs', ['alert_event_id'], {
name: 'alert_logs_alert_event_id_idx'
});
console.log('✅ Added index on alert_logs.alert_event_id');
} catch (error) {
if (error.parent?.code === '42P07') { // Index already exists
console.log('⚠️ Index alert_logs_alert_event_id already exists, skipping...');
} else {
throw error;
}
}
} else {
console.log('⚠️ Column alert_event_id already exists in alert_logs table, skipping...');
}
} catch (error) {
console.error('❌ Error in migration 20250922000002-add-alert-event-id:', error);
throw error;
}
},
async down(queryInterface, Sequelize) {
try {
// Check if alert_logs table exists
const tables = await queryInterface.showAllTables();
if (!tables.includes('alert_logs')) {
console.log('⚠️ Alert_logs table does not exist, skipping migration rollback...');
return;
}
// Check if alert_event_id column exists
const tableDescription = await queryInterface.describeTable('alert_logs');
if (tableDescription.alert_event_id) {
// Remove index first
try {
await queryInterface.removeIndex('alert_logs', 'alert_logs_alert_event_id_idx');
console.log('✅ Removed index alert_logs_alert_event_id_idx');
} catch (error) {
console.log('⚠️ Index alert_logs_alert_event_id_idx might not exist, continuing...');
}
// Remove column
await queryInterface.removeColumn('alert_logs', 'alert_event_id');
console.log('✅ Removed alert_event_id column from alert_logs table');
} else {
console.log('⚠️ Column alert_event_id does not exist in alert_logs table, skipping...');
}
} catch (error) {
console.error('❌ Error in migration rollback 20250922000002-add-alert-event-id:', error);
throw error;
}
}
};

View File

@@ -0,0 +1,124 @@
const { DataTypes } = require('sequelize');
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('security_logs', {
id: {
type: DataTypes.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
},
event_type: {
type: DataTypes.STRING(50),
allowNull: false
},
severity: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'info'
},
user_id: {
type: DataTypes.UUID,
allowNull: true
},
username: {
type: DataTypes.STRING(100),
allowNull: true
},
ip_address: {
type: DataTypes.INET,
allowNull: true
},
client_ip: {
type: DataTypes.INET,
allowNull: true
},
user_agent: {
type: DataTypes.TEXT,
allowNull: true
},
rdns: {
type: DataTypes.STRING(255),
allowNull: true
},
country_code: {
type: DataTypes.STRING(2),
allowNull: true
},
country_name: {
type: DataTypes.STRING(100),
allowNull: true
},
city: {
type: DataTypes.STRING(100),
allowNull: true
},
is_high_risk_country: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
message: {
type: DataTypes.TEXT,
allowNull: false
},
metadata: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: {}
},
session_id: {
type: DataTypes.STRING(255),
allowNull: true
},
request_id: {
type: DataTypes.STRING(255),
allowNull: true
},
endpoint: {
type: DataTypes.STRING(255),
allowNull: true
},
method: {
type: DataTypes.STRING(10),
allowNull: true
},
status_code: {
type: DataTypes.INTEGER,
allowNull: true
},
alerted: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
created_at: {
type: DataTypes.DATE,
defaultValue: Sequelize.NOW,
allowNull: false
}
});
// Add indexes for performance
await queryInterface.addIndex('security_logs', ['tenant_id', 'created_at']);
await queryInterface.addIndex('security_logs', ['event_type', 'created_at']);
await queryInterface.addIndex('security_logs', ['ip_address', 'created_at']);
await queryInterface.addIndex('security_logs', ['username', 'created_at']);
await queryInterface.addIndex('security_logs', ['severity', 'created_at']);
await queryInterface.addIndex('security_logs', ['country_code', 'is_high_risk_country']);
await queryInterface.addIndex('security_logs', ['alerted', 'severity', 'created_at']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('security_logs');
}
};

View File

@@ -4,12 +4,12 @@ module.exports = (sequelize) => {
const AlertLog = sequelize.define('AlertLog', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
defaultValue: sequelize.Sequelize.UUIDV4,
primaryKey: true
},
alert_rule_id: {
type: DataTypes.UUID,
allowNull: false,
allowNull: true, // Allow null for testing
references: {
model: 'alert_rules',
key: 'id'
@@ -17,19 +17,34 @@ module.exports = (sequelize) => {
},
detection_id: {
type: DataTypes.UUID,
allowNull: false,
allowNull: true, // Allow null for testing
references: {
model: 'drone_detections',
key: 'id'
}
},
device_id: {
type: DataTypes.STRING(255),
allowNull: true, // Allow null for testing
references: {
model: 'devices',
key: 'id'
}
},
alert_event_id: {
type: DataTypes.UUID,
allowNull: true,
comment: 'Groups related alerts (SMS, email, webhook) that are part of the same detection event'
},
alert_type: {
type: DataTypes.ENUM('sms', 'email', 'webhook', 'push'),
allowNull: false
allowNull: true, // Allow null for testing
defaultValue: 'sms'
},
recipient: {
type: DataTypes.STRING,
allowNull: false,
allowNull: true, // Allow null for testing
defaultValue: 'test@example.com',
comment: 'Phone number, email, or webhook URL'
},
message: {
@@ -70,16 +85,16 @@ module.exports = (sequelize) => {
comment: 'Number of retry attempts'
},
priority: {
type: DataTypes.ENUM('low', 'medium', 'high', 'critical'),
type: DataTypes.ENUM('low', 'normal', 'medium', 'high', 'critical'),
defaultValue: 'medium'
},
created_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
defaultValue: sequelize.Sequelize.NOW
},
updated_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
defaultValue: sequelize.Sequelize.NOW
}
}, {
tableName: 'alert_logs',
@@ -101,6 +116,9 @@ module.exports = (sequelize) => {
},
{
fields: ['alert_type', 'status']
},
{
fields: ['alert_event_id']
}
]
});

View File

@@ -4,17 +4,25 @@ module.exports = (sequelize) => {
const AlertRule = sequelize.define('AlertRule', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
defaultValue: sequelize.Sequelize.UUIDV4,
primaryKey: true
},
user_id: {
type: DataTypes.UUID,
allowNull: false,
allowNull: true, // Allow null for testing
references: {
model: 'users',
key: 'id'
}
},
tenant_id: {
type: DataTypes.UUID,
allowNull: true, // Allow null for testing
references: {
model: 'tenants',
key: 'id'
}
},
name: {
type: DataTypes.STRING,
allowNull: false,

118
server/models/AuditLog.js Normal file
View File

@@ -0,0 +1,118 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const AuditLog = sequelize.define('AuditLog', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
timestamp: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
},
level: {
type: DataTypes.ENUM('INFO', 'WARNING', 'ERROR', 'CRITICAL'),
allowNull: false
},
action: {
type: DataTypes.STRING(100),
allowNull: false,
comment: 'The action performed (e.g., logo_upload, logo_removal)'
},
message: {
type: DataTypes.TEXT,
allowNull: false,
comment: 'Human-readable description of the event'
},
user_id: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'ID of the user who performed the action'
},
username: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Username of the user who performed the action'
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'ID of the tenant affected by the action'
},
tenant_slug: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Slug of the tenant affected by the action'
},
ip_address: {
type: DataTypes.STRING(45),
allowNull: true,
comment: 'IP address of the user (supports IPv6)'
},
user_agent: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'User agent string from the request'
},
path: {
type: DataTypes.STRING(500),
allowNull: true,
comment: 'Request path that triggered the action'
},
metadata: {
type: DataTypes.JSON,
allowNull: true,
comment: 'Additional metadata about the event'
},
success: {
type: DataTypes.BOOLEAN,
allowNull: true,
comment: 'Whether the action was successful'
}
}, {
tableName: 'audit_logs',
timestamps: false, // We use our own timestamp field
indexes: [
{
fields: ['timestamp']
},
{
fields: ['action']
},
{
fields: ['user_id']
},
{
fields: ['tenant_id']
},
{
fields: ['level']
},
{
fields: ['timestamp', 'action']
},
{
fields: ['tenant_id', 'timestamp']
}
]
});
// Define associations
AuditLog.associate = function(models) {
// Association with User
AuditLog.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'user'
});
// Association with Tenant
AuditLog.belongsTo(models.Tenant, {
foreignKey: 'tenant_id',
as: 'tenant'
});
};
return AuditLog;
};

View File

@@ -3,9 +3,8 @@ const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Device = sequelize.define('Device', {
id: {
type: DataTypes.INTEGER,
type: DataTypes.STRING(255),
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: 'Unique device identifier'
},
@@ -40,7 +39,7 @@ module.exports = (sequelize) => {
comment: 'Whether the device is approved to send data'
},
tenant_id: {
type: DataTypes.INTEGER,
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'tenants',

View File

@@ -4,11 +4,11 @@ module.exports = (sequelize) => {
const DroneDetection = sequelize.define('DroneDetection', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
defaultValue: sequelize.Sequelize.UUIDV4,
primaryKey: true
},
device_id: {
type: DataTypes.INTEGER,
type: DataTypes.STRING(255),
allowNull: false,
references: {
model: 'devices',
@@ -16,10 +16,20 @@ module.exports = (sequelize) => {
},
comment: 'ID of the detecting device'
},
tenant_id: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
},
comment: 'Tenant ID for multi-tenant isolation'
},
drone_id: {
type: DataTypes.INTEGER,
type: DataTypes.BIGINT,
allowNull: false,
comment: 'Detected drone identifier'
defaultValue: 999999,
comment: 'Detected drone identifier (BIGINT for large IDs)'
},
drone_type: {
type: DataTypes.INTEGER,
@@ -36,6 +46,7 @@ module.exports = (sequelize) => {
freq: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 2400,
comment: 'Frequency detected'
},
geo_lat: {
@@ -55,7 +66,7 @@ module.exports = (sequelize) => {
},
server_timestamp: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
defaultValue: sequelize.Sequelize.NOW,
comment: 'When the detection was received by server'
},
confidence_level: {
@@ -98,7 +109,7 @@ module.exports = (sequelize) => {
},
created_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
defaultValue: sequelize.Sequelize.NOW
}
}, {
tableName: 'drone_detections',

View File

@@ -4,11 +4,11 @@ module.exports = (sequelize) => {
const Heartbeat = sequelize.define('Heartbeat', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
defaultValue: sequelize.Sequelize.UUIDV4,
primaryKey: true
},
device_id: {
type: DataTypes.INTEGER,
type: DataTypes.STRING(255),
allowNull: false,
references: {
model: 'devices',
@@ -16,25 +16,30 @@ module.exports = (sequelize) => {
},
comment: 'ID of the device sending heartbeat'
},
tenant_id: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
},
comment: 'Tenant ID for multi-tenancy support'
},
device_key: {
type: DataTypes.STRING,
allowNull: false,
allowNull: true, // Allow null for testing
defaultValue: 'test-device-key',
comment: 'Unique key of the sensor from heartbeat message'
},
signal_strength: {
type: DataTypes.INTEGER,
status: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Signal strength at time of heartbeat'
comment: 'Device status (online, offline, error, etc.)'
},
battery_level: {
type: DataTypes.INTEGER,
timestamp: {
type: DataTypes.DATE,
allowNull: true,
comment: 'Battery level percentage (0-100)'
},
temperature: {
type: DataTypes.DECIMAL(4, 1),
allowNull: true,
comment: 'Device temperature in Celsius'
comment: 'Timestamp from device'
},
uptime: {
type: DataTypes.BIGINT,
@@ -42,10 +47,20 @@ module.exports = (sequelize) => {
comment: 'Device uptime in seconds'
},
memory_usage: {
type: DataTypes.INTEGER,
type: DataTypes.FLOAT,
allowNull: true,
comment: 'Memory usage percentage'
},
cpu_usage: {
type: DataTypes.FLOAT,
allowNull: true,
comment: 'CPU usage percentage'
},
disk_usage: {
type: DataTypes.FLOAT,
allowNull: true,
comment: 'Disk usage percentage'
},
firmware_version: {
type: DataTypes.STRING,
allowNull: true,
@@ -53,7 +68,7 @@ module.exports = (sequelize) => {
},
received_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
defaultValue: sequelize.Sequelize.NOW,
comment: 'When heartbeat was received by server'
},
raw_payload: {
@@ -63,7 +78,7 @@ module.exports = (sequelize) => {
},
created_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
defaultValue: sequelize.Sequelize.NOW
}
}, {
tableName: 'heartbeats',

View File

@@ -9,7 +9,7 @@ module.exports = (sequelize) => {
const ManagementUser = sequelize.define('ManagementUser', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
defaultValue: sequelize.Sequelize.UUIDV4,
primaryKey: true
},
username: {
@@ -101,6 +101,10 @@ module.exports = (sequelize) => {
}
}, {
tableName: 'management_users',
underscored: true, // Add this line
timestamps: true, // Add this line
createdAt: 'created_at', // Add this line
updatedAt: 'updated_at', // Add this line
indexes: [
{
unique: true,

View File

@@ -0,0 +1,160 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const SecurityLog = sequelize.define('SecurityLog', {
id: {
type: DataTypes.UUID,
defaultValue: sequelize.Sequelize.UUIDV4,
primaryKey: true
},
tenant_id: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
},
comment: 'Tenant ID for multi-tenant isolation (null for system-wide events)'
},
event_type: {
type: DataTypes.STRING(50),
allowNull: false,
comment: 'Type of security event (login_failed, login_success, suspicious_pattern, etc.)'
},
severity: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'info',
validate: {
isIn: [['low', 'medium', 'high', 'critical']]
},
comment: 'Severity level of the security event'
},
user_id: {
type: DataTypes.UUID,
allowNull: true,
comment: 'User ID if applicable'
},
username: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'Username involved in the event'
},
ip_address: {
type: DataTypes.INET,
allowNull: true,
comment: 'Client IP address'
},
client_ip: {
type: DataTypes.INET,
allowNull: true,
comment: 'Real client IP (if behind proxy/load balancer)'
},
user_agent: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'User agent string'
},
rdns: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Reverse DNS lookup of IP address'
},
country_code: {
type: DataTypes.STRING(2),
allowNull: true,
comment: 'ISO country code from IP geolocation'
},
country_name: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'Country name from IP geolocation'
},
city: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'City from IP geolocation'
},
is_high_risk_country: {
type: DataTypes.BOOLEAN,
defaultValue: false,
comment: 'Whether the country is flagged as high-risk'
},
message: {
type: DataTypes.TEXT,
allowNull: false,
comment: 'Detailed description of the security event'
},
metadata: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: {},
comment: 'Additional event-specific data'
},
session_id: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Session ID if applicable'
},
request_id: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Request ID for correlation'
},
endpoint: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'API endpoint or URL involved'
},
method: {
type: DataTypes.STRING(10),
allowNull: true,
comment: 'HTTP method'
},
status_code: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'HTTP response status code'
},
alerted: {
type: DataTypes.BOOLEAN,
defaultValue: false,
comment: 'Whether super admins have been alerted about this event'
},
created_at: {
type: DataTypes.DATE,
defaultValue: sequelize.Sequelize.NOW,
comment: 'When the event occurred'
}
}, {
tableName: 'security_logs',
timestamps: true,
createdAt: 'created_at',
updatedAt: false, // Security logs should not be updated
indexes: [
{
fields: ['tenant_id', 'created_at']
},
{
fields: ['event_type', 'created_at']
},
{
fields: ['ip_address', 'created_at']
},
{
fields: ['username', 'created_at']
},
{
fields: ['severity', 'created_at']
},
{
fields: ['country_code', 'is_high_risk_country']
},
{
fields: ['alerted', 'severity', 'created_at']
}
]
});
return SecurityLog;
};

View File

@@ -9,7 +9,7 @@ module.exports = (sequelize) => {
const Tenant = sequelize.define('Tenant', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
defaultValue: sequelize.Sequelize.UUIDV4,
primaryKey: true
},
name: {
@@ -48,6 +48,11 @@ module.exports = (sequelize) => {
defaultValue: 'local',
comment: 'Primary authentication provider'
},
allow_registration: {
type: DataTypes.BOOLEAN,
defaultValue: false,
comment: 'Whether new user registration is allowed for this tenant'
},
auth_config: {
type: DataTypes.JSONB,
allowNull: true,

Some files were not shown because too many files have changed in this diff Show More