Compare commits
548
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2ddb676fe | ||
|
|
f8c21f6000 | ||
|
|
3127182f5c | ||
|
|
0c41241bbd | ||
|
|
4985bd8f91 | ||
|
|
3a6e957710 | ||
|
|
041006c181 | ||
|
|
2bac28a50a | ||
|
|
f55b813aa1 | ||
|
|
c37a0e38d9 | ||
|
|
32b0059b10 | ||
|
|
b542bad939 | ||
|
|
cf9b0581a4 | ||
|
|
d4919dc908 | ||
|
|
30d4464067 | ||
|
|
44176b403e | ||
|
|
f756d17901 | ||
|
|
dea6d6c311 | ||
|
|
aea65eadcd | ||
|
|
1a2f0adfc8 | ||
|
|
c3b9d981f7 | ||
|
|
e062ea1507 | ||
|
|
a8c36f35c3 | ||
|
|
d599390512 | ||
|
|
4e41af51eb | ||
|
|
85a5ebd288 | ||
|
|
81372a81e2 | ||
|
|
a005ead158 | ||
|
|
2f1c800844 | ||
|
|
654880d7e4 | ||
|
|
9a7e845010 | ||
|
|
564096e463 | ||
|
|
278107a1a4 | ||
|
|
c5cc21a4b0 | ||
|
|
aa2d2f8c4b | ||
|
|
9b5b8e6db3 | ||
|
|
9c5972faf4 | ||
|
|
98f1c33318 | ||
|
|
6e24553e10 | ||
|
|
799d6c5d4a | ||
|
|
7572634780 | ||
|
|
7e219bc86f | ||
|
|
e46bf5d248 | ||
|
|
d40655134f | ||
|
|
fa1a2c4b31 | ||
|
|
ce67fcd57c | ||
|
|
c52fd972b7 | ||
|
|
b1393fa59b | ||
|
|
dac2b5063b | ||
|
|
c8b1bb8d62 | ||
|
|
601af7acd8 | ||
|
|
3781fe5cf8 | ||
|
|
a231a2da6c | ||
|
|
0df7cb5c25 | ||
|
|
a44c907745 | ||
|
|
84bb1deae5 | ||
|
|
3b5082c107 | ||
|
|
c57bf54bbb | ||
|
|
0c134dde6e | ||
|
|
727f10448f | ||
|
|
12105b3b29 | ||
|
|
cd77acdfab | ||
|
|
e99fd64882 | ||
|
|
a0cc31da70 | ||
|
|
a87c937b69 | ||
|
|
ea43ed0ca4 | ||
|
|
c4ec4f3c66 | ||
|
|
c053a373b4 | ||
|
|
9fa4bef2a4 | ||
|
|
da13d63969 | ||
|
|
f50f46d4b7 | ||
|
|
c6daccbdf0 | ||
|
|
f2f31b5ee0 | ||
|
|
476fda279d | ||
|
|
bb0026a9fd | ||
|
|
ba81e5e35a | ||
|
|
df8f6d25d4 | ||
|
|
c707b4b10f | ||
|
|
ed52b26f6d | ||
|
|
65b1ae26d1 | ||
|
|
85cf101043 | ||
|
|
bd31a4c3ad | ||
|
|
98b62a9f91 | ||
|
|
1bc470a39f | ||
|
|
0fadc28a12 | ||
|
|
6f97741862 | ||
|
|
348687670d | ||
|
|
0f8d9edb3e | ||
|
|
511c9a4dbb | ||
|
|
fe99421ec9 | ||
|
|
c051bf55b2 | ||
|
|
99ddd9214e | ||
|
|
0ca140f4ff | ||
|
|
98cf214aed | ||
|
|
efd06ea107 | ||
|
|
c7b226142f | ||
|
|
e3cd8a8b25 | ||
|
|
8bcba350d5 | ||
|
|
a44ead09a3 | ||
|
|
388717ec3c | ||
|
|
2bbe594cd2 | ||
|
|
6f2bc8e9be | ||
|
|
14d1ce842a | ||
|
|
ab1744ce20 | ||
|
|
eb21628ee4 | ||
|
|
4d51e3b344 | ||
|
|
23ee229906 | ||
|
|
635fadfce9 | ||
|
|
2d5a7a26c1 | ||
|
|
ea84b1c372 | ||
|
|
dea62547e0 | ||
|
|
1d281c0cc9 | ||
|
|
9c5ca6f906 | ||
|
|
d0d2a82a68 | ||
|
|
1421ccc917 | ||
|
|
770768eb21 | ||
|
|
b14f82b4bb | ||
|
|
ee8375756c | ||
|
|
20e6399d5e | ||
|
|
b5bfe90453 | ||
|
|
41538a7366 | ||
|
|
0aa760a786 | ||
|
|
6ad201f4a2 | ||
|
|
87b46deaf7 | ||
|
|
b5e0848436 | ||
|
|
4c9471e608 | ||
|
|
8ea8b2ded5 | ||
|
|
ac0750452c | ||
|
|
547287dba1 | ||
|
|
665df53a39 | ||
|
|
8dde6bb042 | ||
|
|
52b45d835d | ||
|
|
770a0d63f9 | ||
|
|
17572bc602 | ||
|
|
d3836648c2 | ||
|
|
9d20362cf1 | ||
|
|
59ab63c6d8 | ||
|
|
3277754290 | ||
|
|
bc4644609c | ||
|
|
8b5ea3c5d3 | ||
|
|
d63f6d270f | ||
|
|
2f3619b68d | ||
|
|
6f5c993510 | ||
|
|
c3d54b49a0 | ||
|
|
61333094df | ||
|
|
4a8ed13a87 | ||
|
|
f73dff5482 | ||
|
|
b3f6645783 | ||
|
|
1b0d927e88 | ||
|
|
f24c06852e | ||
|
|
233033f1ce | ||
|
|
7dd4d4ec7b | ||
|
|
0c24cc1a69 | ||
|
|
0f56dc1ecd | ||
|
|
6ce466d38e | ||
|
|
42a49e6461 | ||
|
|
08c6303fa6 | ||
|
|
8aaa4f5856 | ||
|
|
c3e1ce30fa | ||
|
|
9af01bb680 | ||
|
|
adb59b292b | ||
|
|
1994a5938d | ||
|
|
f2c3d109cc | ||
|
|
3ead13e479 | ||
|
|
2245e7aae8 | ||
|
|
f6c7271fb4 | ||
|
|
1bce7a300d | ||
|
|
8f7ed056c4 | ||
|
|
a30be7bc1e | ||
|
|
2dd21d6688 | ||
|
|
4795c8c557 | ||
|
|
0386449ec1 | ||
|
|
17d6229a0f | ||
|
|
65f26117fc | ||
|
|
80e896f658 | ||
|
|
d029f48f79 | ||
|
|
b2143eddeb | ||
|
|
f495383bcf | ||
|
|
78230b1fe9 | ||
|
|
4e431b4f89 | ||
|
|
16e7f938bd | ||
|
|
e2414f5e9f | ||
|
|
ea0c35fa6e | ||
|
|
36915d95fe | ||
|
|
07f72f7367 | ||
|
|
141c65149a | ||
|
|
f3858f0902 | ||
|
|
cc29cb8e43 | ||
|
|
6aa5adf5b0 | ||
|
|
df7ba1edaf | ||
|
|
da2c8fa00b | ||
|
|
bbd0ff775a | ||
|
|
9eea8d48e8 | ||
|
|
612a15810b | ||
|
|
6692c83fbf | ||
|
|
82bfbc6f23 | ||
|
|
ce1ef3cbe7 | ||
|
|
0c44af2f67 | ||
|
|
dd9080f781 | ||
|
|
a02825113a | ||
|
|
2258dc3ec6 | ||
|
|
aa2d440acb | ||
|
|
2bbde5ebd1 | ||
|
|
d0e9e0f09d | ||
|
|
6d4d6efe3f | ||
|
|
8f5d7f8387 | ||
|
|
3cb1221038 | ||
|
|
f1d5dd03cb | ||
|
|
fc317840a7 | ||
|
|
003cc5160c | ||
|
|
6f50f37462 | ||
|
|
657c2eef80 | ||
|
|
d01ddd61b6 | ||
|
|
6738296830 | ||
|
|
3ef478c53a | ||
|
|
5a73700f57 | ||
|
|
6c39612c07 | ||
|
|
5986160265 | ||
|
|
f9cc34c12f | ||
|
|
b9206d3628 | ||
|
|
cd4619845b | ||
|
|
9fb6e67ed3 | ||
|
|
929054d5bd | ||
|
|
414fb32c32 | ||
|
|
aca5c279b5 | ||
|
|
6685f4c806 | ||
|
|
7059a3d88f | ||
|
|
16a5b43695 | ||
|
|
c8dfbc8e2d | ||
|
|
93a3083c60 | ||
|
|
7a56006533 | ||
|
|
50bd4344bc | ||
|
|
2351617177 | ||
|
|
fb55f1272d | ||
|
|
cae9a800c4 | ||
|
|
2d4681d079 | ||
|
|
564fcea8d7 | ||
|
|
703ecece63 | ||
|
|
41b41a3c53 | ||
|
|
cd43253061 | ||
|
|
5e772215a9 | ||
|
|
2535192b2c | ||
|
|
3d4e607a9d | ||
|
|
8c9bb6e5d5 | ||
|
|
d2a30db3c1 | ||
|
|
9d5007c05c | ||
|
|
18c1da4a62 | ||
|
|
18347fef3b | ||
|
|
ed0c6136e6 | ||
|
|
40af17083a | ||
|
|
3f076fafcd | ||
|
|
ea4de527b2 | ||
|
|
c844eb2a22 | ||
|
|
8069d7acf0 | ||
|
|
1272976c1a | ||
|
|
36a7a18c35 | ||
|
|
7b6301e498 | ||
|
|
930bc2d7d2 | ||
|
|
6bf0a53d21 | ||
|
|
4f421433c2 | ||
|
|
08d4472d8b | ||
|
|
e88854c0e5 | ||
|
|
5404cebf5b | ||
|
|
fc1bff2110 | ||
|
|
19cd41a8e5 | ||
|
|
d20384dfe0 | ||
|
|
8c205535eb | ||
|
|
753d091dbb | ||
|
|
00d5020824 | ||
|
|
247de555ce | ||
|
|
ca83872151 | ||
|
|
049e1107e6 | ||
|
|
364d6f4555 | ||
|
|
bef5832470 | ||
|
|
3d1b626baa | ||
|
|
9e1faa03b1 | ||
|
|
1249fb9612 | ||
|
|
0f34bf85a8 | ||
|
|
6ea4a74675 | ||
|
|
4c52cc338e | ||
|
|
1d236678b3 | ||
|
|
8fe162e16c | ||
|
|
0c657276a5 | ||
|
|
d69fed6514 | ||
|
|
b080e0de71 | ||
|
|
824ec8a9ca | ||
|
|
6ff36405ba | ||
|
|
acb4cb9c82 | ||
|
|
fcf23c3872 | ||
|
|
51be02477b | ||
|
|
6d437bd2be | ||
|
|
775c3597dc | ||
|
|
0f14e378d7 | ||
|
|
afb6adf1e6 | ||
|
|
2738f40c5e | ||
|
|
94984ac11b | ||
|
|
c7a7cef1a2 | ||
|
|
69e69ee315 | ||
|
|
3771a61c49 | ||
|
|
f7f676ace9 | ||
|
|
961fca0f64 | ||
|
|
b507bf2ae6 | ||
|
|
48d01e3c6c | ||
|
|
b0e269209e | ||
|
|
09c88a1c36 | ||
|
|
2ea0d04c71 | ||
|
|
78b45c4a5b | ||
|
|
e60e4a49f8 | ||
|
|
6351bf78ff | ||
|
|
f0cd45c813 | ||
|
|
a3520e4dfd | ||
|
|
e193c1c6f5 | ||
|
|
89fe289fc5 | ||
|
|
e34af8659b | ||
|
|
022fad92a9 | ||
|
|
ec2de901aa | ||
|
|
b81f5a2ee3 | ||
|
|
acbb834889 | ||
|
|
7ec4adcb24 | ||
|
|
cbf2ecfb9d | ||
|
|
8779b7d607 | ||
|
|
cd0af7e8f1 | ||
|
|
1311e72ca0 | ||
|
|
7742d5f5b4 | ||
|
|
df08258ba1 | ||
|
|
30ee123072 | ||
|
|
051d57bb39 | ||
|
|
f10c8fcb48 | ||
|
|
d401c8fa0f | ||
|
|
4e6437a180 | ||
|
|
34374e9a18 | ||
|
|
432af79055 | ||
|
|
27d08baa2d | ||
|
|
72aed22125 | ||
|
|
2d155fa0c4 | ||
|
|
058518710e | ||
|
|
9666440e80 | ||
|
|
f5c380f75b | ||
|
|
33441d11ac | ||
|
|
2f77be8045 | ||
|
|
0883173d7b | ||
|
|
7521ef0519 | ||
|
|
3eb90968ac | ||
|
|
c5894136f6 | ||
|
|
17b29620fd | ||
|
|
5e7ee1b657 | ||
|
|
5bf4a2d95b | ||
|
|
1657ebc288 | ||
|
|
0ba647ad51 | ||
|
|
cc61e27fad | ||
|
|
b9bcc1d23a | ||
|
|
497363b491 | ||
|
|
f2629d9ac7 | ||
|
|
ced289095c | ||
|
|
38b011a767 | ||
|
|
ba3f1739f4 | ||
|
|
81a13b0e59 | ||
|
|
4a34ec91e5 | ||
|
|
04725942ee | ||
|
|
9d62fb42c4 | ||
|
|
b9f048c57d | ||
|
|
5b651a782a | ||
|
|
f8c9ae67f8 | ||
|
|
4b39c16d25 | ||
|
|
bc886d9476 | ||
|
|
9ce172e78a | ||
|
|
a9d87e4e6b | ||
|
|
f6e551807f | ||
|
|
3be110097b | ||
|
|
f327837d82 | ||
|
|
13e09968d9 | ||
|
|
67195c6051 | ||
|
|
fcb72e6716 | ||
|
|
349c49462f | ||
|
|
6dafbaae48 | ||
|
|
25452ff063 | ||
|
|
ab7d3f8e4c | ||
|
|
21c0e4b354 | ||
|
|
e18c773af9 | ||
|
|
c833f2cbf2 | ||
|
|
db9c200f89 | ||
|
|
017c378582 | ||
|
|
a80b49a03e | ||
|
|
013c0c0eeb | ||
|
|
9258c397e0 | ||
|
|
81946e50a9 | ||
|
|
365e267b8d | ||
|
|
f9e2df085b | ||
|
|
940a4890e2 | ||
|
|
c399d832e5 | ||
|
|
ac4b4ed9cf | ||
|
|
c2bf6779c0 | ||
|
|
44712608b7 | ||
|
|
d4c2bd2789 | ||
|
|
a2be781f81 | ||
|
|
c4c4d41e4d | ||
|
|
693e319e26 | ||
|
|
c6bb272318 | ||
|
|
c69483a12c | ||
|
|
137ebf6d22 | ||
|
|
3f35163481 | ||
|
|
d378ddb42b | ||
|
|
796261c5f5 | ||
|
|
7fa5e70d5b | ||
|
|
ca1ecdf848 | ||
|
|
b53a2b6b78 | ||
|
|
b8b084c188 | ||
|
|
0cec1dbb17 | ||
|
|
65c6854e44 | ||
|
|
9013d5dbf0 | ||
|
|
2a459dda20 | ||
|
|
afe2637f2d | ||
|
|
26615d80e7 | ||
|
|
5c73b54428 | ||
|
|
a9a46024e2 | ||
|
|
1811e1de79 | ||
|
|
25e732bf45 | ||
|
|
5256853351 | ||
|
|
3a5f818e7c | ||
|
|
15171dd5af | ||
|
|
79cabe6580 | ||
|
|
5f89d93db6 | ||
|
|
d98112eae4 | ||
|
|
e0566dfcfa | ||
|
|
1a24ba6ef7 | ||
|
|
74f1580cba | ||
|
|
bce73bc38f | ||
|
|
e40d2d776f | ||
|
|
67dad45214 | ||
|
|
406ed16ce4 | ||
|
|
9cb6200895 | ||
|
|
851f5e486d | ||
|
|
a0c55459d1 | ||
|
|
37309e0727 | ||
|
|
9181f11b13 | ||
|
|
8e10c48d2f | ||
|
|
55a000a7fc | ||
|
|
b0e10f0c62 | ||
|
|
ff847f0910 | ||
|
|
fa2c448bac | ||
|
|
4836801004 | ||
|
|
eab5c10fa6 | ||
|
|
1128110be3 | ||
|
|
bce085c028 | ||
|
|
15d9327d46 | ||
|
|
4380aa9b3e | ||
|
|
c973a3bfa5 | ||
|
|
6f7327b774 | ||
|
|
eba4052522 | ||
|
|
89ab1df2db | ||
|
|
63b3c1bff1 | ||
|
|
04a836e824 | ||
|
|
90ef982066 | ||
|
|
32a8488b7b | ||
|
|
69d9a79b05 | ||
|
|
d70e99c973 | ||
|
|
20d44121e6 | ||
|
|
d8c21f996c | ||
|
|
868b7b890e | ||
|
|
fc5f02b022 | ||
|
|
47834d4288 | ||
|
|
43e48010e5 | ||
|
|
508fcb9861 | ||
|
|
b2860ffbd3 | ||
|
|
0c7264bc52 | ||
|
|
c512ddba49 | ||
|
|
6a6f9595f6 | ||
|
|
1098725bd1 | ||
|
|
88e5ac2f71 | ||
|
|
e5e06baeb3 | ||
|
|
41e576039f | ||
|
|
56e7aed87f | ||
|
|
c33086d8b5 | ||
|
|
dc75ffb9e6 | ||
|
|
5da74ca7e0 | ||
|
|
14d19fcd8b | ||
|
|
37a8a0fe5a | ||
|
|
445c0c4d2e | ||
|
|
b5379c5352 | ||
|
|
c8f3eb69e5 | ||
|
|
5c92f6a3cc | ||
|
|
ee2703ca4b | ||
|
|
a4edda6932 | ||
|
|
002d011b18 | ||
|
|
baf552bf52 | ||
|
|
015b42aa61 | ||
|
|
4e03e7f49d | ||
|
|
86c729a83f | ||
|
|
86d526db28 | ||
|
|
b82128a90c | ||
|
|
05bbc19a15 | ||
|
|
54a4173e72 | ||
|
|
ed1ac628a1 | ||
|
|
30669d23e3 | ||
|
|
c8d57ac3fa | ||
|
|
636d3c670c | ||
|
|
56c6be523d | ||
|
|
08ba282528 | ||
|
|
ea47e4c0a0 | ||
|
|
7f341efdb3 | ||
|
|
fd642011ba | ||
|
|
fe5af9231e | ||
|
|
e438bac0f5 | ||
|
|
5c68ac9e2e | ||
|
|
d7a30cd0dd | ||
|
|
66036b08bc | ||
|
|
c84fb63b25 | ||
|
|
f0d6676917 | ||
|
|
f23831be5e | ||
|
|
0c7f479952 | ||
|
|
a8748fa439 | ||
|
|
82caec402a | ||
|
|
3df6dcfbe7 | ||
|
|
653f42bccb | ||
|
|
3fb6458edf | ||
|
|
8a0411341d | ||
|
|
7d61baad03 | ||
|
|
0169501676 | ||
|
|
01eb84654d | ||
|
|
f58c38a1aa | ||
|
|
0a4ff4b883 | ||
|
|
be4748b8fa | ||
|
|
ec4b3a6408 | ||
|
|
5382b1999f | ||
|
|
e54d9aaf44 | ||
|
|
99175feda4 | ||
|
|
3da5734571 | ||
|
|
f2c70a4d0b | ||
|
|
dcb9cfaf29 | ||
|
|
8085e72cf4 | ||
|
|
ea0a6a62a8 | ||
|
|
c3f988cc02 | ||
|
|
bf70143cca | ||
|
|
4cee3dfbe0 | ||
|
|
5e3dc1e44d | ||
|
|
74b017d9f4 | ||
|
|
60669999d1 | ||
|
|
ea17975406 | ||
|
|
2c197bc59b | ||
|
|
4f94e98140 | ||
|
|
46943b147a | ||
|
|
8d42ca8b5e | ||
|
|
0d98335354 | ||
|
|
0a3d1e9e5e | ||
|
|
3214d889fa | ||
|
|
408cdeb5c3 | ||
|
|
be711149f7 | ||
|
|
680cc284d6 |
No files matched your search
+2
-1
@@ -5,4 +5,5 @@ npm-debug.log
|
|||||||
README.md
|
README.md
|
||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
dev
|
dev
|
||||||
|
.build
|
||||||
@@ -20,8 +20,6 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
|||||||
# DB_PASSWORD='password'
|
# DB_PASSWORD='password'
|
||||||
# DB_NAME='name-of-database'
|
# DB_NAME='name-of-database'
|
||||||
|
|
||||||
# @see https://next-auth.js.org/configuration/options#nextauth_url
|
|
||||||
AUTH_URL='http://localhost:3000'
|
|
||||||
|
|
||||||
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
||||||
# @see https://next-auth.js.org/configuration/options#secret
|
# @see https://next-auth.js.org/configuration/options#secret
|
||||||
|
|||||||
+14
-26
@@ -1,31 +1,19 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": [
|
extends: ["config:recommended"],
|
||||||
"config:recommended"
|
packageRules: [
|
||||||
],
|
|
||||||
"packageRules": [
|
|
||||||
{
|
{
|
||||||
"matchPackagePatterns": [
|
matchPackagePatterns: ["^@homarr/"],
|
||||||
"^@homarr/",
|
enabled: false,
|
||||||
"tsx" // Disabled for now as version 0.14.4 did not work with the current version of homarr. It resulted in a ERR_MODULE_NOT_FOUND error
|
|
||||||
],
|
|
||||||
"enabled": false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": [
|
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
|
||||||
"minor",
|
automerge: true,
|
||||||
"patch",
|
},
|
||||||
"pin",
|
|
||||||
"digest"
|
|
||||||
],
|
|
||||||
"automerge": true
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"updateInternalDeps": true,
|
updateInternalDeps: true,
|
||||||
"rangeStrategy": "bump",
|
rangeStrategy: "bump",
|
||||||
"automerge": false,
|
automerge: false,
|
||||||
"baseBranches": [
|
baseBranches: ["dev"],
|
||||||
"dev"
|
dependencyDashboard: false,
|
||||||
],
|
}
|
||||||
"dependencyDashboard": false
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,30 @@
|
|||||||
name: "[Deployment] Release"
|
name: "[Deployment] Release"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
push:
|
||||||
types:
|
|
||||||
- closed
|
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
workflow_dispatch: {}
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
send-notifications:
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
description: Send notifications
|
||||||
|
push-image:
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
description: Push Docker Image
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
TURBO_TELEMETRY_DISABLED: 1
|
TURBO_TELEMETRY_DISABLED: 1
|
||||||
|
|
||||||
concurrency: production
|
concurrency: production
|
||||||
@@ -26,19 +38,21 @@ jobs:
|
|||||||
node-version: [20]
|
node-version: [20]
|
||||||
steps:
|
steps:
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Get Next Version
|
- name: Get Next Version
|
||||||
id: semver
|
id: semver
|
||||||
uses: ietf-tools/semver-action@v1
|
uses: ietf-tools/semver-action@v1
|
||||||
with:
|
with:
|
||||||
token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
branch: master
|
branch: dev
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
@@ -51,17 +65,19 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: "pnpm"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
- name: Build artifacts
|
|
||||||
run: pnpm build
|
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Built application artifacts. Building images..."
|
args: "Built application artifacts. Building images..."
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -72,23 +88,48 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest
|
type=raw,value=alpha
|
||||||
type=raw,value=${{ steps.semver.outputs.next }}
|
type=raw,value=early-adopters
|
||||||
|
# tags: |
|
||||||
|
# type=raw,value=latest
|
||||||
|
# type=raw,value=${{ steps.semver.outputs.next }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: buildPushAction
|
id: buildPushAction
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
|
if: ${{ github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null }}
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/arm/v7,linux/arm/v6
|
platforms: linux/amd64,linux/arm64
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
network: host
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
- name: Build
|
||||||
|
id: buildPushDryAction
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
if: ${{ github.events.inputs.push-image == 'false' }}
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
network: host
|
network: host
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false && (github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. This was a dry run."
|
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
|
||||||
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false && !(github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
uses: Ilshidur/action-discord@master
|
||||||
|
with:
|
||||||
|
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushDryAction.outputs.imageid }}'. This was a dry run."
|
||||||
@@ -4,6 +4,12 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
send-notifications:
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
description: Send notifications
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -14,6 +20,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
@@ -30,27 +37,39 @@ jobs:
|
|||||||
token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
branch: dev
|
branch: dev
|
||||||
- name: Create pull request
|
- name: Create pull request
|
||||||
uses: devops-infra/action-pull-request@v0.5.5
|
run: "gh pr create --title \"chore(release): automatic release ${{ steps.semver.outputs.next }}\" --body \"**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``\" --base main --head dev --label automerge"
|
||||||
id: create-pull-request
|
env:
|
||||||
with:
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
title: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
|
||||||
new_string: "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``"
|
|
||||||
source_branch: dev
|
|
||||||
target_branch: main
|
|
||||||
label: automerge
|
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
|
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
|
||||||
|
- name: Obtain token
|
||||||
|
id: obtainApprovalToken
|
||||||
|
uses: tibdex/github-app-token@v2
|
||||||
|
with:
|
||||||
|
private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||||
|
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||||
|
- name: Approve PR
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
|
||||||
|
run: |
|
||||||
|
gh pr review --approve --body "Automatically approved by GitHub Action"
|
||||||
|
- name: Obtain token
|
||||||
|
id: obtainMergeToken
|
||||||
|
uses: tibdex/github-app-token@v2
|
||||||
|
with:
|
||||||
|
private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
||||||
|
app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||||
- id: automerge
|
- id: automerge
|
||||||
if: ${{ steps.semver.outputs.bump != 'major' }}
|
if: ${{ steps.semver.outputs.bump != 'major' }}
|
||||||
name: automerge
|
name: automerge
|
||||||
uses: "pascalgn/automerge-action@v0.16.3"
|
uses: "pascalgn/automerge-action@v0.16.4"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
|
||||||
MERGE_METHOD: merge # we prefer merge commits for merging to master
|
MERGE_METHOD: merge # we prefer merge commits for merging to master
|
||||||
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
||||||
MERGE_DELETE_BRANCH: false # never set to true!
|
MERGE_DELETE_BRANCH: false # never set to true!
|
||||||
@@ -60,21 +79,21 @@ jobs:
|
|||||||
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
||||||
|
|
||||||
- name: Merged Discord notification
|
- name: Merged Discord notification
|
||||||
if: ${{ steps.automerge.outputs.mergeResult == 'merged' }}
|
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
||||||
- name: Major Bump Discord notification
|
- name: Major Bump Discord notification
|
||||||
if: ${{ steps.semver.outputs.bump == 'major' }}
|
if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
||||||
- name: Discord Fail Notification
|
- name: Discord Fail Notification
|
||||||
if: failure()
|
if: failure() && github.events.inputs.send-notifications
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
approve-renovate-prs:
|
approve-renovate-prs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.actor_id == 158783068 # Id of renovate bot see https://api.github.com/users/homarr-renovate%5Bbot%5D
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -21,6 +22,4 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
run: |
|
run: |
|
||||||
for pr in $(gh pr list --author homarr-renovate[bot] --json number --jq .[].number); do
|
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
|
||||||
gh pr review $pr --approve --body "Automatically approved by GitHub Action"
|
|
||||||
done
|
|
||||||
+8
-2
@@ -48,10 +48,16 @@ yarn-error.log*
|
|||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
# database
|
# database
|
||||||
db.sqlite
|
*.sqlite
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
apps/tasks/tasks.cjs
|
apps/tasks/tasks.cjs
|
||||||
apps/websocket/wssServer.cjs
|
apps/websocket/wssServer.cjs
|
||||||
|
apps/nextjs/.million/
|
||||||
|
packages/cli/cli.cjs
|
||||||
|
|
||||||
|
|
||||||
|
#personal backgrounds
|
||||||
|
apps/nextjs/public/images/background.png
|
||||||
Vendored
+2
-1
@@ -2,6 +2,7 @@
|
|||||||
"recommendations": [
|
"recommendations": [
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"yoavbls.pretty-ts-errors"
|
"yoavbls.pretty-ts-errors",
|
||||||
|
"million.million-lint"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Vendored
+11
-2
@@ -9,13 +9,22 @@
|
|||||||
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
||||||
"prettier.configPath": "./tooling/prettier/index.mjs",
|
"prettier.configPath": "./tooling/prettier/index.mjs",
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
|
"ajnart",
|
||||||
"cqmin",
|
"cqmin",
|
||||||
|
"gridstack",
|
||||||
"homarr",
|
"homarr",
|
||||||
"jellyfin",
|
"jellyfin",
|
||||||
|
"mantine",
|
||||||
|
"manuel-rw",
|
||||||
|
"Meierschlumpf",
|
||||||
|
"overseerr",
|
||||||
|
"Sabnzbd",
|
||||||
|
"SeDemal",
|
||||||
|
"Sonarr",
|
||||||
"superjson",
|
"superjson",
|
||||||
|
"tabler",
|
||||||
"trpc",
|
"trpc",
|
||||||
"Umami",
|
"Umami"
|
||||||
"Sonarr"
|
|
||||||
],
|
],
|
||||||
"i18n-ally.dirStructure": "auto",
|
"i18n-ally.dirStructure": "auto",
|
||||||
"i18n-ally.enabledFrameworks": ["next-international"],
|
"i18n-ally.enabledFrameworks": ["next-international"],
|
||||||
|
|||||||
+41
-15
@@ -1,8 +1,9 @@
|
|||||||
FROM node:20.15.0-alpine AS base
|
FROM node:20.18.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN apk update
|
RUN apk update
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -11,6 +12,7 @@ RUN turbo prune @homarr/nextjs --docker --out-dir ./next-out
|
|||||||
RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
|
RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
|
||||||
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
|
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
|
||||||
RUN turbo prune @homarr/db --docker --out-dir ./migration-out
|
RUN turbo prune @homarr/db --docker --out-dir ./migration-out
|
||||||
|
RUN turbo prune @homarr/cli --docker --out-dir ./cli-out
|
||||||
|
|
||||||
# Add lockfile and package.json's of isolated subworkspace
|
# Add lockfile and package.json's of isolated subworkspace
|
||||||
FROM base AS installer
|
FROM base AS installer
|
||||||
@@ -22,20 +24,19 @@ WORKDIR /app
|
|||||||
COPY .gitignore .gitignore
|
COPY .gitignore .gitignore
|
||||||
|
|
||||||
COPY --from=builder /app/tasks-out/json/ .
|
COPY --from=builder /app/tasks-out/json/ .
|
||||||
COPY --from=builder /app/tasks-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
|
||||||
RUN corepack enable pnpm && pnpm install
|
|
||||||
|
|
||||||
COPY --from=builder /app/websocket-out/json/ .
|
COPY --from=builder /app/websocket-out/json/ .
|
||||||
COPY --from=builder /app/websocket-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
|
||||||
RUN corepack enable pnpm && pnpm install
|
|
||||||
|
|
||||||
COPY --from=builder /app/migration-out/json/ .
|
COPY --from=builder /app/migration-out/json/ .
|
||||||
COPY --from=builder /app/migration-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=builder /app/cli-out/json/ .
|
||||||
RUN corepack enable pnpm && pnpm install
|
|
||||||
|
|
||||||
COPY --from=builder /app/next-out/json/ .
|
COPY --from=builder /app/next-out/json/ .
|
||||||
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Is used for postinstall of docs definitions
|
||||||
|
COPY --from=builder /app/packages/definitions/src/docs ./packages/definitions/src/docs
|
||||||
|
|
||||||
|
# Uses the lockfile to install the dependencies
|
||||||
|
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
||||||
|
|
||||||
|
# Install sharp for image optimization
|
||||||
RUN corepack enable pnpm && pnpm install sharp -w
|
RUN corepack enable pnpm && pnpm install sharp -w
|
||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
@@ -43,24 +44,45 @@ COPY --from=builder /app/tasks-out/full/ .
|
|||||||
COPY --from=builder /app/websocket-out/full/ .
|
COPY --from=builder /app/websocket-out/full/ .
|
||||||
COPY --from=builder /app/next-out/full/ .
|
COPY --from=builder /app/next-out/full/ .
|
||||||
COPY --from=builder /app/migration-out/full/ .
|
COPY --from=builder /app/migration-out/full/ .
|
||||||
|
COPY --from=builder /app/cli-out/full/ .
|
||||||
|
|
||||||
# Copy static data as it is not part of the build
|
# Copy static data as it is not part of the build
|
||||||
COPY static-data ./static-data
|
COPY static-data ./static-data
|
||||||
ARG SKIP_ENV_VALIDATION=true
|
ARG SKIP_ENV_VALIDATION='true'
|
||||||
RUN corepack enable pnpm && pnpm turbo run build
|
ARG CI='true'
|
||||||
|
ARG DISABLE_REDIS_LOGS='true'
|
||||||
|
RUN corepack enable pnpm && pnpm build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache redis
|
# gettext is required for envsubst
|
||||||
|
RUN apk add --no-cache redis nginx bash gettext
|
||||||
RUN mkdir /appdata
|
RUN mkdir /appdata
|
||||||
RUN mkdir /appdata/db
|
RUN mkdir /appdata/db
|
||||||
RUN mkdir /appdata/redis
|
RUN mkdir /appdata/redis
|
||||||
VOLUME /appdata
|
VOLUME /appdata
|
||||||
|
RUN mkdir /secrets
|
||||||
|
VOLUME /secrets
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Don't run production as root
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Enable homarr cli
|
||||||
|
COPY --from=installer --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
|
||||||
|
RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr
|
||||||
|
RUN chmod +x /usr/bin/homarr
|
||||||
|
|
||||||
|
# Don't run production as root
|
||||||
RUN chown -R nextjs:nodejs /appdata
|
RUN chown -R nextjs:nodejs /appdata
|
||||||
|
RUN chown -R nextjs:nodejs /secrets
|
||||||
|
RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
|
||||||
|
mkdir -p /var/log/nginx && chown -R nextjs:nodejs /var/log/nginx && \
|
||||||
|
mkdir -p /var/lib/nginx && chown -R nextjs:nodejs /var/lib/nginx && \
|
||||||
|
touch /run/nginx/nginx.pid && chown -R nextjs:nodejs /run/nginx/nginx.pid && \
|
||||||
|
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
COPY --from=installer /app/apps/nextjs/next.config.mjs .
|
COPY --from=installer /app/apps/nextjs/next.config.mjs .
|
||||||
@@ -78,10 +100,14 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
|
|||||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
|
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
|
||||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
|
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
|
||||||
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
||||||
|
COPY --chown=nextjs:nodejs scripts/generateEncryptionKey.js ./generateEncryptionKey.js
|
||||||
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
|
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
|
||||||
|
COPY --chown=nextjs:nodejs nginx.conf /etc/nginx/templates/nginx.conf
|
||||||
|
|
||||||
|
|
||||||
ENV DB_URL='/appdata/db/db.sqlite'
|
ENV DB_URL='/appdata/db/db.sqlite'
|
||||||
ENV DB_DIALECT='sqlite'
|
ENV DB_DIALECT='sqlite'
|
||||||
ENV DB_DRIVER='better-sqlite3'
|
ENV DB_DRIVER='better-sqlite3'
|
||||||
|
ENV AUTH_PROVIDERS='credentials'
|
||||||
|
|
||||||
CMD ["sh", "run.sh"]
|
CMD ["sh", "run.sh"]
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
// Importing env files here to validate on build
|
// Importing env files here to validate on build
|
||||||
import "@homarr/auth/env.mjs";
|
import "@homarr/auth/env.mjs";
|
||||||
|
|
||||||
|
import MillionLint from "@million/lint";
|
||||||
|
|
||||||
import "./src/env.mjs";
|
import "./src/env.mjs";
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
@@ -9,11 +12,14 @@ const config = {
|
|||||||
/** We already do linting and typechecking as separate tasks in CI */
|
/** We already do linting and typechecking as separate tasks in CI */
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
webpack: (config) => {
|
webpack: (config, { isServer }) => {
|
||||||
config.module.rules.push({
|
if (isServer) {
|
||||||
test: /\.node$/,
|
config.module.rules.push({
|
||||||
loader: "node-loader",
|
test: /\.node$/,
|
||||||
});
|
loader: "node-loader",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -25,4 +31,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
|
||||||
|
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
+38
-32
@@ -7,12 +7,13 @@
|
|||||||
"build": "pnpm with-env next build",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .next .turbo node_modules",
|
"clean": "git clean -xdf .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev",
|
"dev": "pnpm with-env next dev",
|
||||||
"lint": "eslint",
|
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
"lint": "eslint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"with-env": "dotenv -e ../../.env --"
|
"with-env": "dotenv -e ../../.env --"
|
||||||
},
|
},
|
||||||
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/analytics": "workspace:^0.1.0",
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
@@ -22,27 +23,31 @@
|
|||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/form": "workspace:^0.1.0",
|
"@homarr/form": "workspace:^0.1.0",
|
||||||
"@homarr/gridstack": "^1.0.0",
|
"@homarr/gridstack": "^1.0.3",
|
||||||
"@homarr/integrations": "workspace:^0.1.0",
|
"@homarr/integrations": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
"@homarr/modals": "workspace:^0.1.0",
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
|
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||||
"@homarr/notifications": "workspace:^0.1.0",
|
"@homarr/notifications": "workspace:^0.1.0",
|
||||||
|
"@homarr/old-schema": "workspace:^0.1.0",
|
||||||
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@homarr/spotlight": "workspace:^0.1.0",
|
"@homarr/spotlight": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/colors-generator": "^7.11.1",
|
"@mantine/colors-generator": "^7.13.4",
|
||||||
"@mantine/core": "^7.11.1",
|
"@mantine/core": "^7.13.4",
|
||||||
"@mantine/hooks": "^7.11.1",
|
"@mantine/hooks": "^7.13.4",
|
||||||
"@mantine/modals": "^7.11.1",
|
"@mantine/modals": "^7.13.4",
|
||||||
"@mantine/tiptap": "^7.11.1",
|
"@mantine/tiptap": "^7.13.4",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@million/lint": "1.0.11",
|
||||||
"@t3-oss/env-nextjs": "^0.10.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tanstack/react-query": "^5.50.1",
|
"@tabler/icons-react": "^3.20.0",
|
||||||
"@tanstack/react-query-devtools": "^5.50.1",
|
"@tanstack/react-query": "^5.59.16",
|
||||||
"@tanstack/react-query-next-experimental": "5.50.1",
|
"@tanstack/react-query-devtools": "^5.59.16",
|
||||||
"@tabler/icons-react": "^3.9.0",
|
"@tanstack/react-query-next-experimental": "5.59.16",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
@@ -50,23 +55,24 @@
|
|||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "0.10.0",
|
"@xterm/addon-fit": "0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"chroma-js": "^2.4.2",
|
"chroma-js": "^3.1.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
"glob": "^10.4.3",
|
"glob": "^11.0.0",
|
||||||
"jotai": "^2.8.4",
|
"jotai": "^2.10.1",
|
||||||
"mantine-react-table": "2.0.0-beta.5",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.16",
|
||||||
"postcss-preset-mantine": "^1.15.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-error-boundary": "^4.0.13",
|
"react-error-boundary": "^4.1.2",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.77.6",
|
"sass": "^1.80.4",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
|
"swagger-ui-react": "^5.17.14",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -74,15 +80,15 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "2.4.4",
|
"@types/chroma-js": "2.4.4",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.17.1",
|
||||||
"@types/prismjs": "^1.26.4",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.1",
|
||||||
"concurrently": "^8.2.2",
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
"eslint": "^9.6.0",
|
"concurrently": "^9.0.1",
|
||||||
|
"eslint": "^9.13.0",
|
||||||
"node-loader": "^2.0.0",
|
"node-loader": "^2.0.0",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "^5.5.3"
|
"typescript": "^5.6.3"
|
||||||
},
|
}
|
||||||
"prettier": "@homarr/prettier-config"
|
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||||
|
import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||||
|
|
||||||
|
export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
|
||||||
|
const manager = useColorSchemeManager();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DirectionProvider>
|
||||||
|
<MantineProvider
|
||||||
|
defaultColorScheme="dark"
|
||||||
|
colorSchemeManager={manager}
|
||||||
|
theme={createTheme({
|
||||||
|
primaryColor: "red",
|
||||||
|
autoContrast: true,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
</DirectionProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function useColorSchemeManager(): MantineColorSchemeManager {
|
||||||
|
const key = "homarr-color-scheme";
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
|
||||||
|
setClientCookie(key, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutate: mutateColorScheme } = clientApi.user.changeColorScheme.useMutation({
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
updateCookieValue(variables.colorScheme);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: (defaultValue) => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cookies = parseCookies(document.cookie);
|
||||||
|
return (cookies[key] as MantineColorScheme | undefined) ?? defaultValue;
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
set: (value) => {
|
||||||
|
if (value === "auto") return;
|
||||||
|
try {
|
||||||
|
if (session) {
|
||||||
|
mutateColorScheme({ colorScheme: value });
|
||||||
|
}
|
||||||
|
updateCookieValue(value);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[@mantine/core] Color scheme manager was unable to save color scheme.", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subscribe: () => undefined,
|
||||||
|
unsubscribe: () => undefined,
|
||||||
|
clear: () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect } from "react";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { SessionProvider } from "@homarr/auth/client";
|
import { SessionProvider, signIn } from "@homarr/auth/client";
|
||||||
|
|
||||||
interface AuthProviderProps {
|
interface AuthProviderProps extends AuthContextProps {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthProvider = ({ children, session }: PropsWithChildren<AuthProviderProps>) => {
|
export const AuthProvider = ({ children, session, logoutUrl }: PropsWithChildren<AuthProviderProps>) => {
|
||||||
return <SessionProvider session={session}>{children}</SessionProvider>;
|
useLoginRedirectOnSessionExpiry(session);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionProvider session={session}>
|
||||||
|
<AuthContext.Provider value={{ logoutUrl }}>{children}</AuthContext.Provider>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthContextProps {
|
||||||
|
logoutUrl: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextProps | null>(null);
|
||||||
|
|
||||||
|
export const useAuthContext = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
|
||||||
|
if (!context) throw new Error("useAuthContext must be used within an AuthProvider");
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useLoginRedirectOnSessionExpiry = (session: Session | null) => {
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
if (!session) return () => {};
|
||||||
|
//setTimeout doesn't allow for a number higher than 2147483647 (2³¹-1 , or roughly 24 days)
|
||||||
|
const timeout = setTimeout(() => void signIn(), Math.min(dayjs(session.expires).diff(), 2147483647));
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [session]);
|
||||||
};
|
};
|
||||||
@@ -5,14 +5,37 @@ import { useState } from "react";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||||
import { createWSClient, loggerLink, unstable_httpBatchStreamLink, wsLink } from "@trpc/client";
|
import {
|
||||||
|
createWSClient,
|
||||||
|
httpLink,
|
||||||
|
isNonJsonSerializable,
|
||||||
|
loggerLink,
|
||||||
|
splitLink,
|
||||||
|
unstable_httpBatchStreamLink,
|
||||||
|
wsLink,
|
||||||
|
} from "@trpc/client";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
|
import { env } from "~/env.mjs";
|
||||||
|
|
||||||
|
const constructWebsocketUrl = () => {
|
||||||
|
const fallback = "ws://localhost:3001/websockets";
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.NODE_ENV === "development") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `ws://${window.location.hostname}:${window.location.port}/websockets`;
|
||||||
|
};
|
||||||
|
|
||||||
const wsClient = createWSClient({
|
const wsClient = createWSClient({
|
||||||
url: "ws://localhost:3001",
|
url: constructWebsocketUrl(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||||
@@ -34,18 +57,29 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
enabled: (opts) =>
|
enabled: (opts) =>
|
||||||
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||||
}),
|
}),
|
||||||
(args) => {
|
splitLink({
|
||||||
return ({ op, next }) => {
|
condition: ({ type }) => type === "subscription",
|
||||||
console.log("op", op.type, op.input, op.path, op.id);
|
true: wsLink<AppRouter>({
|
||||||
if (op.type === "subscription") {
|
client: wsClient,
|
||||||
const link = wsLink<AppRouter>({
|
transformer: superjson,
|
||||||
client: wsClient,
|
}),
|
||||||
transformer: superjson,
|
false: splitLink({
|
||||||
});
|
condition: ({ input }) => isNonJsonSerializable(input),
|
||||||
return link(args)({ op, next });
|
true: httpLink({
|
||||||
}
|
/**
|
||||||
|
* We don't want to transform the data here as we want to use form data
|
||||||
return unstable_httpBatchStreamLink({
|
*/
|
||||||
|
transformer: {
|
||||||
|
serialize(object: unknown) {
|
||||||
|
return object;
|
||||||
|
},
|
||||||
|
deserialize(data: unknown) {
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
url: `${getBaseUrl()}/api/trpc`,
|
||||||
|
}),
|
||||||
|
false: unstable_httpBatchStreamLink({
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
url: `${getBaseUrl()}/api/trpc`,
|
url: `${getBaseUrl()}/api/trpc`,
|
||||||
headers() {
|
headers() {
|
||||||
@@ -53,9 +87,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
headers.set("x-trpc-source", "nextjs-react");
|
headers.set("x-trpc-source", "nextjs-react");
|
||||||
return headers;
|
return headers;
|
||||||
},
|
},
|
||||||
})(args)({ op, next });
|
}),
|
||||||
};
|
}),
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { CustomPasswordInput } from "@homarr/ui";
|
||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
@@ -64,7 +65,8 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
|||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
|
<TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
|
||||||
<PasswordInput
|
<CustomPasswordInput
|
||||||
|
withPasswordRequirements
|
||||||
label={t("field.password.label")}
|
label={t("field.password.label")}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { and, db, eq } from "@homarr/db";
|
import { and, db, eq } from "@homarr/db";
|
||||||
import { invites } from "@homarr/db/schema/sqlite";
|
import { invites } from "@homarr/db/schema/sqlite";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
@@ -19,6 +20,8 @@ interface InviteUsagePageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
|
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
|
||||||
|
if (!isProviderEnabled("credentials")) notFound();
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session) notFound();
|
if (session) notFound();
|
||||||
|
|
||||||
|
|||||||
@@ -1,78 +1,185 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Alert, Button, PasswordInput, rem, Stack, TextInput } from "@mantine/core";
|
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import type { useForm } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
interface LoginFormProps {
|
||||||
|
providers: string[];
|
||||||
|
oidcClientName: string;
|
||||||
|
isOidcAutoLoginEnabled: boolean;
|
||||||
|
callbackUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const LoginForm = () => {
|
const extendedValidation = validation.user.signIn.extend({ provider: z.enum(["credentials", "ldap"]) });
|
||||||
|
|
||||||
|
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
|
||||||
const t = useScopedI18n("user");
|
const t = useScopedI18n("user");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const form = useZodForm(extendedValidation, {
|
||||||
const form = useZodForm(validation.user.signIn, {
|
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: "",
|
name: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
provider: "credentials",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmitAsync = async (values: z.infer<typeof validation.user.signIn>) => {
|
const credentialInputsVisible = providers.includes("credentials") || providers.includes("ldap");
|
||||||
setIsLoading(true);
|
|
||||||
setError(undefined);
|
|
||||||
await signIn("credentials", {
|
|
||||||
...values,
|
|
||||||
redirect: false,
|
|
||||||
callbackUrl: "/",
|
|
||||||
})
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!response?.ok || response.error) {
|
|
||||||
throw response?.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
showSuccessNotification({
|
const onSuccess = useCallback(
|
||||||
title: t("action.login.notification.success.title"),
|
async (response: Awaited<ReturnType<typeof signIn>>) => {
|
||||||
message: t("action.login.notification.success.message"),
|
if (response && (!response.ok || response.error)) {
|
||||||
});
|
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
||||||
await revalidatePathActionAsync("/");
|
throw response.error;
|
||||||
router.push("/");
|
}
|
||||||
})
|
|
||||||
.catch((error: Error | string) => {
|
showSuccessNotification({
|
||||||
setIsLoading(false);
|
title: t("action.login.notification.success.title"),
|
||||||
setError(error.toString());
|
message: t("action.login.notification.success.message"),
|
||||||
showErrorNotification({
|
|
||||||
title: t("action.login.notification.error.title"),
|
|
||||||
message: t("action.login.notification.error.message"),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
// Redirect to the callback URL if the response is defined and comes from a credentials provider (ldap or credentials). oidc is redirected automatically.
|
||||||
|
if (response) {
|
||||||
|
await revalidatePathActionAsync("/");
|
||||||
|
router.push(callbackUrl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t, router, callbackUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onError = useCallback(() => {
|
||||||
|
setIsPending(false);
|
||||||
|
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("action.login.notification.error.title"),
|
||||||
|
message: t("action.login.notification.error.message"),
|
||||||
|
autoClose: 10000,
|
||||||
|
});
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const signInAsync = useCallback(
|
||||||
|
async (provider: string, options?: Parameters<typeof signIn>[1]) => {
|
||||||
|
setIsPending(true);
|
||||||
|
await signIn(provider, {
|
||||||
|
...options,
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl: new URL(callbackUrl, window.location.href).href,
|
||||||
|
})
|
||||||
|
.then(onSuccess)
|
||||||
|
.catch(onError);
|
||||||
|
},
|
||||||
|
[setIsPending, onSuccess, onError, callbackUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoginInProgress = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOidcAutoLoginEnabled && !isPending && !isLoginInProgress.current) {
|
||||||
|
isLoginInProgress.current = true;
|
||||||
|
void signInAsync("oidc");
|
||||||
|
}
|
||||||
|
}, [signInAsync, isOidcAutoLoginEnabled, isPending]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
<Stack gap="lg">
|
||||||
<Stack gap="lg">
|
{credentialInputsVisible && (
|
||||||
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
|
<>
|
||||||
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
|
<form onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}>
|
||||||
<Button type="submit" fullWidth loading={isLoading}>
|
<Stack gap="lg">
|
||||||
{t("action.login.label")}
|
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
|
||||||
</Button>
|
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{error && (
|
{providers.includes("credentials") && (
|
||||||
<Alert icon={<IconAlertTriangle size={rem(16)} />} color="red">
|
<Stack gap="sm">
|
||||||
{error}
|
<SubmitButton isPending={isPending} form={form} provider="credentials">
|
||||||
</Alert>
|
{t("action.login.label")}
|
||||||
)}
|
</SubmitButton>
|
||||||
|
<PasswordForgottenCollapse username={form.values.name} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{providers.includes("ldap") && (
|
||||||
|
<SubmitButton isPending={isPending} form={form} provider="ldap">
|
||||||
|
{t("action.login.labelWith", { provider: "LDAP" })}
|
||||||
|
</SubmitButton>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
{providers.includes("oidc") && <Divider label="OIDC" labelPosition="center" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{providers.includes("oidc") && (
|
||||||
|
<Button fullWidth variant="light" onClick={async () => await signInAsync("oidc")}>
|
||||||
|
{t("action.login.labelWith", { provider: oidcClientName })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface SubmitButtonProps {
|
||||||
|
isPending: boolean;
|
||||||
|
form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>;
|
||||||
|
provider: "credentials" | "ldap";
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||||
|
const isCurrentProviderActive = form.getValues().provider === provider;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
name={provider}
|
||||||
|
fullWidth
|
||||||
|
onClick={() => form.setFieldValue("provider", provider)}
|
||||||
|
loading={isPending && isCurrentProviderActive}
|
||||||
|
disabled={isPending && !isCurrentProviderActive}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PasswordForgottenCollapseProps {
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
|
||||||
|
const [visible, { toggle }] = useDisclosure(false);
|
||||||
|
const tForgotPassword = useScopedI18n("user.action.login.forgotPassword");
|
||||||
|
|
||||||
|
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Anchor type="button" component="button" onClick={toggle}>
|
||||||
|
{tForgotPassword("label")}
|
||||||
|
</Anchor>
|
||||||
|
|
||||||
|
<Collapse in={visible}>
|
||||||
|
<Card>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm">{tForgotPassword("description")}</Text>
|
||||||
|
|
||||||
|
<Code>homarr reset-password -u {commandUsername}</Code>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Collapse>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof extendedValidation>;
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { env } from "@homarr/auth/env.mjs";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||||
import { LoginForm } from "./_login-form";
|
import { LoginForm } from "./_login-form";
|
||||||
|
|
||||||
export default async function Login() {
|
interface LoginProps {
|
||||||
|
searchParams: {
|
||||||
|
callbackUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Login({ searchParams }: LoginProps) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
redirect(searchParams.callbackUrl ?? "/");
|
||||||
|
}
|
||||||
|
|
||||||
const t = await getScopedI18n("user.page.login");
|
const t = await getScopedI18n("user.page.login");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,7 +36,12 @@ export default async function Login() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||||
<LoginForm />
|
<LoginForm
|
||||||
|
providers={env.AUTH_PROVIDERS}
|
||||||
|
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||||
|
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
|
||||||
|
callbackUrl={searchParams.callbackUrl ?? "/"}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { DDGridStack } from "@homarr/gridstack";
|
|
||||||
|
|
||||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||||
@@ -41,47 +40,15 @@ export const useUpdateBoard = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const dd = new DDGridStack();
|
|
||||||
|
|
||||||
export const ClientBoard = () => {
|
export const ClientBoard = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const isReady = useIsBoardReady();
|
const isReady = useIsBoardReady();
|
||||||
const { updateBoard } = useUpdateBoard();
|
|
||||||
|
|
||||||
const sortedSections = board.sections.sort((sectionA, sectionB) => sectionA.position - sectionB.position);
|
const fullWidthSortedSections = board.sections
|
||||||
|
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||||
|
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!boxRef.current) return;
|
|
||||||
dd.droppable(boxRef.current, {
|
|
||||||
accept: () => true,
|
|
||||||
})
|
|
||||||
.on(boxRef.current, "dropover", (event, el, helper) => {
|
|
||||||
console.log("dropover", event, el, helper);
|
|
||||||
})
|
|
||||||
.on(boxRef.current, "dropout", (event, el, helper) => {
|
|
||||||
console.log("dropout", event, el, helper);
|
|
||||||
})
|
|
||||||
.on(boxRef.current, "drop", (event, el, helper) => {
|
|
||||||
console.log("drop", event, el, helper);
|
|
||||||
const itemId = el.getAttribute("data-id");
|
|
||||||
if (!itemId) return;
|
|
||||||
|
|
||||||
// Same as removeItem
|
|
||||||
updateBoard((previous) => {
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
// Filter removed item out of items array
|
|
||||||
sections: previous.sections.map((section) => ({
|
|
||||||
...section,
|
|
||||||
items: section.items.filter((item) => item.id !== itemId),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
@@ -93,12 +60,11 @@ export const ClientBoard = () => {
|
|||||||
h={fullHeightWithoutHeaderAndFooter}
|
h={fullHeightWithoutHeaderAndFooter}
|
||||||
/>
|
/>
|
||||||
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
||||||
<Box ref={boxRef} w="100%" h={80} bg="red"></Box>
|
{fullWidthSortedSections.map((section) =>
|
||||||
{sortedSections.map((section) =>
|
|
||||||
section.kind === "empty" ? (
|
section.kind === "empty" ? (
|
||||||
<BoardEmptySection key={section.id} section={section} mainRef={ref} />
|
<BoardEmptySection key={section.id} section={section} />
|
||||||
) : (
|
) : (
|
||||||
<BoardCategorySection key={section.id} section={section} mainRef={ref} />
|
<BoardCategorySection key={section.id} section={section} />
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const BoardProvider = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
|
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [data.sections.length, setReadySections]);
|
}, [data.sections.length, setReadySections]);
|
||||||
|
|
||||||
const markAsReady = useCallback((id: string) => {
|
const markAsReady = useCallback((id: string) => {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { TRPCError } from "@trpc/server";
|
|||||||
// Placed here because gridstack styles are used for board content
|
// Placed here because gridstack styles are used for board content
|
||||||
import "~/styles/gridstack.scss";
|
import "~/styles/gridstack.scss";
|
||||||
|
|
||||||
|
import { IntegrationProvider } from "@homarr/auth/client";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
@@ -25,10 +28,17 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
headerActions: <BoardContentHeaderActions />,
|
headerActions: <BoardContentHeaderActions />,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
isBoardContentPage: true,
|
|
||||||
}),
|
}),
|
||||||
page: () => {
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
return <ClientBoard />;
|
page: async () => {
|
||||||
|
const session = await auth();
|
||||||
|
const integrations = await getIntegrationsWithPermissionsAsync(session);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntegrationProvider integrations={integrations}>
|
||||||
|
<ClientBoard />
|
||||||
|
</IntegrationProvider>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
|
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
|
||||||
try {
|
try {
|
||||||
@@ -39,6 +49,10 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
||||||
icons: {
|
icons: {
|
||||||
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
||||||
|
apple: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
||||||
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
startupImage: { url: board.faviconImageUrl ? board.faviconImageUrl : "/logo/logo.png" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,27 +2,29 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Group, Menu } from "@mantine/core";
|
import { Group, Menu } from "@mantine/core";
|
||||||
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconBox,
|
IconBox,
|
||||||
IconBoxAlignTop,
|
IconBoxAlignTop,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconPackageImport,
|
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconPencilOff,
|
IconPencilOff,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
|
IconResize,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||||
|
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
import { useEditMode, useRequiredBoard } from "./_context";
|
import { useEditMode, useRequiredBoard } from "./_context";
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ const AddMenu = () => {
|
|||||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||||
const { addCategoryToEnd } = useCategoryActions();
|
const { addCategoryToEnd } = useCategoryActions();
|
||||||
|
const { addDynamicSection } = useDynamicSectionActions();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
const handleAddCategory = useCallback(
|
const handleAddCategory = useCallback(
|
||||||
@@ -92,13 +95,16 @@ const AddMenu = () => {
|
|||||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
|
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
|
||||||
{t("item.action.create")}
|
{t("item.action.create")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item leftSection={<IconPackageImport size={20} />}>{t("item.action.import")}</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||||
{t("section.category.action.create")}
|
{t("section.category.action.create")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
||||||
|
{t("section.dynamic.action.create")}
|
||||||
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
@@ -132,6 +138,8 @@ const EditModeMenu = () => {
|
|||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
}, [board, isEditMode, saveBoard, setEditMode]);
|
}, [board, isEditMode, saveBoard, setEditMode]);
|
||||||
|
|
||||||
|
useHotkeys([["mod+e", toggle]]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderButton onClick={toggle} loading={isPending}>
|
<HeaderButton onClick={toggle} loading={isPending}>
|
||||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||||
|
|||||||
@@ -8,5 +8,4 @@ export default createBoardLayout<{ locale: string; name: string }>({
|
|||||||
async getInitialBoardAsync({ name }) {
|
async getInitialBoardAsync({ name }) {
|
||||||
return await api.board.getBoardByName({ name });
|
return await api.board.getBoardByName({ name });
|
||||||
},
|
},
|
||||||
isBoardContentPage: false,
|
|
||||||
});
|
});
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Group, Stack, Tabs } from "@mantine/core";
|
|
||||||
import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
|
||||||
import { CountBadge } from "@homarr/ui";
|
|
||||||
|
|
||||||
import type { Board } from "../../_types";
|
|
||||||
import { GroupsForm } from "./_access/group-access";
|
|
||||||
import { InheritTable } from "./_access/inherit-access";
|
|
||||||
import { UsersForm } from "./_access/user-access";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
board: Board;
|
|
||||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
|
|
||||||
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
|
|
||||||
{
|
|
||||||
id: board.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
initialData: initialPermissions,
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const [counts, setCounts] = useState({
|
|
||||||
user: initialPermissions.userPermissions.length + (board.creator ? 1 : 0),
|
|
||||||
group: initialPermissions.groupPermissions.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Tabs color="red" defaultValue="user">
|
|
||||||
<Tabs.List grow>
|
|
||||||
<TabItem value="user" count={counts.user} icon={IconUser} />
|
|
||||||
<TabItem value="group" count={counts.group} icon={IconUsersGroup} />
|
|
||||||
<TabItem value="inherited" count={initialPermissions.inherited.length} icon={IconUserDown} />
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Panel value="user">
|
|
||||||
<UsersForm
|
|
||||||
board={board}
|
|
||||||
initialPermissions={permissions}
|
|
||||||
onCountChange={(callback) =>
|
|
||||||
setCounts(({ user, ...others }) => ({
|
|
||||||
user: callback(user),
|
|
||||||
...others,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="group">
|
|
||||||
<GroupsForm
|
|
||||||
board={board}
|
|
||||||
initialPermissions={permissions}
|
|
||||||
onCountChange={(callback) =>
|
|
||||||
setCounts(({ group, ...others }) => ({
|
|
||||||
group: callback(group),
|
|
||||||
...others,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="inherited">
|
|
||||||
<InheritTable initialPermissions={permissions} />
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TabItemProps {
|
|
||||||
value: "user" | "group" | "inherited";
|
|
||||||
count: number;
|
|
||||||
icon: TablerIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TabItem = ({ value, icon: Icon, count }: TabItemProps) => {
|
|
||||||
const t = useScopedI18n("board.setting.section.access.permission");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs.Tab value={value} leftSection={<Icon stroke={1.5} size={16} />}>
|
|
||||||
<Group gap="sm">
|
|
||||||
{t(`tab.${value}`)}
|
|
||||||
<CountBadge count={count} />
|
|
||||||
</Group>
|
|
||||||
</Tabs.Tab>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { BoardPermission } from "@homarr/definitions";
|
|
||||||
import { createFormContext } from "@homarr/form";
|
|
||||||
|
|
||||||
export interface BoardAccessFormType {
|
|
||||||
items: {
|
|
||||||
itemId: string;
|
|
||||||
permission: BoardPermission;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const [FormProvider, useFormContext, useForm] = createFormContext<BoardAccessFormType>();
|
|
||||||
|
|
||||||
export type OnCountChange = (callback: (prev: number) => number) => void;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { getPermissionsWithChildren } from "@homarr/definitions";
|
|
||||||
import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { BoardAccessDisplayRow } from "./board-access-table-rows";
|
|
||||||
import { GroupItemContent } from "./group-access";
|
|
||||||
|
|
||||||
export interface InheritTableProps {
|
|
||||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapPermissions = {
|
|
||||||
"board-full-access": "board-full",
|
|
||||||
"board-modify-all": "board-change",
|
|
||||||
"board-view-all": "board-view",
|
|
||||||
} satisfies Partial<Record<GroupPermissionKey, BoardPermission | "board-full">>;
|
|
||||||
|
|
||||||
export const InheritTable = ({ initialPermissions }: InheritTableProps) => {
|
|
||||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
|
||||||
return (
|
|
||||||
<Stack pt="sm">
|
|
||||||
<Table>
|
|
||||||
<TableThead>
|
|
||||||
<TableTr>
|
|
||||||
<TableTh>{tPermissions("field.user.label")}</TableTh>
|
|
||||||
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
|
||||||
</TableTr>
|
|
||||||
</TableThead>
|
|
||||||
<TableTbody>
|
|
||||||
{initialPermissions.inherited.map(({ group, permission }) => {
|
|
||||||
const boardPermission =
|
|
||||||
permission in mapPermissions
|
|
||||||
? mapPermissions[permission as keyof typeof mapPermissions]
|
|
||||||
: getPermissionsWithChildren([permission]).includes("board-full-access")
|
|
||||||
? "board-full"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!boardPermission) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BoardAccessDisplayRow
|
|
||||||
key={group.id}
|
|
||||||
itemContent={<GroupItemContent group={group} />}
|
|
||||||
permission={boardPermission}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableTbody>
|
|
||||||
</Table>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
|
||||||
import { IconPlus } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import { UserAvatar } from "@homarr/ui";
|
|
||||||
|
|
||||||
import type { Board } from "../../../_types";
|
|
||||||
import { BoardAccessDisplayRow, BoardAccessSelectRow } from "./board-access-table-rows";
|
|
||||||
import type { BoardAccessFormType, OnCountChange } from "./form";
|
|
||||||
import { FormProvider, useForm } from "./form";
|
|
||||||
import { UserSelectModal } from "./user-select-modal";
|
|
||||||
|
|
||||||
export interface FormProps {
|
|
||||||
board: Pick<Board, "id" | "creatorId" | "creator">;
|
|
||||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
|
||||||
onCountChange: OnCountChange;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
|
|
||||||
const { mutate, isPending } = clientApi.board.saveUserBoardPermissions.useMutation();
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const [users, setUsers] = useState<Map<string, User>>(
|
|
||||||
new Map(initialPermissions.userPermissions.map(({ user }) => [user.id, user])),
|
|
||||||
);
|
|
||||||
const { openModal } = useModalAction(UserSelectModal);
|
|
||||||
const t = useI18n();
|
|
||||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
|
||||||
const form = useForm({
|
|
||||||
initialValues: {
|
|
||||||
items: initialPermissions.userPermissions.map(({ user, permission }) => ({
|
|
||||||
itemId: user.id,
|
|
||||||
permission,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
(values: BoardAccessFormType) => {
|
|
||||||
mutate(
|
|
||||||
{
|
|
||||||
id: board.id,
|
|
||||||
permissions: values.items,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
void utils.board.getBoardPermissions.invalidate();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[board.id, mutate, utils.board.getBoardPermissions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddUser = useCallback(() => {
|
|
||||||
const presentUserIds = form.values.items.map(({ itemId: id }) => id);
|
|
||||||
|
|
||||||
openModal({
|
|
||||||
presentUserIds: board.creatorId ? presentUserIds.concat(board.creatorId) : presentUserIds,
|
|
||||||
onSelect: (user) => {
|
|
||||||
setUsers((prev) => new Map(prev).set(user.id, user));
|
|
||||||
form.setFieldValue("items", [
|
|
||||||
{
|
|
||||||
itemId: user.id,
|
|
||||||
permission: "board-view",
|
|
||||||
},
|
|
||||||
...form.values.items,
|
|
||||||
]);
|
|
||||||
onCountChange((prev) => prev + 1);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [form, openModal, board.creatorId, onCountChange]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<FormProvider form={form}>
|
|
||||||
<Stack pt="sm">
|
|
||||||
<Table>
|
|
||||||
<TableThead>
|
|
||||||
<TableTr>
|
|
||||||
<TableTh>{tPermissions("field.user.label")}</TableTh>
|
|
||||||
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
|
||||||
</TableTr>
|
|
||||||
</TableThead>
|
|
||||||
<TableTbody>
|
|
||||||
{board.creator && (
|
|
||||||
<BoardAccessDisplayRow itemContent={<UserItemContent user={board.creator} />} permission="board-full" />
|
|
||||||
)}
|
|
||||||
{form.values.items.map((row, index) => (
|
|
||||||
<BoardAccessSelectRow
|
|
||||||
key={row.itemId}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
itemContent={<UserItemContent user={users.get(row.itemId)!} />}
|
|
||||||
permission={row.permission}
|
|
||||||
index={index}
|
|
||||||
onCountChange={onCountChange}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableTbody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
|
|
||||||
{t("common.action.add")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={isPending} color="teal">
|
|
||||||
{t("common.action.saveChanges")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</FormProvider>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserItemContent = ({ user }: { user: User }) => {
|
|
||||||
return (
|
|
||||||
<Group wrap="nowrap">
|
|
||||||
<Box visibleFrom="xs">
|
|
||||||
<UserAvatar user={user} size="sm" />
|
|
||||||
</Box>
|
|
||||||
<Anchor component={Link} href={`/manage/users/${user.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{user.name}
|
|
||||||
</Anchor>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
image: string | null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { boardPermissions, boardPermissionsMap } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { AccessSettings } from "~/components/access/access-settings";
|
||||||
|
import type { Board } from "../../_types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
board: Board;
|
||||||
|
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
|
||||||
|
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
|
||||||
|
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
|
||||||
|
{
|
||||||
|
id: board.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData: initialPermissions,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessSettings
|
||||||
|
entity={{
|
||||||
|
id: board.id,
|
||||||
|
ownerId: board.creatorId,
|
||||||
|
owner: board.creator,
|
||||||
|
}}
|
||||||
|
query={{
|
||||||
|
invalidate: () => utils.board.getBoardPermissions.invalidate(),
|
||||||
|
data: permissions,
|
||||||
|
}}
|
||||||
|
groupsMutation={{
|
||||||
|
mutate: groupMutation.mutate,
|
||||||
|
isPending: groupMutation.isPending,
|
||||||
|
}}
|
||||||
|
usersMutation={{
|
||||||
|
mutate: userMutation.mutate,
|
||||||
|
isPending: userMutation.isPending,
|
||||||
|
}}
|
||||||
|
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
|
||||||
|
permission={{
|
||||||
|
items: boardPermissions,
|
||||||
|
default: "view",
|
||||||
|
fullAccessGroupPermission: "board-full-all",
|
||||||
|
groupPermissionMapping: boardPermissionsMap,
|
||||||
|
icons: {
|
||||||
|
modify: IconPencil,
|
||||||
|
view: IconEye,
|
||||||
|
full: IconSettings,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -20,8 +20,8 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
|
|
||||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||||
import { AccessSettingsContent } from "./_access";
|
|
||||||
import { BackgroundSettingsContent } from "./_background";
|
import { BackgroundSettingsContent } from "./_background";
|
||||||
|
import { BoardAccessSettings } from "./_board-access";
|
||||||
import { ColorSettingsContent } from "./_colors";
|
import { ColorSettingsContent } from "./_colors";
|
||||||
import { CustomCssSettingsContent } from "./_customCss";
|
import { CustomCssSettingsContent } from "./_customCss";
|
||||||
import { DangerZoneSettingsContent } from "./_danger";
|
import { DangerZoneSettingsContent } from "./_danger";
|
||||||
@@ -44,8 +44,8 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
|||||||
const permissions = hasFullAccess
|
const permissions = hasFullAccess
|
||||||
? await api.board.getBoardPermissions({ id: board.id })
|
? await api.board.getBoardPermissions({ id: board.id })
|
||||||
: {
|
: {
|
||||||
userPermissions: [],
|
users: [],
|
||||||
groupPermissions: [],
|
groups: [],
|
||||||
inherited: [],
|
inherited: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
|
|||||||
{hasFullAccess && (
|
{hasFullAccess && (
|
||||||
<>
|
<>
|
||||||
<AccordionItemFor value="access" icon={IconUser}>
|
<AccordionItemFor value="access" icon={IconUser}>
|
||||||
<AccessSettingsContent board={board} initialPermissions={permissions} />
|
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||||
<DangerZoneSettingsContent />
|
<DangerZoneSettingsContent />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { AppShellMain } from "@mantine/core";
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import { GlobalItemServerDataRunner } from "@homarr/widgets";
|
|
||||||
|
|
||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||||
@@ -18,13 +17,11 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
|||||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||||
headerActions: JSX.Element;
|
headerActions: JSX.Element;
|
||||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
isBoardContentPage: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBoardLayout = <TParams extends Params>({
|
export const createBoardLayout = <TParams extends Params>({
|
||||||
headerActions,
|
headerActions,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
isBoardContentPage,
|
|
||||||
}: CreateBoardLayoutProps<TParams>) => {
|
}: CreateBoardLayoutProps<TParams>) => {
|
||||||
const Layout = async ({
|
const Layout = async ({
|
||||||
params,
|
params,
|
||||||
@@ -42,21 +39,19 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
|
<BoardProvider initialBoard={initialBoard}>
|
||||||
<BoardProvider initialBoard={initialBoard}>
|
<BoardMantineProvider>
|
||||||
<BoardMantineProvider>
|
<CustomCss />
|
||||||
<CustomCss />
|
<ClientShell hasNavigation={false}>
|
||||||
<ClientShell hasNavigation={false}>
|
<MainHeader
|
||||||
<MainHeader
|
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
actions={headerActions}
|
||||||
actions={headerActions}
|
hasNavigation={false}
|
||||||
hasNavigation={false}
|
/>
|
||||||
/>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
</ClientShell>
|
||||||
</ClientShell>
|
</BoardMantineProvider>
|
||||||
</BoardMantineProvider>
|
</BoardProvider>
|
||||||
</BoardProvider>
|
|
||||||
</GlobalItemServerDataRunner>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ export type Item = Section["items"][number];
|
|||||||
|
|
||||||
export type CategorySection = Extract<Section, { kind: "category" }>;
|
export type CategorySection = Extract<Section, { kind: "category" }>;
|
||||||
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
||||||
|
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
|
||||||
|
|
||||||
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
||||||
@@ -7,6 +7,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { CustomPasswordInput } from "@homarr/ui";
|
||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
@@ -50,7 +51,11 @@ export const InitUserForm = () => {
|
|||||||
>
|
>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||||
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
|
<CustomPasswordInput
|
||||||
|
withPasswordRequirements
|
||||||
|
label={t("field.password.label")}
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
/>
|
||||||
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
||||||
<Button type="submit" fullWidth loading={isPending}>
|
<Button type="submit" fullWidth loading={isPending}>
|
||||||
{t("action.create")}
|
{t("action.create")}
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
import "@homarr/ui/styles.css";
|
|
||||||
import "@homarr/notifications/styles.css";
|
import "@homarr/notifications/styles.css";
|
||||||
import "@homarr/spotlight/styles.css";
|
import "@homarr/spotlight/styles.css";
|
||||||
|
import "@homarr/ui/styles.css";
|
||||||
|
import "~/styles/scroll-area.scss";
|
||||||
|
|
||||||
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
import { env } from "@homarr/auth/env.mjs";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { ModalProvider } from "@homarr/modals";
|
import { ModalProvider } from "@homarr/modals";
|
||||||
import { Notifications } from "@homarr/notifications";
|
import { Notifications } from "@homarr/notifications";
|
||||||
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { Analytics } from "~/components/layout/analytics";
|
import { Analytics } from "~/components/layout/analytics";
|
||||||
|
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||||
import { JotaiProvider } from "./_client-providers/jotai";
|
import { JotaiProvider } from "./_client-providers/jotai";
|
||||||
|
import { CustomMantineProvider } from "./_client-providers/mantine";
|
||||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
import { NextInternationalProvider } from "./_client-providers/next-international";
|
||||||
import { AuthProvider } from "./_client-providers/session";
|
import { AuthProvider } from "./_client-providers/session";
|
||||||
import { TRPCReactProvider } from "./_client-providers/trpc";
|
import { TRPCReactProvider } from "./_client-providers/trpc";
|
||||||
@@ -23,8 +28,7 @@ const fontSans = Inter({
|
|||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const generateMetadata = (): Metadata => ({
|
||||||
metadataBase: new URL("http://localhost:3000"),
|
|
||||||
title: "Homarr",
|
title: "Homarr",
|
||||||
description:
|
description:
|
||||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||||
@@ -35,12 +39,17 @@ export const metadata: Metadata = {
|
|||||||
url: "https://homarr.dev",
|
url: "https://homarr.dev",
|
||||||
siteName: "Homarr Documentation",
|
siteName: "Homarr Documentation",
|
||||||
},
|
},
|
||||||
twitter: {
|
icons: {
|
||||||
card: "summary_large_image",
|
icon: "/logo/logo.png",
|
||||||
site: "@jullerino",
|
apple: "/logo/logo.png",
|
||||||
creator: "@jullerino",
|
|
||||||
},
|
},
|
||||||
};
|
appleWebApp: {
|
||||||
|
title: "Homarr",
|
||||||
|
capable: true,
|
||||||
|
startupImage: { url: "/logo/logo.png" },
|
||||||
|
statusBarStyle: getColorScheme() === "dark" ? "black-translucent" : "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: [
|
themeColor: [
|
||||||
@@ -49,35 +58,37 @@ export const viewport: Viewport = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
||||||
const colorScheme = "dark";
|
const session = await auth();
|
||||||
|
const colorScheme = getColorScheme();
|
||||||
|
const tCommon = await getScopedI18n("common");
|
||||||
|
const direction = tCommon("direction");
|
||||||
|
|
||||||
const StackedProvider = composeWrappers([
|
const StackedProvider = composeWrappers([
|
||||||
async (innerProps) => {
|
(innerProps) => {
|
||||||
const session = await auth();
|
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
|
||||||
return <AuthProvider session={session} {...innerProps} />;
|
|
||||||
},
|
},
|
||||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||||
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
|
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
|
||||||
(innerProps) => (
|
(innerProps) => <CustomMantineProvider {...innerProps} />,
|
||||||
<MantineProvider
|
|
||||||
{...innerProps}
|
|
||||||
defaultColorScheme="dark"
|
|
||||||
theme={createTheme({
|
|
||||||
primaryColor: "red",
|
|
||||||
autoContrast: true,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
(innerProps) => <ModalProvider {...innerProps} />,
|
(innerProps) => <ModalProvider {...innerProps} />,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
dir={direction}
|
||||||
|
data-mantine-color-scheme={colorScheme}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
|
||||||
|
}}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<head>
|
<head>
|
||||||
<ColorSchemeScript defaultColorScheme={colorScheme} />
|
|
||||||
<Analytics />
|
<Analytics />
|
||||||
|
<SearchEngineOptimization />
|
||||||
</head>
|
</head>
|
||||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||||
<StackedProvider>
|
<StackedProvider>
|
||||||
@@ -88,3 +99,7 @@ export default function Layout(props: { children: React.ReactNode; params: { loc
|
|||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getColorScheme = () => {
|
||||||
|
return cookies().get("homarr-color-scheme")?.value ?? "dark";
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50.8%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -17,16 +18,15 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
|
import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
|
||||||
import { setStaticParamsLocale } from "next-international/server";
|
|
||||||
|
|
||||||
import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { getPackageAttributesAsync } from "~/versions/package-reader";
|
import { getPackageAttributesAsync } from "~/versions/package-reader";
|
||||||
import contributorsData from "../../../../../../../static-data/contributors.json";
|
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
|
||||||
import translatorsData from "../../../../../../../static-data/translators.json";
|
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
|
||||||
import classes from "./about.module.css";
|
import classes from "./about.module.css";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
@@ -37,16 +37,26 @@ export async function generateMetadata() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageProps {
|
const getHost = () => {
|
||||||
params: {
|
if (process.env.HOSTNAME) {
|
||||||
locale: string;
|
return `${process.env.HOSTNAME}:3000`;
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AboutPage({ params: { locale } }: PageProps) {
|
return headers().get("host");
|
||||||
setStaticParamsLocale(locale);
|
};
|
||||||
|
|
||||||
|
export default async function AboutPage() {
|
||||||
|
const baseServerUrl = `http://${getHost()}`;
|
||||||
const t = await getScopedI18n("management.page.about");
|
const t = await getScopedI18n("management.page.about");
|
||||||
const attributes = await getPackageAttributesAsync();
|
const attributes = await getPackageAttributesAsync();
|
||||||
|
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
|
||||||
|
res.json(),
|
||||||
|
)) as typeof githubContributorsJson;
|
||||||
|
|
||||||
|
const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) =>
|
||||||
|
res.json(),
|
||||||
|
)) as typeof crowdinContributorsJson;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
@@ -70,14 +80,14 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
|
|||||||
<Text>{t("accordion.contributors.title")}</Text>
|
<Text>{t("accordion.contributors.title")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("accordion.contributors.subtitle", {
|
{t("accordion.contributors.subtitle", {
|
||||||
count: contributorsData.length,
|
count: githubContributors.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</AccordionControl>
|
</AccordionControl>
|
||||||
<AccordionPanel>
|
<AccordionPanel>
|
||||||
<Flex wrap="wrap" gap="xs">
|
<Flex wrap="wrap" gap="xs">
|
||||||
{contributorsData.map((contributor) => (
|
{githubContributors.map((contributor) => (
|
||||||
<GenericContributorLinkCard
|
<GenericContributorLinkCard
|
||||||
key={contributor.login}
|
key={contributor.login}
|
||||||
link={`https://github.com/${contributor.login}`}
|
link={`https://github.com/${contributor.login}`}
|
||||||
@@ -94,14 +104,14 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
|
|||||||
<Text>{t("accordion.translators.title")}</Text>
|
<Text>{t("accordion.translators.title")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("accordion.translators.subtitle", {
|
{t("accordion.translators.subtitle", {
|
||||||
count: translatorsData.length,
|
count: crowdinContributors.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</AccordionControl>
|
</AccordionControl>
|
||||||
<AccordionPanel>
|
<AccordionPanel>
|
||||||
<Flex wrap="wrap" gap="xs">
|
<Flex wrap="wrap" gap="xs">
|
||||||
{translatorsData.map((translator) => (
|
{crowdinContributors.map((translator) => (
|
||||||
<GenericContributorLinkCard
|
<GenericContributorLinkCard
|
||||||
key={translator.username}
|
key={translator.username}
|
||||||
link={`https://crowdin.com/profile/${translator.username}`}
|
link={`https://crowdin.com/profile/${translator.username}`}
|
||||||
@@ -164,9 +174,3 @@ const GenericContributorLinkCard = ({ name, image, link }: GenericContributorLin
|
|||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function generateStaticParams() {
|
|
||||||
return getStaticParams();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = "force-static";
|
|
||||||
@@ -6,12 +6,11 @@ import { IconTrash } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
|
|
||||||
|
|
||||||
interface AppDeleteButtonProps {
|
interface AppDeleteButtonProps {
|
||||||
app: RouterOutputs["app"]["all"][number];
|
app: RouterOutputs["app"]["all"][number];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { validation, z } from "@homarr/validation";
|
import type { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { AppForm } from "../../_form";
|
import { AppForm } from "../../_form";
|
||||||
|
|
||||||
interface AppEditFormProps {
|
interface AppEditFormProps {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useCallback } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { validation, z } from "@homarr/validation";
|
import type { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { AppForm } from "../_form";
|
import { AppForm } from "../_form";
|
||||||
|
|
||||||
export const AppNewForm = () => {
|
export const AppNewForm = () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { IconApps, IconPencil } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
@@ -69,8 +70,8 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{app.href && (
|
{app.href && (
|
||||||
<Anchor href={app.href} lineClamp={1} size="sm" w="min-content">
|
<Anchor href={parseAppHrefWithVariablesServer(app.href)} lineClamp={1} size="sm" w="min-content">
|
||||||
{app.href}
|
{parseAppHrefWithVariablesServer(app.href)}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -104,7 +105,7 @@ const AppNoResults = async () => {
|
|||||||
<Text fw={500} size="lg">
|
<Text fw={500} size="lg">
|
||||||
{t("app.page.list.noResults.title")}
|
{t("app.page.list.noResults.title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.description")}</Anchor>
|
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
|
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
|
|||||||
@@ -1,44 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { Affix, Button, Group, Menu } from "@mantine/core";
|
||||||
import { IconCategoryPlus } from "@tabler/icons-react";
|
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
|
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { BetaBadge } from "@homarr/ui";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
export const CreateBoardButton = () => {
|
||||||
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
|
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
|
||||||
|
|
||||||
interface CreateBoardButtonProps {
|
|
||||||
boardNames: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal } = useModalAction(AddBoardModal);
|
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||||
|
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||||
|
|
||||||
const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
|
const buttonGroupContent = (
|
||||||
onSettled: async () => {
|
<>
|
||||||
await revalidatePathActionAsync("/manage/boards");
|
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
||||||
},
|
{t("management.page.board.action.new.label")}
|
||||||
});
|
</Button>
|
||||||
|
<Menu position="bottom-end">
|
||||||
const onClick = useCallback(() => {
|
<Menu.Target>
|
||||||
openModal({
|
<Button px="xs" ms={1}>
|
||||||
onSuccess: async (values) => {
|
<IconChevronDown size="1rem" />
|
||||||
await mutateAsync({
|
</Button>
|
||||||
name: values.name,
|
</Menu.Target>
|
||||||
});
|
<Menu.Dropdown>
|
||||||
},
|
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||||
boardNames,
|
<Group>
|
||||||
});
|
{t("board.action.oldImport.label")}
|
||||||
}, [mutateAsync, boardNames, openModal]);
|
<BetaBadge size="xs" />
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileAffixButton leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
|
<>
|
||||||
{t("management.page.board.action.new.label")}
|
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
|
||||||
</MobileAffixButton>
|
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||||
|
<Button.Group>{buttonGroupContent}</Button.Group>
|
||||||
|
</Affix>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -19,6 +19,7 @@ import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/i
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { UserAvatar } from "@homarr/ui";
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
@@ -30,8 +31,9 @@ import { CreateBoardButton } from "./_components/create-board-button";
|
|||||||
|
|
||||||
export default async function ManageBoardsPage() {
|
export default async function ManageBoardsPage() {
|
||||||
const t = await getScopedI18n("management.page.board");
|
const t = await getScopedI18n("management.page.board");
|
||||||
|
const session = await auth();
|
||||||
const boards = await api.board.getAllBoards();
|
const boards = await api.board.getAllBoards();
|
||||||
|
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer>
|
<ManageContainer>
|
||||||
@@ -39,7 +41,7 @@ export default async function ManageBoardsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title mb="md">{t("title")}</Title>
|
<Title mb="md">{t("title")}</Title>
|
||||||
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
|
{canCreateBoards && <CreateBoardButton />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Grid mb={{ base: "xl", md: 0 }}>
|
<Grid mb={{ base: "xl", md: 0 }}>
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconPlayerPlay, IconSelector, IconSettings } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { integrationPermissions, integrationPermissionsMap } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { AccessSettings } from "~/components/access/access-settings";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
integration: RouterOutputs["integration"]["byId"];
|
||||||
|
initialPermissions: RouterOutputs["integration"]["getIntegrationPermissions"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntegrationAccessSettings = ({ integration, initialPermissions }: Props) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const { data } = clientApi.integration.getIntegrationPermissions.useQuery(
|
||||||
|
{
|
||||||
|
id: integration.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
initialData: initialPermissions,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const usersMutation = clientApi.integration.saveUserIntegrationPermissions.useMutation();
|
||||||
|
const groupsMutation = clientApi.integration.saveGroupIntegrationPermissions.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessSettings
|
||||||
|
entity={{
|
||||||
|
id: integration.id,
|
||||||
|
ownerId: null,
|
||||||
|
owner: null,
|
||||||
|
}}
|
||||||
|
permission={{
|
||||||
|
items: integrationPermissions,
|
||||||
|
default: "use",
|
||||||
|
fullAccessGroupPermission: "integration-full-all",
|
||||||
|
icons: {
|
||||||
|
use: IconSelector,
|
||||||
|
interact: IconPlayerPlay,
|
||||||
|
full: IconSettings,
|
||||||
|
},
|
||||||
|
groupPermissionMapping: integrationPermissionsMap,
|
||||||
|
}}
|
||||||
|
translate={(key) => t(`integration.permission.${key}`)}
|
||||||
|
query={{
|
||||||
|
data,
|
||||||
|
invalidate: () => utils.integration.getIntegrationPermissions.invalidate(),
|
||||||
|
}}
|
||||||
|
groupsMutation={groupsMutation}
|
||||||
|
usersMutation={usersMutation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,12 +5,11 @@ import { ActionIcon } from "@mantine/core";
|
|||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
|
|
||||||
|
|
||||||
interface DeleteIntegrationActionButtonProps {
|
interface DeleteIntegrationActionButtonProps {
|
||||||
count: number;
|
count: number;
|
||||||
integration: { id: string; name: string };
|
integration: { id: string; name: string };
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,7 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
@@ -15,7 +16,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
||||||
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
|
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Container, Group, Stack, Title } from "@mantine/core";
|
import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { IntegrationAvatar } from "../../_integration-avatar";
|
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||||
|
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
|
||||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||||
|
|
||||||
interface EditIntegrationPageProps {
|
interface EditIntegrationPageProps {
|
||||||
@@ -13,8 +15,10 @@ interface EditIntegrationPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
|
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
|
||||||
const t = await getScopedI18n("integration.page.edit");
|
const editT = await getScopedI18n("integration.page.edit");
|
||||||
const integration = await api.integration.byId({ id: params.id });
|
const t = await getI18n();
|
||||||
|
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||||
|
const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -23,9 +27,14 @@ export default async function EditIntegrationPage({ params }: EditIntegrationPag
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group align="center">
|
<Group align="center">
|
||||||
<IntegrationAvatar kind={integration.kind} size="md" />
|
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||||
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
|
<Title>{editT("title", { name: getIntegrationName(integration.kind) })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<EditIntegrationForm integration={integration} />
|
<EditIntegrationForm integration={integration} />
|
||||||
|
|
||||||
|
<Title order={2}>{t("permission.title")}</Title>
|
||||||
|
<Fieldset>
|
||||||
|
<IntegrationAccessSettings integration={integration} initialPermissions={integrationPermissions} />
|
||||||
|
</Fieldset>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import { IconSearch } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
import { IntegrationAvatar } from "../_integration-avatar";
|
|
||||||
|
|
||||||
export const IntegrationCreateDropdownContent = () => {
|
export const IntegrationCreateDropdownContent = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button, Fieldset, Group, SegmentedControl, Stack, TextInput } from "@mantine/core";
|
import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
||||||
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
@@ -17,7 +19,6 @@ import type { z } from "@homarr/validation";
|
|||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
||||||
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
|
|
||||||
|
|
||||||
interface NewIntegrationFormProps {
|
interface NewIntegrationFormProps {
|
||||||
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
||||||
@@ -31,7 +32,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: searchParams.name ?? "",
|
name: searchParams.name ?? getIntegrationName(searchParams.kind),
|
||||||
url: searchParams.url ?? "",
|
url: searchParams.url ?? "",
|
||||||
secrets: secretKinds[0].map((kind) => ({
|
secrets: secretKinds[0].map((kind) => ({
|
||||||
kind,
|
kind,
|
||||||
@@ -80,7 +81,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} />
|
||||||
|
|
||||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||||
|
|
||||||
@@ -95,6 +96,11 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
{...form.getInputProps(`secrets.${index}.value`)}
|
{...form.getInputProps(`secrets.${index}.value`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{form.values.secrets.length === 0 && (
|
||||||
|
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
||||||
|
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
@@ -120,12 +126,20 @@ const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmented
|
|||||||
const t = useScopedI18n("integration.secrets");
|
const t = useScopedI18n("integration.secrets");
|
||||||
|
|
||||||
const secretKindGroups = secretKinds.map((kinds) => ({
|
const secretKindGroups = secretKinds.map((kinds) => ({
|
||||||
label: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
|
label:
|
||||||
value: kinds.join("-"),
|
kinds.length === 0
|
||||||
|
? t("noSecretsRequired.segmentTitle")
|
||||||
|
: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
|
||||||
|
value: kinds.length === 0 ? "empty" : kinds.join("-"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
|
if (value === "empty") {
|
||||||
|
form.setFieldValue("secrets", []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const kinds = value.split("-") as IntegrationSecretKind[];
|
const kinds = value.split("-") as IntegrationSecretKind[];
|
||||||
const secrets = kinds.map((kind) => ({
|
const secrets = kinds.map((kind) => ({
|
||||||
kind,
|
kind,
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Container, Group, Stack, Title } from "@mantine/core";
|
import { Container, Group, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
import type { validation } from "@homarr/validation";
|
import type { validation } from "@homarr/validation";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { IntegrationAvatar } from "../_integration-avatar";
|
|
||||||
import { NewIntegrationForm } from "./_integration-new-form";
|
import { NewIntegrationForm } from "./_integration-new-form";
|
||||||
|
|
||||||
interface NewIntegrationPageProps {
|
interface NewIntegrationPageProps {
|
||||||
@@ -18,13 +19,17 @@ interface NewIntegrationPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
const session = await auth();
|
||||||
const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind);
|
if (!session?.user.permissions.includes("integration-create")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getScopedI18n("integration.page.create");
|
const tCreate = await getScopedI18n("integration.page.create");
|
||||||
|
|
||||||
const currentKind = result.data;
|
const currentKind = result.data;
|
||||||
|
|
||||||
@@ -35,7 +40,7 @@ export default async function IntegrationsNewPage({ searchParams }: NewIntegrati
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group align="center">
|
<Group align="center">
|
||||||
<IntegrationAvatar kind={currentKind} size="md" />
|
<IntegrationAvatar kind={currentKind} size="md" />
|
||||||
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
|
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<NewIntegrationForm searchParams={searchParams} />
|
<NewIntegrationForm searchParams={searchParams} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -30,16 +30,16 @@ import { IconChevronDown, IconChevronUp, IconPencil } from "@tabler/icons-react"
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { objectEntries } from "@homarr/common";
|
import { objectEntries } from "@homarr/common";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { CountBadge } from "@homarr/ui";
|
import { CountBadge, IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||||
import { IntegrationAvatar } from "./_integration-avatar";
|
|
||||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||||
|
|
||||||
@@ -51,8 +51,11 @@ interface IntegrationsPageProps {
|
|||||||
|
|
||||||
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
||||||
const integrations = await api.integration.all();
|
const integrations = await api.integration.all();
|
||||||
|
const session = await auth();
|
||||||
const t = await getScopedI18n("integration");
|
const t = await getScopedI18n("integration");
|
||||||
|
|
||||||
|
const canCreateIntegrations = session?.user.permissions.includes("integration-create") ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer>
|
<ManageContainer>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
@@ -60,23 +63,27 @@ export default async function IntegrationsPage({ searchParams }: IntegrationsPag
|
|||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Title>{t("page.list.title")}</Title>
|
<Title>{t("page.list.title")}</Title>
|
||||||
|
|
||||||
<Box>
|
{canCreateIntegrations && (
|
||||||
<IntegrationSelectMenu>
|
<>
|
||||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
<Box>
|
||||||
<MenuTarget>
|
<IntegrationSelectMenu>
|
||||||
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||||
</MenuTarget>
|
<MenuTarget>
|
||||||
</Affix>
|
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
</IntegrationSelectMenu>
|
</MenuTarget>
|
||||||
</Box>
|
</Affix>
|
||||||
|
</IntegrationSelectMenu>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box visibleFrom="md">
|
<Box visibleFrom="md">
|
||||||
<IntegrationSelectMenu>
|
<IntegrationSelectMenu>
|
||||||
<MenuTarget>
|
<MenuTarget>
|
||||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
</MenuTarget>
|
</MenuTarget>
|
||||||
</IntegrationSelectMenu>
|
</IntegrationSelectMenu>
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||||
@@ -103,6 +110,8 @@ interface IntegrationListProps {
|
|||||||
|
|
||||||
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
|
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
|
||||||
const t = await getScopedI18n("integration");
|
const t = await getScopedI18n("integration");
|
||||||
|
const session = await auth();
|
||||||
|
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||||
|
|
||||||
if (integrations.length === 0) {
|
if (integrations.length === 0) {
|
||||||
return <div>{t("page.list.empty")}</div>;
|
return <div>{t("page.list.empty")}</div>;
|
||||||
@@ -152,18 +161,20 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<ActionIconGroup>
|
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||||
<ActionIcon
|
<ActionIconGroup>
|
||||||
component={Link}
|
<ActionIcon
|
||||||
href={`/manage/integrations/edit/${integration.id}`}
|
component={Link}
|
||||||
variant="subtle"
|
href={`/manage/integrations/edit/${integration.id}`}
|
||||||
color="gray"
|
variant="subtle"
|
||||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
color="gray"
|
||||||
>
|
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||||
<IconPencil size={16} stroke={1.5} />
|
>
|
||||||
</ActionIcon>
|
<IconPencil size={16} stroke={1.5} />
|
||||||
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
</ActionIcon>
|
||||||
</ActionIconGroup>
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
|
</ActionIconGroup>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
@@ -178,18 +189,21 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Text>{integration.name}</Text>
|
<Text>{integration.name}</Text>
|
||||||
<ActionIconGroup>
|
{hasFullAccess ||
|
||||||
<ActionIcon
|
(integration.permissions.hasFullAccess && (
|
||||||
component={Link}
|
<ActionIconGroup>
|
||||||
href={`/manage/integrations/edit/${integration.id}`}
|
<ActionIcon
|
||||||
variant="subtle"
|
component={Link}
|
||||||
color="gray"
|
href={`/manage/integrations/edit/${integration.id}`}
|
||||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
variant="subtle"
|
||||||
>
|
color="gray"
|
||||||
<IconPencil size={16} stroke={1.5} />
|
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||||
</ActionIcon>
|
>
|
||||||
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</ActionIconGroup>
|
</ActionIcon>
|
||||||
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
|
</ActionIconGroup>
|
||||||
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||||
{integration.url}
|
{integration.url}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
IconPlug,
|
IconPlug,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconReport,
|
IconReport,
|
||||||
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconTool,
|
IconTool,
|
||||||
IconUser,
|
IconUser,
|
||||||
@@ -22,6 +23,9 @@ import {
|
|||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
@@ -31,6 +35,7 @@ import { ClientShell } from "~/components/layout/shell";
|
|||||||
|
|
||||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||||
const t = await getScopedI18n("management.navbar");
|
const t = await getScopedI18n("management.navbar");
|
||||||
|
const session = await auth();
|
||||||
const navigationLinks: NavigationLink[] = [
|
const navigationLinks: NavigationLink[] = [
|
||||||
{
|
{
|
||||||
label: t("items.home"),
|
label: t("items.home"),
|
||||||
@@ -52,9 +57,15 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
label: t("items.integrations"),
|
label: t("items.integrations"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: IconSearch,
|
||||||
|
href: "/manage/search-engines",
|
||||||
|
label: t("items.searchEngies"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: IconUser,
|
icon: IconUser,
|
||||||
label: t("items.users.label"),
|
label: t("items.users.label"),
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t("items.users.items.manage"),
|
label: t("items.users.items.manage"),
|
||||||
@@ -65,6 +76,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
label: t("items.users.items.invites"),
|
label: t("items.users.items.invites"),
|
||||||
icon: IconMailForward,
|
icon: IconMailForward,
|
||||||
href: "/manage/users/invites",
|
href: "/manage/users/invites",
|
||||||
|
hidden: !isProviderEnabled("credentials"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.users.items.groups"),
|
label: t("items.users.items.groups"),
|
||||||
@@ -76,12 +88,18 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
{
|
{
|
||||||
label: t("items.tools.label"),
|
label: t("items.tools.label"),
|
||||||
icon: IconTool,
|
icon: IconTool,
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.docker"),
|
label: t("items.tools.items.docker"),
|
||||||
icon: IconBrandDocker,
|
icon: IconBrandDocker,
|
||||||
href: "/manage/tools/docker",
|
href: "/manage/tools/docker",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("items.tools.items.api"),
|
||||||
|
icon: IconPlug,
|
||||||
|
href: "/manage/tools/api",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.logs"),
|
label: t("items.tools.items.logs"),
|
||||||
icon: IconLogs,
|
icon: IconLogs,
|
||||||
@@ -98,6 +116,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
label: t("items.settings"),
|
label: t("items.settings"),
|
||||||
href: "/manage/settings",
|
href: "/manage/settings",
|
||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.help.label"),
|
label: t("items.help.label"),
|
||||||
@@ -106,7 +125,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
{
|
{
|
||||||
label: t("items.help.items.documentation"),
|
label: t("items.help.items.documentation"),
|
||||||
icon: IconBook2,
|
icon: IconBook2,
|
||||||
href: "https://homarr.dev/docs/getting-started/prerequisites",
|
href: createDocumentationLink("/docs/getting-started"),
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -116,7 +135,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.docker"),
|
label: t("items.help.items.discord"),
|
||||||
icon: IconBrandDiscord,
|
icon: IconBrandDiscord,
|
||||||
href: "https://discord.com/invite/aCsmEV5RgA",
|
href: "https://discord.com/invite/aCsmEV5RgA",
|
||||||
external: true,
|
external: true,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
|
|||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
@@ -14,6 +16,7 @@ interface LinkProps {
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
count: number;
|
count: number;
|
||||||
href: string;
|
href: string;
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
@@ -26,6 +29,7 @@ export async function generateMetadata() {
|
|||||||
|
|
||||||
export default async function ManagementPage() {
|
export default async function ManagementPage() {
|
||||||
const statistics = await api.home.getStats();
|
const statistics = await api.home.getStats();
|
||||||
|
const session = await auth();
|
||||||
const t = await getScopedI18n("management.page.home");
|
const t = await getScopedI18n("management.page.home");
|
||||||
|
|
||||||
const links: LinkProps[] = [
|
const links: LinkProps[] = [
|
||||||
@@ -33,37 +37,40 @@ export default async function ManagementPage() {
|
|||||||
count: statistics.countBoards,
|
count: statistics.countBoards,
|
||||||
href: "/manage/boards",
|
href: "/manage/boards",
|
||||||
subtitle: t("statisticLabel.boards"),
|
subtitle: t("statisticLabel.boards"),
|
||||||
title: t("statistic.countBoards"),
|
title: t("statistic.board"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countUsers,
|
count: statistics.countUsers,
|
||||||
href: "/manage/boards",
|
href: "/manage/users",
|
||||||
subtitle: t("statisticLabel.authentication"),
|
subtitle: t("statisticLabel.authentication"),
|
||||||
title: t("statistic.createUser"),
|
title: t("statistic.user"),
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countInvites,
|
count: statistics.countInvites,
|
||||||
href: "/manage/boards",
|
href: "/manage/users/invites",
|
||||||
subtitle: t("statisticLabel.authentication"),
|
subtitle: t("statisticLabel.authentication"),
|
||||||
title: t("statistic.createInvite"),
|
title: t("statistic.invite"),
|
||||||
|
hidden: !isProviderEnabled("credentials") || !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countIntegrations,
|
count: statistics.countIntegrations,
|
||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
subtitle: t("statisticLabel.resources"),
|
subtitle: t("statisticLabel.resources"),
|
||||||
title: t("statistic.addIntegration"),
|
title: t("statistic.integration"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countApps,
|
count: statistics.countApps,
|
||||||
href: "/manage/apps",
|
href: "/manage/apps",
|
||||||
subtitle: t("statisticLabel.resources"),
|
subtitle: t("statisticLabel.resources"),
|
||||||
title: t("statistic.addApp"),
|
title: t("statistic.app"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countGroups,
|
count: statistics.countGroups,
|
||||||
href: "/manage/users/groups",
|
href: "/manage/users/groups",
|
||||||
subtitle: t("statisticLabel.authorization"),
|
subtitle: t("statisticLabel.authorization"),
|
||||||
title: t("statistic.manageRoles"),
|
title: t("statistic.group"),
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
@@ -72,24 +79,27 @@ export default async function ManagementPage() {
|
|||||||
<HeroBanner />
|
<HeroBanner />
|
||||||
<Space h="md" />
|
<Space h="md" />
|
||||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||||
{links.map((link, index) => (
|
{links.map(
|
||||||
<Card component={Link} href={link.href} key={`link-${index}`} withBorder>
|
(link) =>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
!link.hidden && (
|
||||||
<Group wrap="nowrap">
|
<Card component={Link} href={link.href} key={link.href} withBorder>
|
||||||
<Text size="2.4rem" fw="bolder">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
{link.count}
|
<Group wrap="nowrap">
|
||||||
</Text>
|
<Text size="2.4rem" fw="bolder">
|
||||||
<Stack gap={0}>
|
{link.count}
|
||||||
<Text c="red" size="xs">
|
</Text>
|
||||||
{link.subtitle}
|
<Stack gap={0}>
|
||||||
</Text>
|
<Text c="red" size="xs">
|
||||||
<Text fw="bold">{link.title}</Text>
|
{link.subtitle}
|
||||||
</Stack>
|
</Text>
|
||||||
</Group>
|
<Text fw="bold">{link.title}</Text>
|
||||||
<IconArrowRight />
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
<IconArrowRight />
|
||||||
))}
|
</Group>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button, Grid, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import type { z } from "@homarr/validation";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { IconPicker } from "~/components/icons/picker/icon-picker";
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof validation.searchEngine.manage>;
|
||||||
|
|
||||||
|
interface SearchEngineFormProps {
|
||||||
|
submitButtonTranslation: (t: TranslationFunction) => string;
|
||||||
|
initialValues?: FormType;
|
||||||
|
handleSubmit: (values: FormType) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
disableShort?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||||
|
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const form = useZodForm(validation.searchEngine.manage, {
|
||||||
|
initialValues: initialValues ?? {
|
||||||
|
name: "",
|
||||||
|
short: "",
|
||||||
|
iconUrl: "",
|
||||||
|
urlTemplate: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, md: 8, lg: 9, xl: 10 }}>
|
||||||
|
<TextInput {...form.getInputProps("name")} withAsterisk label={t("search.engine.field.name.label")} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 4, lg: 3, xl: 2 }}>
|
||||||
|
<TextInput
|
||||||
|
{...form.getInputProps("short")}
|
||||||
|
disabled={disableShort}
|
||||||
|
withAsterisk
|
||||||
|
label={t("search.engine.field.short.label")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
||||||
|
<TextInput
|
||||||
|
{...form.getInputProps("urlTemplate")}
|
||||||
|
withAsterisk
|
||||||
|
label={t("search.engine.field.urlTemplate.label")}
|
||||||
|
/>
|
||||||
|
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button variant="default" component={Link} href="/manage/search-engines">
|
||||||
|
{t("common.action.backToOverview")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{submitButtonTranslation(t)}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { ActionIcon } from "@mantine/core";
|
||||||
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
interface SearchEngineDeleteButtonProps {
|
||||||
|
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
|
||||||
|
const t = useScopedI18n("search.engine.page.delete");
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
openConfirmModal({
|
||||||
|
title: t("title"),
|
||||||
|
children: t("message", searchEngine),
|
||||||
|
onConfirm: () => {
|
||||||
|
mutate(
|
||||||
|
{ id: searchEngine.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("notification.success.title"),
|
||||||
|
message: t("notification.success.message"),
|
||||||
|
});
|
||||||
|
void revalidatePathActionAsync("/manage/search-engines");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("notification.error.title"),
|
||||||
|
message: t("notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [searchEngine, mutate, t, openConfirmModal]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
|
||||||
|
<IconTrash color="red" size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
};
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import type { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { SearchEngineForm } from "../../_form";
|
||||||
|
|
||||||
|
interface SearchEngineEditFormProps {
|
||||||
|
searchEngine: RouterOutputs["searchEngine"]["byId"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps) => {
|
||||||
|
const t = useScopedI18n("search.engine.page.edit.notification");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { mutate, isPending } = clientApi.searchEngine.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("success.title"),
|
||||||
|
message: t("success.message"),
|
||||||
|
});
|
||||||
|
void revalidatePathActionAsync("/manage/search-engines").then(() => {
|
||||||
|
router.push("/manage/search-engines");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("error.title"),
|
||||||
|
message: t("error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(values: z.infer<typeof validation.searchEngine.manage>) => {
|
||||||
|
mutate({
|
||||||
|
id: searchEngine.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mutate, searchEngine.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchEngineForm
|
||||||
|
submitButtonTranslation={submitButtonTranslation}
|
||||||
|
initialValues={searchEngine}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
isPending={isPending}
|
||||||
|
disableShort
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { SearchEngineEditForm } from "./_search-engine-edit-form";
|
||||||
|
|
||||||
|
interface SearchEngineEditPageProps {
|
||||||
|
params: { id: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
|
||||||
|
const searchEngine = await api.searchEngine.byId({ id: params.id });
|
||||||
|
const t = await getI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ManageContainer>
|
||||||
|
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, searchEngine.name]])} nonInteractable={["edit"]} />
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("search.engine.page.edit.title")}</Title>
|
||||||
|
<SearchEngineEditForm searchEngine={searchEngine} />
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import type { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { SearchEngineForm } from "../_form";
|
||||||
|
|
||||||
|
export const SearchEngineNewForm = () => {
|
||||||
|
const t = useScopedI18n("search.engine.page.create.notification");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { mutate, isPending } = clientApi.searchEngine.create.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("success.title"),
|
||||||
|
message: t("success.message"),
|
||||||
|
});
|
||||||
|
await revalidatePathActionAsync("/manage/search-engines");
|
||||||
|
router.push("/manage/search-engines");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("error.title"),
|
||||||
|
message: t("error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(values: z.infer<typeof validation.searchEngine.manage>) => {
|
||||||
|
mutate(values);
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchEngineForm
|
||||||
|
submitButtonTranslation={submitButtonTranslation}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
isPending={isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { SearchEngineNewForm } from "./_search-engine-new-form";
|
||||||
|
|
||||||
|
export default async function SearchEngineNewPage() {
|
||||||
|
const t = await getI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ManageContainer>
|
||||||
|
<DynamicBreadcrumb />
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("search.engine.page.create.title")}</Title>
|
||||||
|
<SearchEngineNewForm />
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||||
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
||||||
|
|
||||||
|
const searchParamsSchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||||
|
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
||||||
|
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
interface SearchEnginesPageProps {
|
||||||
|
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||||
|
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||||
|
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
||||||
|
|
||||||
|
const tEngine = await getScopedI18n("search.engine");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ManageContainer>
|
||||||
|
<DynamicBreadcrumb />
|
||||||
|
<Stack>
|
||||||
|
<Title>{tEngine("page.list.title")}</Title>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
|
||||||
|
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||||
|
{tEngine("page.create.title")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
</Group>
|
||||||
|
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
||||||
|
{searchEngines.length > 0 && (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{searchEngines.map((searchEngine) => (
|
||||||
|
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchEngineCardProps {
|
||||||
|
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||||
|
const t = await getScopedI18n("search.engine");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
||||||
|
<Avatar
|
||||||
|
size="sm"
|
||||||
|
src={searchEngine.iconUrl}
|
||||||
|
radius={0}
|
||||||
|
styles={{
|
||||||
|
image: {
|
||||||
|
objectFit: "contain",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={500} lineClamp={1}>
|
||||||
|
{searchEngine.name}
|
||||||
|
</Text>
|
||||||
|
{searchEngine.description && (
|
||||||
|
<Text size="sm" c="gray.6" lineClamp={4}>
|
||||||
|
{searchEngine.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
|
||||||
|
{searchEngine.urlTemplate}
|
||||||
|
</Anchor>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<ActionIconGroup>
|
||||||
|
<ActionIcon
|
||||||
|
component={Link}
|
||||||
|
href={`/manage/search-engines/edit/${searchEngine.id}`}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={t("page.edit.title")}
|
||||||
|
>
|
||||||
|
<IconPencil size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
<SearchEngineDeleteButton searchEngine={searchEngine} />
|
||||||
|
</ActionIconGroup>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SearchEngineNoResults = async () => {
|
||||||
|
const t = await getI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder bg="transparent">
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<IconSearch size="2rem" />
|
||||||
|
<Text fw={500} size="lg">
|
||||||
|
{t("search.engine.page.list.noResults.title")}
|
||||||
|
</Text>
|
||||||
|
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { MantineSpacing } from "@mantine/core";
|
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
|
||||||
import { Card, Group, LoadingOverlay, Stack, Switch, Text, Title, UnstyledButton } from "@mantine/core";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useForm } from "@homarr/form";
|
import { useForm } from "@homarr/form";
|
||||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||||
|
|
||||||
interface AnalyticsSettingsProps {
|
interface AnalyticsSettingsProps {
|
||||||
initialData: typeof defaultServerSettings.analytics;
|
initialData: typeof defaultServerSettings.analytics;
|
||||||
@@ -62,6 +60,7 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
|||||||
ms="xl"
|
ms="xl"
|
||||||
title={t("integrationData.title")}
|
title={t("integrationData.title")}
|
||||||
text={t("integrationData.text")}
|
text={t("integrationData.text")}
|
||||||
|
disabled={!form.values.enableGeneral}
|
||||||
/>
|
/>
|
||||||
<SwitchSetting
|
<SwitchSetting
|
||||||
form={form}
|
form={form}
|
||||||
@@ -69,6 +68,7 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
|||||||
ms="xl"
|
ms="xl"
|
||||||
title={t("widgetData.title")}
|
title={t("widgetData.title")}
|
||||||
text={t("widgetData.text")}
|
text={t("widgetData.text")}
|
||||||
|
disabled={!form.values.enableGeneral}
|
||||||
/>
|
/>
|
||||||
<SwitchSetting
|
<SwitchSetting
|
||||||
form={form}
|
form={form}
|
||||||
@@ -76,45 +76,10 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
|||||||
ms="xl"
|
ms="xl"
|
||||||
title={t("usersData.title")}
|
title={t("usersData.title")}
|
||||||
text={t("usersData.text")}
|
text={t("usersData.text")}
|
||||||
|
disabled={!form.values.enableGeneral}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SwitchSetting = ({
|
|
||||||
form,
|
|
||||||
ms,
|
|
||||||
title,
|
|
||||||
text,
|
|
||||||
formKey,
|
|
||||||
}: {
|
|
||||||
form: UseFormReturnType<typeof defaultServerSettings.analytics>;
|
|
||||||
formKey: keyof typeof defaultServerSettings.analytics;
|
|
||||||
ms?: MantineSpacing;
|
|
||||||
title: string;
|
|
||||||
text: ReactNode;
|
|
||||||
}) => {
|
|
||||||
const disabled = formKey !== "enableGeneral" && !form.values.enableGeneral;
|
|
||||||
const handleClick = React.useCallback(() => {
|
|
||||||
if (disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.setFieldValue(formKey, !form.values[formKey]);
|
|
||||||
}, [form, formKey, disabled]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
|
|
||||||
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text fw="bold">{title}</Text>
|
|
||||||
<Text c="gray.5" fz={{ base: "xs", md: "sm" }}>
|
|
||||||
{text}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</UnstyledButton>
|
|
||||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useForm } from "@homarr/form";
|
||||||
|
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||||
|
|
||||||
|
interface CrawlingAndIndexingSettingsProps {
|
||||||
|
initialData: typeof defaultServerSettings.crawlingAndIndexing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => {
|
||||||
|
const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing");
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: initialData,
|
||||||
|
onValuesChange: (updatedValues, _) => {
|
||||||
|
if (!form.isValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void mutateAsync({
|
||||||
|
settingsKey: "crawlingAndIndexing",
|
||||||
|
value: updatedValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||||
|
onSettled: async () => {
|
||||||
|
await revalidatePathActionAsync("/manage/settings");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Title order={2}>{t("title")}</Title>
|
||||||
|
|
||||||
|
<Card pos="relative" withBorder>
|
||||||
|
<Text c={"dimmed"} mb={"lg"}>
|
||||||
|
{t("warning")}
|
||||||
|
</Text>
|
||||||
|
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||||
|
<Stack>
|
||||||
|
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||||
|
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||||
|
<SwitchSetting
|
||||||
|
form={form}
|
||||||
|
formKey="noTranslate"
|
||||||
|
title={t("noTranslate.title")}
|
||||||
|
text={t("noTranslate.text")}
|
||||||
|
/>
|
||||||
|
<SwitchSetting
|
||||||
|
form={form}
|
||||||
|
formKey="noSiteLinksSearchBox"
|
||||||
|
title={t("noSiteLinksSearchBox.title")}
|
||||||
|
text={t("noSiteLinksSearchBox.text")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import React from "react";
|
||||||
|
import type { MantineSpacing } from "@mantine/core";
|
||||||
|
import { Group, Stack, Switch, Text, UnstyledButton } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { Modify } from "@homarr/common/types";
|
||||||
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
|
|
||||||
|
export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||||
|
form,
|
||||||
|
ms,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
formKey,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
form: Modify<
|
||||||
|
UseFormReturnType<TFormValue, () => TFormValue>,
|
||||||
|
{
|
||||||
|
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
formKey: keyof TFormValue;
|
||||||
|
ms?: MantineSpacing;
|
||||||
|
title: string;
|
||||||
|
text: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
const handleClick = React.useCallback(() => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setFieldValue(formKey, (previous) => !previous);
|
||||||
|
}, [form, formKey, disabled]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
|
||||||
|
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw="bold">{title}</Text>
|
||||||
|
<Text c="gray.5" fz={{ base: "xs", md: "sm" }}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</UnstyledButton>
|
||||||
|
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { Stack, Title } from "@mantine/core";
|
|||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export default async function SettingsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("title")}</Title>
|
<Title order={1}>{t("title")}</Title>
|
||||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||||
|
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Button, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
|
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useModalAction } from "@homarr/modals";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal";
|
||||||
|
|
||||||
|
interface ApiKeysManagementProps {
|
||||||
|
apiKeys: RouterOutputs["apiKeys"]["getAll"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
||||||
|
const { openModal } = useModalAction(CopyApiKeyModal);
|
||||||
|
const { mutate, isPending } = clientApi.apiKeys.create.useMutation({
|
||||||
|
async onSuccess(data) {
|
||||||
|
openModal({
|
||||||
|
apiKey: data.randomToken,
|
||||||
|
});
|
||||||
|
await revalidatePathActionAsync("/manage/tools/api");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const t = useScopedI18n("management.page.tool.api.tab.apiKey");
|
||||||
|
|
||||||
|
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: t("table.header.id"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "user",
|
||||||
|
header: t("table.header.createdBy"),
|
||||||
|
Cell: ({ row }) => (
|
||||||
|
<Group gap={"xs"}>
|
||||||
|
<UserAvatar user={row.original.user} size={"sm"} />
|
||||||
|
<Text>{row.original.user.name}</Text>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useMantineReactTable({
|
||||||
|
columns,
|
||||||
|
data: apiKeys,
|
||||||
|
renderTopToolbarCustomActions: () => (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
mutate();
|
||||||
|
}}
|
||||||
|
loading={isPending}
|
||||||
|
>
|
||||||
|
{t("button.createApiToken")}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
enableDensityToggle: false,
|
||||||
|
state: {
|
||||||
|
density: "xs",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("title")}</Title>
|
||||||
|
<MantineReactTable table={table} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
|
||||||
|
import { createModal } from "@homarr/modals";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => {
|
||||||
|
const t = useScopedI18n("management.page.tool.api.modal.createApiToken");
|
||||||
|
const [visible, { toggle }] = useDisclosure(false);
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Text>{t("description")}</Text>
|
||||||
|
<PasswordInput value={innerProps.apiKey} visible={visible} onVisibilityChange={toggle} readOnly />
|
||||||
|
<CopyButton value={innerProps.apiKey}>
|
||||||
|
{({ copy }) => (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
copy();
|
||||||
|
actions.closeModal();
|
||||||
|
}}
|
||||||
|
variant="default"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t("button")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}).withOptions({
|
||||||
|
defaultTitle(t) {
|
||||||
|
return t("management.page.tool.api.modal.createApiToken.title");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { OpenAPIV3 } from "openapi-types";
|
||||||
|
import SwaggerUI from "swagger-ui-react";
|
||||||
|
|
||||||
|
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
|
||||||
|
import "../swagger-ui-dark.css";
|
||||||
|
import "../swagger-ui-overrides.css";
|
||||||
|
import "../swagger-ui.css";
|
||||||
|
|
||||||
|
interface SwaggerUIClientProps {
|
||||||
|
document: OpenAPIV3.Document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const requestInterceptor = (req: Record<string, any>) => {
|
||||||
|
req.credentials = "omit";
|
||||||
|
return req;
|
||||||
|
};
|
||||||
|
|
||||||
|
return <SwaggerUI requestInterceptor={requestInterceptor} spec={document} />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
||||||
|
|
||||||
|
import { openApiDocument } from "@homarr/api";
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||||
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { SwaggerUIClient } from "~/app/[locale]/manage/tools/api/components/swagger-ui";
|
||||||
|
import { createMetaTitle } from "~/metadata";
|
||||||
|
import { ApiKeysManagement } from "./components/api-keys";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !session.user.permissions.includes("admin")) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getScopedI18n("management");
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: createMetaTitle(t("metaTitle")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ApiPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !session.user.permissions.includes("admin")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
const document = openApiDocument(extractBaseUrlFromHeaders(headers()));
|
||||||
|
const apiKeys = await api.apiKeys.getAll();
|
||||||
|
const t = await getScopedI18n("management.page.tool.api.tab");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Tabs defaultValue={"documentation"}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTab value={"documentation"}>{t("documentation.label")}</TabsTab>
|
||||||
|
<TabsTab value={"authentication"}>{t("apiKey.label")}</TabsTab>
|
||||||
|
</TabsList>
|
||||||
|
<TabsPanel value={"authentication"}>
|
||||||
|
<ApiKeysManagement apiKeys={apiKeys} />
|
||||||
|
</TabsPanel>
|
||||||
|
<TabsPanel value={"documentation"}>
|
||||||
|
<SwaggerUIClient document={document} />
|
||||||
|
</TabsPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,4 @@
|
|||||||
|
.swagger-ui .info {
|
||||||
|
margin: 0 !important;
|
||||||
|
margin-bottom: 20px !important;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import type { MantineColor } from "@mantine/core";
|
import type { MantineColor } from "@mantine/core";
|
||||||
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
||||||
import { IconPlayerPlay, IconPlayerStop, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
|
import { IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
import { MantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -15,6 +15,7 @@ import type { TranslationFunction } from "@homarr/translation";
|
|||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
import { OverflowBadge } from "@homarr/ui";
|
import { OverflowBadge } from "@homarr/ui";
|
||||||
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
const createColumns = (
|
const createColumns = (
|
||||||
t: TranslationFunction,
|
t: TranslationFunction,
|
||||||
@@ -45,10 +46,12 @@ const createColumns = (
|
|||||||
accessorKey: "image",
|
accessorKey: "image",
|
||||||
header: t("docker.field.containerImage.label"),
|
header: t("docker.field.containerImage.label"),
|
||||||
maxSize: 200,
|
maxSize: 200,
|
||||||
Cell({ renderedCellValue }) {
|
Cell({ renderedCellValue, cell }) {
|
||||||
return (
|
return (
|
||||||
<Box maw={200}>
|
<Box maw={200}>
|
||||||
<Text truncate="end">{renderedCellValue}</Text>
|
<Text truncate="end" title={cell.row.original.image}>
|
||||||
|
{renderedCellValue}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -74,7 +77,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
});
|
});
|
||||||
const relativeTime = useTimeAgo(data.timestamp);
|
const relativeTime = useTimeAgo(data.timestamp);
|
||||||
const table = useMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data: data.containers,
|
data: data.containers,
|
||||||
enableDensityToggle: false,
|
enableDensityToggle: false,
|
||||||
enableColumnActions: false,
|
enableColumnActions: false,
|
||||||
@@ -92,6 +95,35 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
},
|
},
|
||||||
|
|
||||||
initialState: { density: "xs", showGlobalFilter: true },
|
initialState: { density: "xs", showGlobalFilter: true },
|
||||||
|
renderTopToolbarCustomActions: () => {
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||||
|
async onSuccess() {
|
||||||
|
await utils.docker.getContainers.invalidate();
|
||||||
|
showSuccessNotification({
|
||||||
|
title: tDocker("action.refresh.notification.success.title"),
|
||||||
|
message: tDocker("action.refresh.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: tDocker("action.refresh.notification.error.title"),
|
||||||
|
message: tDocker("action.refresh.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
rightSection={<IconRefresh size="1rem" />}
|
||||||
|
onClick={() => mutate()}
|
||||||
|
loading={isPending}
|
||||||
|
>
|
||||||
|
{tDocker("action.refresh.label")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||||
return (
|
return (
|
||||||
<Group gap={"sm"}>
|
<Group gap={"sm"}>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
import { Stack, Title } from "@mantine/core";
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { DockerTable } from "./docker-table";
|
import { DockerTable } from "./docker-table";
|
||||||
|
|
||||||
export default async function DockerPage() {
|
export default async function DockerPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !session.user.permissions.includes("admin")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const { containers, timestamp } = await api.docker.getContainers();
|
const { containers, timestamp } = await api.docker.getContainers();
|
||||||
const tDocker = await getScopedI18n("docker");
|
const tDocker = await getScopedI18n("docker");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
export const ClientSideTerminalComponent = dynamic(
|
||||||
|
() => import("./terminal").then(({ TerminalComponent }) => TerminalComponent),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -4,13 +4,20 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
|
import { ClientSideTerminalComponent } from "./client";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !session.user.permissions.includes("admin")) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -18,11 +25,12 @@ export async function generateMetadata() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClientSideTerminalComponent = dynamic(() => import("./terminal"), {
|
export default async function LogsManagementPage() {
|
||||||
ssr: false,
|
const session = await auth();
|
||||||
});
|
if (!session?.user || !session.user.permissions.includes("admin")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
export default function LogsManagementPage() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
|
|
||||||
import classes from "./terminal.module.css";
|
import classes from "./terminal.module.css";
|
||||||
|
|
||||||
export default function TerminalComponent() {
|
export const TerminalComponent = () => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const terminalRef = useRef<Terminal>();
|
const terminalRef = useRef<Terminal>();
|
||||||
@@ -54,4 +54,4 @@ export default function TerminalComponent() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
||||||
}
|
};
|
||||||
@@ -58,7 +58,7 @@ export const JobsList = ({ initialJobs }: JobsListProps) => {
|
|||||||
<Group justify={"space-between"} gap={"md"}>
|
<Group justify={"space-between"} gap={"md"}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Group>
|
<Group>
|
||||||
<Text>{t(`${job.job.name}.label` as TranslationKeys)}</Text>
|
<Text>{t(`job.${job.job.name}.label` as TranslationKeys)}</Text>
|
||||||
{job.status?.status === "idle" && <Badge variant="default">{t("status.idle")}</Badge>}
|
{job.status?.status === "idle" && <Badge variant="default">{t("status.idle")}</Badge>}
|
||||||
{job.status?.status === "running" && <Badge color="green">{t("status.running")}</Badge>}
|
{job.status?.status === "running" && <Badge color="green">{t("status.running")}</Badge>}
|
||||||
{job.status?.lastExecutionStatus === "error" && <Badge color="red">{t("status.error")}</Badge>}
|
{job.status?.lastExecutionStatus === "error" && <Badge color="red">{t("status.error")}</Badge>}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
import { Box, Title } from "@mantine/core";
|
import { Box, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { JobsList } from "./_components/jobs-list";
|
import { JobsList } from "./_components/jobs-list";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -15,6 +21,11 @@ export async function generateMetadata() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function TasksPage() {
|
export default async function TasksPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const jobs = await api.cronJobs.getJobs();
|
const jobs = await api.cronJobs.getJobs();
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Select, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import type { z } from "@homarr/validation";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
interface ChangeHomeBoardFormProps {
|
||||||
|
user: RouterOutputs["user"]["getById"];
|
||||||
|
boardsData: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutate, isPending } = clientApi.user.changeHomeBoardId.useMutation({
|
||||||
|
async onSettled() {
|
||||||
|
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||||
|
},
|
||||||
|
onSuccess(_, variables) {
|
||||||
|
form.setInitialValues({
|
||||||
|
homeBoardId: variables.homeBoardId,
|
||||||
|
});
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("user.action.changeHomeBoard.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
message: t("user.action.changeHomeBoard.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useZodForm(validation.user.changeHomeBoard, {
|
||||||
|
initialValues: {
|
||||||
|
homeBoardId: user.homeBoardId ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: FormType) => {
|
||||||
|
mutate({
|
||||||
|
userId: user.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select w="100%" data={boardsData} {...form.getInputProps("homeBoardId")} />
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" color="teal" loading={isPending}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof validation.user.changeHomeBoard>;
|
||||||
+1
-2
@@ -6,11 +6,10 @@ import { Button } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface DeleteUserButtonProps {
|
interface DeleteUserButtonProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
|
|||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Radio, Stack } from "@mantine/core";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import localeData from "dayjs/plugin/localeData";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import type { z } from "@homarr/validation";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
dayjs.extend(localeData);
|
||||||
|
|
||||||
|
interface FirstDayOfWeekProps {
|
||||||
|
user: RouterOutputs["user"]["getById"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekDays = dayjs.weekdays(false);
|
||||||
|
|
||||||
|
export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({
|
||||||
|
async onSettled() {
|
||||||
|
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||||
|
},
|
||||||
|
onSuccess(_, variables) {
|
||||||
|
form.setInitialValues({
|
||||||
|
firstDayOfWeek: variables.firstDayOfWeek,
|
||||||
|
});
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
message: t("user.action.changeFirstDayOfWeek.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useZodForm(validation.user.firstDayOfWeek, {
|
||||||
|
initialValues: {
|
||||||
|
firstDayOfWeek: user.firstDayOfWeek,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: FormType) => {
|
||||||
|
mutate({
|
||||||
|
id: user.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputProps = form.getInputProps("firstDayOfWeek");
|
||||||
|
const onChange = inputProps.onChange as (value: number) => void;
|
||||||
|
const value = (inputProps.value as number).toString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Radio.Group {...inputProps} value={value} onChange={(value) => onChange(parseInt(value))}>
|
||||||
|
<Group mt="xs">
|
||||||
|
<Radio value="1" label={weekDays[1]} />
|
||||||
|
<Radio value="6" label={weekDays[6]} />
|
||||||
|
<Radio value="0" label={weekDays[0]} />
|
||||||
|
</Group>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" color="teal" loading={isPending}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof validation.user.firstDayOfWeek>;
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Stack, Switch } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import type { z } from "@homarr/validation";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
interface PingIconsEnabledProps {
|
||||||
|
user: RouterOutputs["user"]["getById"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
|
||||||
|
async onSettled() {
|
||||||
|
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||||
|
},
|
||||||
|
onSuccess(_, variables) {
|
||||||
|
form.setInitialValues({
|
||||||
|
pingIconsEnabled: variables.pingIconsEnabled,
|
||||||
|
});
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("user.action.changePingIconsEnabled.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
message: t("user.action.changePingIconsEnabled.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useZodForm(validation.user.pingIconsEnabled, {
|
||||||
|
initialValues: {
|
||||||
|
pingIconsEnabled: user.pingIconsEnabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: FormType) => {
|
||||||
|
mutate({
|
||||||
|
id: user.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Switch {...form.getInputProps("pingIconsEnabled")} label={t("user.field.pingIconsEnabled.label")} />
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" color="teal" loading={isPending}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof validation.user.pingIconsEnabled>;
|
||||||
+29
-16
@@ -7,13 +7,12 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { UserAvatar } from "@homarr/ui";
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface UserProfileAvatarForm {
|
interface UserProfileAvatarForm {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
@@ -93,24 +92,38 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
|||||||
});
|
});
|
||||||
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
||||||
|
|
||||||
|
const isCredentialsUser = user.provider === "credentials";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pos="relative">
|
<Box pos="relative">
|
||||||
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
|
<Menu
|
||||||
|
opened={opened}
|
||||||
|
keepMounted
|
||||||
|
onChange={isCredentialsUser ? toggle : undefined}
|
||||||
|
position="bottom-start"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton onClick={toggle}>
|
<UnstyledButton
|
||||||
|
component={isCredentialsUser ? undefined : "div"}
|
||||||
|
style={{ cursor: !isCredentialsUser ? "default" : undefined }}
|
||||||
|
onClick={isCredentialsUser ? toggle : undefined}
|
||||||
|
>
|
||||||
<UserAvatar user={user} size={200} />
|
<UserAvatar user={user} size={200} />
|
||||||
<Button
|
{isCredentialsUser && (
|
||||||
component="div"
|
<Button
|
||||||
pos="absolute"
|
component="div"
|
||||||
bottom={0}
|
pos="absolute"
|
||||||
left={0}
|
bottom={0}
|
||||||
size="compact-md"
|
left={0}
|
||||||
fw="normal"
|
size="compact-md"
|
||||||
variant="default"
|
fw="normal"
|
||||||
leftSection={<IconPencil size={18} stroke={1.5} />}
|
variant="default"
|
||||||
>
|
leftSection={<IconPencil size={18} stroke={1.5} />}
|
||||||
{t("common.action.edit")}
|
>
|
||||||
</Button>
|
{t("common.action.edit")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
|||||||
+24
-10
@@ -5,13 +5,12 @@ import { Button, Group, Stack, TextInput } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface UserProfileFormProps {
|
interface UserProfileFormProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
@@ -51,27 +50,42 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Only credentials users can edit their profile
|
||||||
|
const isProviderCredentials = user.provider === "credentials";
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(values: FormType) => {
|
(values: FormType) => {
|
||||||
|
if (!isProviderCredentials) return;
|
||||||
mutate({
|
mutate({
|
||||||
...values,
|
...values,
|
||||||
id: user.id,
|
id: user.id,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[user.id, mutate],
|
[isProviderCredentials, mutate, user.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={t("user.field.username.label")} withAsterisk {...form.getInputProps("name")} />
|
<TextInput
|
||||||
<TextInput label={t("user.field.email.label")} {...form.getInputProps("email")} />
|
disabled={!isProviderCredentials}
|
||||||
|
label={t("user.field.username.label")}
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
disabled={!isProviderCredentials}
|
||||||
|
label={t("user.field.email.label")}
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
|
||||||
<Group justify="end">
|
{isProviderCredentials && (
|
||||||
<Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}>
|
<Group justify="end">
|
||||||
{t("common.action.saveChanges")}
|
<Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}>
|
||||||
</Button>
|
{t("common.action.saveChanges")}
|
||||||
</Group>
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
import { Stack, Title } from "@mantine/core";
|
|
||||||
|
|
||||||
import { LanguageCombobox } from "~/components/language/language-combobox";
|
|
||||||
|
|
||||||
export const ProfileLanguageChange = () => {
|
|
||||||
return (
|
|
||||||
<Stack mb="lg">
|
|
||||||
<Title order={2}>Language & Region</Title>
|
|
||||||
<LanguageCombobox />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Box, Group, Stack, Title } from "@mantine/core";
|
import { Alert, Box, Group, Stack, Title } from "@mantine/core";
|
||||||
|
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { LanguageCombobox } from "~/components/language/language-combobox";
|
||||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
|
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
|
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
||||||
|
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
|
||||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||||
import { UserProfileForm } from "./_components/_profile-form";
|
import { UserProfileForm } from "./_components/_profile-form";
|
||||||
import { ProfileLanguageChange } from "./_components/_profile-language-change";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: {
|
params: {
|
||||||
@@ -53,8 +57,18 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const boards = await api.board.getAllBoards();
|
||||||
|
|
||||||
|
const isCredentialsUser = user.provider === "credentials";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
|
{!isCredentialsUser && (
|
||||||
|
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||||
|
{t("management.page.user.fieldsDisabledExternalProvider")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Title>{tGeneral("title")}</Title>
|
<Title>{tGeneral("title")}</Title>
|
||||||
<Group gap="xl">
|
<Group gap="xl">
|
||||||
<Box flex={1}>
|
<Box flex={1}>
|
||||||
@@ -65,15 +79,41 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ProfileLanguageChange />
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.language")}</Title>
|
||||||
|
<LanguageCombobox />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<DangerZoneRoot>
|
<Stack mb="lg">
|
||||||
<DangerZoneItem
|
<Title order={2}>{tGeneral("item.board")}</Title>
|
||||||
label={t("user.action.delete.label")}
|
<ChangeHomeBoardForm
|
||||||
description={t("user.action.delete.description")}
|
user={user}
|
||||||
action={<DeleteUserButton user={user} />}
|
boardsData={boards.map((board) => ({
|
||||||
|
value: board.id,
|
||||||
|
label: board.name,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</DangerZoneRoot>
|
</Stack>
|
||||||
|
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||||
|
<FirstDayOfWeek user={user} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.accessibility")}</Title>
|
||||||
|
<PingIconsEnabled user={user} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{isCredentialsUser && (
|
||||||
|
<DangerZoneRoot>
|
||||||
|
<DangerZoneItem
|
||||||
|
label={t("user.action.delete.label")}
|
||||||
|
description={t("user.action.delete.description")}
|
||||||
|
action={<DeleteUserButton user={user} />}
|
||||||
|
/>
|
||||||
|
</DangerZoneRoot>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,8 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCredentialsUser = user.provider === "credentials";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer size="xl">
|
<ManageContainer size="xl">
|
||||||
<DynamicBreadcrumb
|
<DynamicBreadcrumb
|
||||||
@@ -41,7 +43,7 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
|
|||||||
/>
|
/>
|
||||||
<Grid>
|
<Grid>
|
||||||
<GridCol span={12}>
|
<GridCol span={12}>
|
||||||
<Group justify="space-between" align="center">
|
<Group align="center">
|
||||||
<UserAvatar user={user} size="lg" />
|
<UserAvatar user={user} size="lg" />
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Title order={3}>{user.name}</Title>
|
<Title order={3}>{user.name}</Title>
|
||||||
@@ -57,11 +59,13 @@ export default async function Layout({ children, params }: PropsWithChildren<Lay
|
|||||||
label={tUser("setting.general.title")}
|
label={tUser("setting.general.title")}
|
||||||
icon={<IconSettings size="1rem" stroke={1.5} />}
|
icon={<IconSettings size="1rem" stroke={1.5} />}
|
||||||
/>
|
/>
|
||||||
<NavigationLink
|
{isCredentialsUser && (
|
||||||
href={`/manage/users/${params.userId}/security`}
|
<NavigationLink
|
||||||
label={tUser("setting.security.title")}
|
href={`/manage/users/${params.userId}/security`}
|
||||||
icon={<IconShieldLock size="1rem" stroke={1.5} />}
|
label={tUser("setting.security.title")}
|
||||||
/>
|
icon={<IconShieldLock size="1rem" stroke={1.5} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
|
|||||||
+8
-3
@@ -5,13 +5,13 @@ import { Button, Fieldset, Group, PasswordInput, Stack } from "@mantine/core";
|
|||||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { CustomPasswordInput } from "@homarr/ui";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface ChangePasswordFormProps {
|
interface ChangePasswordFormProps {
|
||||||
user: RouterOutputs["user"]["getById"];
|
user: RouterOutputs["user"]["getById"];
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,12 @@ export const ChangePasswordForm = ({ user }: ChangePasswordFormProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PasswordInput withAsterisk label={t("user.field.password.label")} {...form.getInputProps("password")} />
|
<CustomPasswordInput
|
||||||
|
withPasswordRequirements
|
||||||
|
withAsterisk
|
||||||
|
label={t("user.field.password.label")}
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
withAsterisk
|
withAsterisk
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export default async function UserSecurityPage({ params }: Props) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.provider !== "credentials") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title>{tSecurity("title")}</Title>
|
<Title>{tSecurity("title")}</Title>
|
||||||
|
|||||||
+24
-19
@@ -2,20 +2,23 @@
|
|||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Avatar, Button, Group, Text, ThemeIcon, Title } from "@mantine/core";
|
import { Anchor, Button, Group, Text, Title, Tooltip } from "@mantine/core";
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
import { MantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
interface UserListComponentProps {
|
interface UserListComponentProps {
|
||||||
initialUserList: RouterOutputs["user"]["getAll"];
|
initialUserList: RouterOutputs["user"]["getAll"];
|
||||||
|
credentialsProviderEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserListComponent = ({ initialUserList }: UserListComponentProps) => {
|
export const UserListComponent = ({ initialUserList, credentialsProviderEnabled }: UserListComponentProps) => {
|
||||||
const tUserList = useScopedI18n("management.page.user.list");
|
const tUserList = useScopedI18n("management.page.user.list");
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
|
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
|
||||||
@@ -27,26 +30,27 @@ export const UserListComponent = ({ initialUserList }: UserListComponentProps) =
|
|||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: t("user.field.username.label"),
|
header: t("user.field.username.label"),
|
||||||
grow: 100,
|
grow: 1,
|
||||||
Cell: ({ renderedCellValue, row }) => (
|
Cell: ({ renderedCellValue, row }) => (
|
||||||
<Link href={`/manage/users/${row.original.id}/general`}>
|
<Group>
|
||||||
<Group>
|
<UserAvatar size="sm" user={row.original} />
|
||||||
<Avatar size="sm"></Avatar>
|
<Anchor component={Link} href={`/manage/users/${row.original.id}/general`}>
|
||||||
{renderedCellValue}
|
{renderedCellValue}
|
||||||
</Group>
|
</Anchor>
|
||||||
</Link>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "email",
|
accessorKey: "email",
|
||||||
header: t("user.field.email.label"),
|
header: t("user.field.email.label"),
|
||||||
|
size: 300,
|
||||||
Cell: ({ renderedCellValue, row }) => (
|
Cell: ({ renderedCellValue, row }) => (
|
||||||
<Group>
|
<Group wrap="nowrap" gap="sm">
|
||||||
{row.original.email ? renderedCellValue : <Text>-</Text>}
|
{row.original.email ? renderedCellValue : <Text>-</Text>}
|
||||||
{row.original.emailVerified && (
|
{row.original.emailVerified && (
|
||||||
<ThemeIcon radius="xl" size="sm">
|
<Tooltip label={t("user.field.email.verified")} position="top">
|
||||||
<IconCheck size="1rem" />
|
<IconCheck color="var(--mantine-color-green-4)" size="1rem" />
|
||||||
</ThemeIcon>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
@@ -55,7 +59,7 @@ export const UserListComponent = ({ initialUserList }: UserListComponentProps) =
|
|||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
@@ -66,11 +70,12 @@ export const UserListComponent = ({ initialUserList }: UserListComponentProps) =
|
|||||||
enableFullScreenToggle: false,
|
enableFullScreenToggle: false,
|
||||||
layoutMode: "grid-no-grow",
|
layoutMode: "grid-no-grow",
|
||||||
getRowId: (row) => row.id,
|
getRowId: (row) => row.id,
|
||||||
renderTopToolbarCustomActions: () => (
|
renderTopToolbarCustomActions: () =>
|
||||||
<Button component={Link} href="/manage/users/create">
|
credentialsProviderEnabled ? (
|
||||||
Create New User
|
<Button component={Link} href="/manage/users/create">
|
||||||
</Button>
|
{t("management.page.user.create.title")}
|
||||||
),
|
</Button>
|
||||||
|
) : null,
|
||||||
state: {
|
state: {
|
||||||
isLoading,
|
isLoading,
|
||||||
},
|
},
|
||||||
+6
-4
@@ -1,17 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Avatar, Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core";
|
import { Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core";
|
||||||
import { IconUserCheck } from "@tabler/icons-react";
|
import { IconUserCheck } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification } from "@homarr/notifications";
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
import { createCustomErrorParams } from "@homarr/validation/form";
|
import { createCustomErrorParams } from "@homarr/validation/form";
|
||||||
|
|
||||||
import { StepperNavigationComponent } from "./stepper-navigation.component";
|
import { StepperNavigationComponent } from "./stepper-navigation";
|
||||||
|
|
||||||
export const UserCreateStepperComponent = () => {
|
export const UserCreateStepperComponent = () => {
|
||||||
const t = useScopedI18n("management.page.user.create");
|
const t = useScopedI18n("management.page.user.create");
|
||||||
@@ -123,7 +124,8 @@ export const UserCreateStepperComponent = () => {
|
|||||||
<form>
|
<form>
|
||||||
<Card p="xl">
|
<Card p="xl">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<PasswordInput
|
<CustomPasswordInput
|
||||||
|
withPasswordRequirements
|
||||||
label={tUserField("password.label")}
|
label={tUserField("password.label")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
@@ -150,7 +152,7 @@ export const UserCreateStepperComponent = () => {
|
|||||||
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
|
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
|
||||||
<Card p="xl">
|
<Card p="xl">
|
||||||
<Stack maw={300} align="center" mx="auto">
|
<Stack maw={300} align="center" mx="auto">
|
||||||
<Avatar size="xl">{generalForm.values.username}</Avatar>
|
<UserAvatar size="xl" user={{ name: generalForm.values.username, image: null }} />
|
||||||
<Text tt="uppercase" fw="bolder" size="xl">
|
<Text tt="uppercase" fw="bolder" size="xl">
|
||||||
{generalForm.values.username}
|
{generalForm.values.username}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
File renamed without changes.
@@ -1,3 +1,7 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
@@ -5,6 +9,13 @@ import { createMetaTitle } from "~/metadata";
|
|||||||
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
|
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
|
if (!isProviderEnabled("credentials")) return {};
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
const t = await getScopedI18n("management.page.user.create");
|
const t = await getScopedI18n("management.page.user.create");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -12,7 +23,16 @@ export async function generateMetadata() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CreateUserPage() {
|
export default async function CreateUserPage() {
|
||||||
|
if (!isProviderEnabled("credentials")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user.permissions.includes("admin")) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface DeleteGroupProps {
|
interface DeleteGroupProps {
|
||||||
group: {
|
group: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ import { useCallback } from "react";
|
|||||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
|
|
||||||
interface RenameGroupFormProps {
|
interface RenameGroupFormProps {
|
||||||
group: {
|
group: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RenameGroupForm = ({ group }: RenameGroupFormProps) => {
|
export const RenameGroupForm = ({ group, disabled }: RenameGroupFormProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutate, isPending } = clientApi.group.updateGroup.useMutation();
|
const { mutate, isPending } = clientApi.group.updateGroup.useMutation();
|
||||||
const form = useZodForm(validation.group.update.pick({ name: true }), {
|
const form = useZodForm(validation.group.update.pick({ name: true }), {
|
||||||
@@ -29,6 +29,9 @@ export const RenameGroupForm = ({ group }: RenameGroupFormProps) => {
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(values: FormType) => {
|
(values: FormType) => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
mutate(
|
mutate(
|
||||||
{
|
{
|
||||||
...values,
|
...values,
|
||||||
@@ -61,13 +64,15 @@ export const RenameGroupForm = ({ group }: RenameGroupFormProps) => {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={t("group.field.name")} {...form.getInputProps("name")} />
|
<TextInput label={t("group.field.name")} {...form.getInputProps("name")} disabled={disabled} />
|
||||||
|
|
||||||
<Group justify="end">
|
{!disabled && (
|
||||||
<Button type="submit" color="teal" loading={isPending}>
|
<Group justify="end">
|
||||||
{t("common.action.saveChanges")}
|
<Button type="submit" color="teal" loading={isPending}>
|
||||||
</Button>
|
{t("common.action.saveChanges")}
|
||||||
</Group>
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Alert, Anchor } from "@mantine/core";
|
||||||
|
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
export const ReservedGroupAlert = async () => {
|
||||||
|
const t = await getI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||||
|
{t("group.reservedNotice.message", {
|
||||||
|
checkoutDocs: (
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
component={Link}
|
||||||
|
href={createDocumentationLink("/docs/management/users", "#special-groups")}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{t("common.action.checkoutDocs")}
|
||||||
|
</Anchor>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,7 +8,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
|
|||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal";
|
import { UserSelectModal } from "~/components/access/user-select-modal";
|
||||||
|
|
||||||
interface TransferGroupOwnershipProps {
|
interface TransferGroupOwnershipProps {
|
||||||
group: {
|
group: {
|
||||||
|
|||||||
Loaded 100 of 624 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user