mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-27 17:00:54 +01:00
chore(release): automatic release v0.1.0
This commit is contained in:
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -3,6 +3,7 @@
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"yoavbls.pretty-ts-errors",
|
||||
"million.million-lint"
|
||||
"million.million-lint",
|
||||
"lokalise.i18n-ally"
|
||||
]
|
||||
}
|
||||
|
||||
36
.vscode/i18n-ally-custom-framework.yml
vendored
Normal file
36
.vscode/i18n-ally-custom-framework.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# .vscode/i18n-ally-custom-framework.yml
|
||||
|
||||
# An array of strings which contain Language Ids defined by VS Code
|
||||
# You can check available language ids here: https://code.visualstudio.com/docs/languages/identifiers
|
||||
languageIds:
|
||||
- javascript
|
||||
- typescript
|
||||
- javascriptreact
|
||||
- typescriptreact
|
||||
|
||||
# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
|
||||
# You should unescape RegEx strings in order to fit in the YAML file
|
||||
# To help with this, you can use https://www.freeformatter.com/json-escape.html
|
||||
usageMatchRegex:
|
||||
# The following example shows how to detect `t("your.i18n.keys")`
|
||||
# the `{key}` will be placed by a proper keypath matching regex,
|
||||
# you can ignore it and use your own matching rules as well
|
||||
- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
|
||||
|
||||
# A RegEx to set a custom scope range. This scope will be used as a prefix when detecting keys
|
||||
# and works like how the i18next framework identifies the namespace scope from the
|
||||
# useTranslation() hook.
|
||||
# You should unescape RegEx strings in order to fit in the YAML file
|
||||
# To help with this, you can use https://www.freeformatter.com/json-escape.html
|
||||
scopeRangeRegex: "(getScopedI18n|useScopedI18n)\\(\\s*['\"](.*?)['\"]\\)"
|
||||
|
||||
# An array of strings containing refactor templates.
|
||||
# The "$1" will be replaced by the keypath specified.
|
||||
# Optional: uncomment the following two lines to use
|
||||
|
||||
# refactorTemplates:
|
||||
# - i18n.get("$1")
|
||||
|
||||
|
||||
# If set to true, only enables this custom framework (will disable all built-in frameworks)
|
||||
monopoly: true
|
||||
13
.vscode/settings.json
vendored
13
.vscode/settings.json
vendored
@@ -27,9 +27,12 @@
|
||||
"Umami"
|
||||
],
|
||||
"i18n-ally.dirStructure": "auto",
|
||||
"i18n-ally.enabledFrameworks": ["next-intl"],
|
||||
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
|
||||
"i18n-ally.enabledParsers": ["ts"],
|
||||
"i18n-ally.extract.keyMaxLength": 0,
|
||||
"i18n-ally.keystyle": "flat"
|
||||
"i18n-ally.displayLanguage": "en",
|
||||
"i18n-ally.enabledFrameworks": [
|
||||
"custom"
|
||||
],
|
||||
"i18n-ally.localesPaths": [
|
||||
"packages/translation/src/lang",
|
||||
],
|
||||
"i18n-ally.keystyle": "auto",
|
||||
}
|
||||
|
||||
@@ -37,17 +37,17 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "^7.14.0",
|
||||
"@mantine/core": "^7.14.0",
|
||||
"@mantine/hooks": "^7.14.0",
|
||||
"@mantine/modals": "^7.14.0",
|
||||
"@mantine/tiptap": "^7.14.0",
|
||||
"@mantine/colors-generator": "^7.14.1",
|
||||
"@mantine/core": "^7.14.1",
|
||||
"@mantine/hooks": "^7.14.1",
|
||||
"@mantine/modals": "^7.14.1",
|
||||
"@mantine/tiptap": "^7.14.1",
|
||||
"@million/lint": "1.0.12",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"@tanstack/react-query-devtools": "^5.60.5",
|
||||
"@tanstack/react-query-next-experimental": "5.60.5",
|
||||
"@tanstack/react-query": "^5.61.0",
|
||||
"@tanstack/react-query-devtools": "^5.61.0",
|
||||
"@tanstack/react-query-next-experimental": "5.61.0",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
@@ -61,7 +61,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"flag-icons": "^7.2.3",
|
||||
"glob": "^11.0.0",
|
||||
"jotai": "^2.10.2",
|
||||
"jotai": "^2.10.3",
|
||||
"mantine-react-table": "2.0.0-beta.7",
|
||||
"next": "^14.2.18",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
@@ -80,13 +80,13 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "2.4.4",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/node": "^22.9.1",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"concurrently": "^9.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"node-loader": "^2.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 497 B |
3
apps/nextjs/public/images/apps/imdb.svg
Normal file
3
apps/nextjs/public/images/apps/imdb.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83" width="575" height="289.83"><defs><path d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" id="d1pwhf9wy2"></path><path d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" id="g5jjnq26yS"></path><path d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" id="i3Prh1JpXt"></path><path d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" id="a4ov9rRGQm"></path><path d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" id="fk968BpsX"></path></defs><g><g><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill="#f6c700" fill-opacity="1"></use><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#g5jjnq26yS" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#g5jjnq26yS" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#fk968BpsX" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#fk968BpsX" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
4
apps/nextjs/public/images/apps/lastfm.svg
Normal file
4
apps/nextjs/public/images/apps/lastfm.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="708.767" height="179.332">
|
||||
<path fill="#d51007" d="m158.431 165.498-8.354-22.708s-13.575 15.14-33.932 15.14c-18.013 0-30.802-15.662-30.802-40.721 0-32.106 16.182-43.591 32.107-43.591 22.969 0 30.277 14.878 36.543 33.934l8.354 26.103c8.351 25.318 24.013 45.678 69.17 45.678 32.37 0 54.295-9.918 54.295-36.02 0-21.143-12.009-32.107-34.458-37.328l-16.705-3.654c-11.484-2.61-14.877-7.309-14.877-15.14 0-8.875 7.046-14.096 18.533-14.096 12.529 0 19.315 4.699 20.36 15.923l26.102-3.133c-2.088-23.492-18.271-33.15-44.896-33.15-23.491 0-46.462 8.875-46.462 37.327 0 17.75 8.614 28.975 30.277 34.195l17.752 4.175c13.312 3.133 17.748 8.614 17.748 16.185 0 9.656-9.396 13.572-27.146 13.572-26.364 0-37.325-13.834-43.591-32.89l-8.614-26.101c-10.961-33.934-28.452-46.463-63.169-46.463-38.37 0-58.731 24.275-58.731 65.517 0 39.677 20.361 61.08 56.906 61.08 29.492 0 43.59-13.834 43.59-13.834zM46.726 153.229c-2.61.784-5.221 1.306-8.614 1.306-6.265 0-10.703-2.87-10.703-10.442V1.827H0v148.792c0 19.577 13.575 27.672 29.497 27.672 5.221 0 10.181-.785 16.446-2.349l.783-22.713zm330.185-4.176c-6.787 4.701-12.529 7.051-20.36 7.051-9.92 0-15.401-5.221-15.401-18.012V77.006h36.023V55.603H341.41V26.625l-27.669 3.394v25.583h-17.49v21.403h17.49v66.826c0 24.02 13.834 35.5 36.284 35.5 12.269 0 23.232-2.346 31.847-7.305l-4.961-22.973zm23.807 9.396c0 10.705 8.354 19.318 19.056 19.318 11.226 0 19.578-8.613 19.578-19.318 0-10.963-8.353-19.313-19.578-19.313-10.702 0-19.056 8.35-19.056 19.313zm67.009-81.443v99.195h27.409V77.006h30.803V55.603h-30.803V44.638c0-16.444 7.049-21.665 18.534-21.665 8.092 0 13.574 1.825 19.839 5.221l4.437-22.974C530.638 1.827 522.023 0 511.582 0c-22.973 0-43.855 10.963-43.855 43.593v12.01h-17.489v21.403h17.489zm167.427 2.352c-3.133-19.578-15.923-26.629-32.63-26.629-16.706 0-31.062 7.571-37.329 26.104l-3.393-23.23h-22.188v120.598h27.409v-68.129c0-23.235 12.008-32.11 24.799-32.11 13.312 0 18.795 8.875 18.795 23.232V176.2h27.147v-68.39c0-22.974 12.269-31.849 25.061-31.849 13.052 0 18.532 8.875 18.532 23.232v77.006h27.409v-86.66c0-25.843-15.14-36.81-35.24-36.81-16.965 0-32.107 7.571-38.372 26.629z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 KiB |
1
apps/nextjs/public/images/apps/tmdb.svg
Normal file
1
apps/nextjs/public/images/apps/tmdb.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 190.24 81.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 2</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M105.67,36.06h66.9A17.67,17.67,0,0,0,190.24,18.4h0A17.67,17.67,0,0,0,172.57.73h-66.9A17.67,17.67,0,0,0,88,18.4h0A17.67,17.67,0,0,0,105.67,36.06Zm-88,45h76.9A17.67,17.67,0,0,0,112.24,63.4h0A17.67,17.67,0,0,0,94.57,45.73H17.67A17.67,17.67,0,0,0,0,63.4H0A17.67,17.67,0,0,0,17.67,81.06ZM10.41,35.42h7.8V6.92h10.1V0H.31v6.9h10.1Zm28.1,0h7.8V8.25h.1l9,27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2,23.1h-.1L50.31,0H38.51ZM152.43,55.67a15.07,15.07,0,0,0-4.52-5.52,18.57,18.57,0,0,0-6.68-3.08,33.54,33.54,0,0,0-8.07-1h-11.7v35.4h12.75a24.58,24.58,0,0,0,7.55-1.15A19.34,19.34,0,0,0,148.11,77a16.27,16.27,0,0,0,4.37-5.5,16.91,16.91,0,0,0,1.63-7.58A18.5,18.5,0,0,0,152.43,55.67ZM145,68.6A8.8,8.8,0,0,1,142.36,72a10.7,10.7,0,0,1-4,1.82,21.57,21.57,0,0,1-5,.55h-4.05v-21h4.6a17,17,0,0,1,4.67.63,11.66,11.66,0,0,1,3.88,1.87A9.14,9.14,0,0,1,145,59a9.87,9.87,0,0,1,1,4.52A11.89,11.89,0,0,1,145,68.6Zm44.63-.13a8,8,0,0,0-1.58-2.62A8.38,8.38,0,0,0,185.63,64a10.31,10.31,0,0,0-3.17-1v-.1a9.22,9.22,0,0,0,4.42-2.82,7.43,7.43,0,0,0,1.68-5,8.42,8.42,0,0,0-1.15-4.65,8.09,8.09,0,0,0-3-2.72,12.56,12.56,0,0,0-4.18-1.3,32.84,32.84,0,0,0-4.62-.33h-13.2v35.4h14.5a22.41,22.41,0,0,0,4.72-.5,13.53,13.53,0,0,0,4.28-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68A9.39,9.39,0,0,0,189.66,68.47ZM170.21,52.72h5.3a10,10,0,0,1,1.85.18,6.18,6.18,0,0,1,1.7.57,3.39,3.39,0,0,1,1.22,1.13,3.22,3.22,0,0,1,.48,1.82,3.63,3.63,0,0,1-.43,1.8,3.4,3.4,0,0,1-1.12,1.2,4.92,4.92,0,0,1-1.58.65,7.51,7.51,0,0,1-1.77.2h-5.65Zm11.72,20a3.9,3.9,0,0,1-1.22,1.3,4.64,4.64,0,0,1-1.68.7,8.18,8.18,0,0,1-1.82.2h-7v-8h5.9a15.35,15.35,0,0,1,2,.15,8.47,8.47,0,0,1,2.05.55,4,4,0,0,1,1.57,1.18,3.11,3.11,0,0,1,.63,2A3.71,3.71,0,0,1,181.93,72.72Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
47
apps/nextjs/public/images/apps/vgmdb.svg
Normal file
47
apps/nextjs/public/images/apps/vgmdb.svg
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="64mm"
|
||||
height="64mm"
|
||||
viewBox="0 0 64 64"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.2-alpha1 (b6a15bbbed, 2022-02-23)"
|
||||
sodipodi:docname="vgmdb.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="2.1089995"
|
||||
inkscape:cx="96.017091"
|
||||
inkscape:cy="132.29021"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
inkscape:window-x="1912"
|
||||
inkscape:window-y="456"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<path
|
||||
style="fill:#000000;stroke-width:0.21854"
|
||||
d="m 24.915135,62.650271 c 0.887879,-1.778597 2.041345,-3.66055 2.243584,-3.66055 0.373389,0 3.112623,2.34553 4.058216,3.474939 l 0.933036,1.114407 h -3.849247 -3.849246 z m 12.769897,0.133716 c 0.188983,-1.699691 0.540579,-9.438559 0.541952,-11.928786 l 0.0013,-2.561742 1.233546,-0.358603 c 1.858993,-0.540424 4.864719,-0.351614 6.306093,0.396126 2.102595,1.090761 3.373658,3.018391 3.382575,5.129841 0.0167,3.956206 -2.25508,7.722455 -5.608397,9.297806 -1.304436,0.612809 -1.766356,0.710219 -3.720191,0.784508 l -2.230831,0.08482 0.09384,-0.843972 z M 15.28167,58.692354 C 13.855849,58.372721 13.307133,58.098082 12.027965,57.063833 10.767623,56.044804 9.571224,54.160633 8.9251418,52.177293 8.4791686,50.808245 8.4074221,50.178438 8.4306037,47.836119 8.5286648,37.927793 11.975024,28.061437 18.601259,18.719217 c 1.935825,-2.729288 2.971061,-3.715223 4.909347,-4.675547 2.754154,-1.364545 6.425257,-1.245307 7.097695,0.230534 0.528157,1.159175 -0.351225,2.928281 -5.658254,11.38306 -3.70676,5.905347 -6.114927,9.18009 -8.52131,11.587702 l -1.747431,1.748322 0.748093,2.185403 c 1.052426,3.074452 2.737855,6.406887 3.730724,7.376398 l 0.831803,0.812233 1.792285,-1.012255 c 0.985756,-0.556742 3.87521,-2.58299 6.421008,-4.502779 l 4.628722,-3.490523 -0.152738,4.29269 c -0.08401,2.36098 -0.222472,4.480622 -0.3077,4.710314 -0.332428,0.895893 -8.050465,7.1666 -10.09734,8.203831 -2.185833,1.107645 -5.028822,1.564407 -6.994493,1.123754 z m 12.832756,-0.03877 c -0.369353,-0.206871 -0.546974,-0.44325 -0.453135,-0.603036 0.708434,-1.206309 4.008342,-5.172791 4.307161,-5.177197 0.225634,-0.0033 -0.01427,3.110825 -0.312679,4.058824 -0.487124,1.547514 -2.288476,2.42313 -3.541347,1.721409 z M 54.400377,47.270961 c -2.064993,-1.073365 -4.152108,-1.442529 -8.74161,-1.546198 -3.114822,-0.07036 -4.685696,-0.01346 -5.837847,0.211437 l -1.576311,0.307699 -0.101497,-0.893553 C 38.087288,44.858893 37.933571,42.73579 37.801516,40.63234 l -0.240099,-3.824455 0.497396,-0.409308 c 3.74237,-3.079615 11.651909,-7.818064 15.417757,-9.236475 2.486857,-0.936679 2.235049,-2.072647 2.235049,10.082864 0,5.829561 -0.07376,10.585246 -0.163906,10.568187 -0.09015,-0.01705 -0.606449,-0.261044 -1.147336,-0.542192 z M 23.409747,36.75325 c 0.981959,-3.570355 4.756318,-11.031164 6.826933,-13.49486 0.464503,-0.552684 0.586271,-0.604153 0.775553,-0.327811 0.553602,0.808232 1.251919,4.110924 1.621318,7.668016 l 0.126807,1.221076 -3.079512,1.795215 c -1.693731,0.987367 -3.814306,2.281815 -4.71239,2.876551 -0.898084,0.594738 -1.666906,1.081339 -1.708492,1.081339 -0.04159,0 0.02582,-0.368786 0.149783,-0.819526 z m 13.184647,-7.110817 c -0.04629,-0.145705 -0.341909,-1.543377 -0.656926,-3.10594 -0.315019,-1.562562 -0.864147,-3.967401 -1.220285,-5.344086 l -0.647526,-2.503063 1.150575,-1.176622 c 0.632814,-0.647143 2.643448,-2.301196 4.468071,-3.675673 3.438183,-2.589963 4.301443,-3.419286 4.80161,-4.6128516 0.714642,-1.7053778 -0.03157,-3.7643498 -1.67217,-4.613896 -0.898369,-0.4651992 -1.181912,-0.4997701 -3.278104,-0.3996832 -2.190425,0.1045861 -4.050576,0.523224 -8.85088,1.9919437 l -1.092702,0.334327 0.546351,-0.4412242 c 6.292255,-5.0815261 14.525967,-6.98424479 19.646997,-4.540202 2.438424,1.163752 4.050578,3.1575295 5.198539,6.4291158 l 0.605044,1.7243152 0.0047,7.3023343 0.0047,7.302333 -2.622483,0.481857 c -4.702423,0.864027 -9.663403,2.365904 -14.706862,4.452327 -1.118146,0.462565 -1.619586,0.580467 -1.67861,0.394688 z"
|
||||
id="path6625" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -17,7 +17,7 @@ import {
|
||||
import superjson from "superjson";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
|
||||
|
||||
import { env } from "~/env.mjs";
|
||||
|
||||
@@ -86,16 +86,13 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
return data;
|
||||
},
|
||||
},
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
url: getTrpcUrl(),
|
||||
headers: createHeadersCallbackForSource("nextjs-react (form-data)"),
|
||||
}),
|
||||
false: unstable_httpBatchStreamLink({
|
||||
transformer: superjson,
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
headers() {
|
||||
const headers = new Headers();
|
||||
headers.set("x-trpc-source", "nextjs-react");
|
||||
return headers;
|
||||
},
|
||||
url: getTrpcUrl(),
|
||||
headers: createHeadersCallbackForSource("nextjs-react (json)"),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -112,9 +109,3 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
</clientApi.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
@@ -11,6 +13,11 @@ interface AppEditPageProps {
|
||||
}
|
||||
|
||||
export default async function AppEditPage({ params }: AppEditPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("app-modify-all")) {
|
||||
notFound();
|
||||
}
|
||||
const app = await api.app.byId({ id: params.id });
|
||||
const t = await getI18n();
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppNewForm } from "./_app-new-form";
|
||||
|
||||
export default async function AppNewPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("app-create")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconApps, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
@@ -13,6 +15,12 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
export default async function AppsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const apps = await api.app.all();
|
||||
const t = await getScopedI18n("app");
|
||||
|
||||
@@ -22,9 +30,11 @@ export default async function AppsPage() {
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
{session.user.permissions.includes("app-create") && (
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
)}
|
||||
</Group>
|
||||
{apps.length === 0 && <AppNoResults />}
|
||||
{apps.length > 0 && (
|
||||
@@ -45,6 +55,7 @@ interface AppCardProps {
|
||||
|
||||
const AppCard = async ({ app }: AppCardProps) => {
|
||||
const t = await getScopedI18n("app");
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -78,16 +89,18 @@ const AppCard = async ({ app }: AppCardProps) => {
|
||||
</Group>
|
||||
<Group>
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title")}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<AppDeleteButton app={app} />
|
||||
{session?.user.permissions.includes("app-modify-all") && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/apps/edit/${app.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title")}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
|
||||
</ActionIconGroup>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -97,6 +110,7 @@ const AppCard = async ({ app }: AppCardProps) => {
|
||||
|
||||
const AppNoResults = async () => {
|
||||
const t = await getI18n();
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<Card withBorder bg="transparent">
|
||||
@@ -105,7 +119,9 @@ const AppNoResults = async () => {
|
||||
<Text fw={500} size="lg">
|
||||
{t("app.page.list.noResults.title")}
|
||||
</Text>
|
||||
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
|
||||
{session?.user.permissions.includes("app-create") && (
|
||||
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
|
||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
@@ -50,11 +51,16 @@ interface IntegrationsPageProps {
|
||||
}
|
||||
|
||||
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
||||
const integrations = await api.integration.all();
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const integrations = await api.integration.all();
|
||||
const t = await getScopedI18n("integration");
|
||||
|
||||
const canCreateIntegrations = session?.user.permissions.includes("integration-create") ?? false;
|
||||
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
|
||||
@@ -52,16 +52,19 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconBox,
|
||||
href: "/manage/apps",
|
||||
label: t("items.apps"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconPlug,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
href: "/manage/search-engines",
|
||||
label: t("items.searchEngies"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconPhoto,
|
||||
@@ -95,27 +98,32 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
{
|
||||
label: t("items.tools.label"),
|
||||
icon: IconTool,
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
// As permissions always include there children permissions, we can check other-view-logs as admin includes it
|
||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||
items: [
|
||||
{
|
||||
label: t("items.tools.items.docker"),
|
||||
icon: IconBrandDocker,
|
||||
href: "/manage/tools/docker",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.api"),
|
||||
icon: IconPlug,
|
||||
href: "/manage/tools/api",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.logs"),
|
||||
icon: IconLogs,
|
||||
href: "/manage/tools/logs",
|
||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||
},
|
||||
{
|
||||
label: t("items.tools.items.tasks"),
|
||||
icon: IconReport,
|
||||
href: "/manage/tools/tasks",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -47,7 +47,6 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||
const isAdmin = session.user.permissions.includes("admin");
|
||||
|
||||
return (
|
||||
<ManageContainer size="xl">
|
||||
@@ -57,10 +56,12 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||
{isAdmin && <IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />}
|
||||
{session.user.permissions.includes("media-view-all") && (
|
||||
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<UploadMedia />
|
||||
{session.user.permissions.includes("media-upload") && <UploadMedia />}
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
@@ -91,7 +92,10 @@ interface RowProps {
|
||||
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
const Row = ({ media }: RowProps) => {
|
||||
const Row = async ({ media }: RowProps) => {
|
||||
const session = await auth();
|
||||
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
|
||||
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd w={64}>
|
||||
@@ -120,7 +124,7 @@ const Row = ({ media }: RowProps) => {
|
||||
<TableTd w={64}>
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<CopyMedia media={media} />
|
||||
<DeleteMedia media={media} />
|
||||
{canDelete && <DeleteMedia media={media} />}
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
|
||||
@@ -64,6 +64,7 @@ export default async function ManagementPage() {
|
||||
href: "/manage/apps",
|
||||
subtitle: t("statisticLabel.resources"),
|
||||
title: t("statistic.app"),
|
||||
hidden: !session?.user,
|
||||
},
|
||||
{
|
||||
count: statistics.countGroups,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
@@ -12,6 +14,12 @@ interface SearchEngineEditPageProps {
|
||||
}
|
||||
|
||||
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("search-engine-modify-all")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const searchEngine = await api.searchEngine.byId({ id: params.id });
|
||||
const t = await getI18n();
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
@@ -7,6 +9,12 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { SearchEngineNewForm } from "./_search-engine-new-form";
|
||||
|
||||
export default async function SearchEngineNewPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("search-engine-create")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
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 { auth } from "@homarr/auth/next";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
@@ -28,6 +30,12 @@ interface SearchEnginesPageProps {
|
||||
}
|
||||
|
||||
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
||||
|
||||
@@ -40,9 +48,11 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
<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>
|
||||
{session.user.permissions.includes("search-engine-create") && (
|
||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||
{tEngine("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
)}
|
||||
</Group>
|
||||
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
||||
{searchEngines.length > 0 && (
|
||||
@@ -67,6 +77,7 @@ interface SearchEngineCardProps {
|
||||
|
||||
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||
const t = await getScopedI18n("search.engine");
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -105,16 +116,20 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||
</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} />
|
||||
{session?.user.permissions.includes("search-engine-modify-all") && (
|
||||
<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>
|
||||
)}
|
||||
{session?.user.permissions.includes("search-engine-full-all") && (
|
||||
<SearchEngineDeleteButton searchEngine={searchEngine} />
|
||||
)}
|
||||
</ActionIconGroup>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -124,6 +139,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||
|
||||
const SearchEngineNoResults = async () => {
|
||||
const t = await getI18n();
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<Card withBorder bg="transparent">
|
||||
@@ -132,7 +148,9 @@ const SearchEngineNoResults = async () => {
|
||||
<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>
|
||||
{session?.user.permissions.includes("search-engine-create") && (
|
||||
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
||||
const { mutate, isPending } = clientApi.apiKeys.create.useMutation({
|
||||
async onSuccess(data) {
|
||||
openModal({
|
||||
apiKey: data.randomToken,
|
||||
apiKey: data.apiKey,
|
||||
});
|
||||
await revalidatePathActionAsync("/manage/tools/api");
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function generateMetadata() {
|
||||
|
||||
export default async function LogsManagementPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user || !session.user.permissions.includes("admin")) {
|
||||
if (!session?.user || !session.user.permissions.includes("other-view-logs")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||
|
||||
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { NavigationLink } from "../groups/[id]/_navigation";
|
||||
import { canAccessUserEditPage } from "./access";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export const ReservedGroupAlert = async () => {
|
||||
const t = await getI18n();
|
||||
export const ReservedGroupAlert = () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { headers } from "next/headers";
|
||||
import { userAgent } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createOpenApiFetchHandler } from "trpc-swagger/build/index.mjs";
|
||||
|
||||
import { appRouter, createTRPCContext } from "@homarr/api";
|
||||
import { hashPasswordAsync } from "@homarr/auth";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createSessionAsync } from "@homarr/auth/server";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { apiKeys } from "@homarr/db/schema/sqlite";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
const handlerAsync = async (req: Request) => {
|
||||
const handlerAsync = async (req: NextRequest) => {
|
||||
const apiKeyHeaderValue = req.headers.get("ApiKey");
|
||||
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue);
|
||||
const ipAddress = req.ip ?? headers().get("x-forwarded-for");
|
||||
const { ua } = userAgent(req);
|
||||
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua);
|
||||
|
||||
return createOpenApiFetchHandler({
|
||||
req,
|
||||
@@ -19,7 +25,11 @@ const handlerAsync = async (req: Request) => {
|
||||
});
|
||||
};
|
||||
|
||||
const getSessionOrDefaultFromHeadersAsync = async (apiKeyHeaderValue: string | null): Promise<Session | null> => {
|
||||
const getSessionOrDefaultFromHeadersAsync = async (
|
||||
apiKeyHeaderValue: string | null,
|
||||
ipAdress: string | null,
|
||||
userAgent: string,
|
||||
): Promise<Session | null> => {
|
||||
logger.info(
|
||||
`Creating OpenAPI fetch handler for user ${apiKeyHeaderValue ? "with an api key" : "without an api key"}`,
|
||||
);
|
||||
@@ -28,12 +38,21 @@ const getSessionOrDefaultFromHeadersAsync = async (apiKeyHeaderValue: string | n
|
||||
return null;
|
||||
}
|
||||
|
||||
const [apiKeyId, apiKey] = apiKeyHeaderValue.split(".");
|
||||
|
||||
if (!apiKeyId || !apiKey) {
|
||||
logger.warn(
|
||||
`An attempt to authenticate over API has failed due to invalid API key format ip='${ipAdress}' userAgent='${userAgent}'`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiKeyFromDb = await db.query.apiKeys.findFirst({
|
||||
where: eq(apiKeys.apiKey, apiKeyHeaderValue),
|
||||
where: eq(apiKeys.id, apiKeyId),
|
||||
columns: {
|
||||
id: true,
|
||||
apiKey: false,
|
||||
salt: false,
|
||||
apiKey: true,
|
||||
salt: true,
|
||||
},
|
||||
with: {
|
||||
user: {
|
||||
@@ -47,8 +66,15 @@ const getSessionOrDefaultFromHeadersAsync = async (apiKeyHeaderValue: string | n
|
||||
},
|
||||
});
|
||||
|
||||
if (apiKeyFromDb === undefined) {
|
||||
logger.warn("An attempt to authenticate over API has failed");
|
||||
if (!apiKeyFromDb) {
|
||||
logger.warn(`An attempt to authenticate over API has failed ip='${ipAdress}' userAgent='${userAgent}'`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashedApiKey = await hashPasswordAsync(apiKey, apiKeyFromDb.salt);
|
||||
|
||||
if (apiKeyFromDb.apiKey !== hashedApiKey) {
|
||||
logger.warn(`An attempt to authenticate over API has failed ip='${ipAdress}' userAgent='${userAgent}'`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,15 +71,6 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
||||
? section.width
|
||||
: board.columnCount;
|
||||
|
||||
useCssVariableConfiguration({
|
||||
columnCount,
|
||||
gridRef,
|
||||
wrapperRef,
|
||||
width,
|
||||
height,
|
||||
isDynamic: section.kind === "dynamic",
|
||||
});
|
||||
|
||||
const itemRefKeys = Object.keys(itemRefs.current);
|
||||
// define items in itemRefs for easy access and reference to items
|
||||
if (itemRefKeys.length !== itemIds.length) {
|
||||
@@ -95,11 +86,6 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle the gridstack to be static or not based on the edit mode
|
||||
useEffect(() => {
|
||||
gridRef.current?.setStatic(!isEditMode);
|
||||
}, [isEditMode]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(changedNode: GridStackNode) => {
|
||||
const id = changedNode.el?.getAttribute("data-id");
|
||||
@@ -258,14 +244,40 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
||||
};
|
||||
}, [isEditMode, onAdd, onChange]);
|
||||
|
||||
/**
|
||||
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
|
||||
* because we need the gridstack object to add the listeners
|
||||
* Toggle the gridstack to be static or not based on the edit mode
|
||||
*/
|
||||
useEffect(() => {
|
||||
gridRef.current?.setStatic(!isEditMode);
|
||||
}, [isEditMode]);
|
||||
|
||||
const sectionHeight = section.kind === "dynamic" && "height" in section ? (section.height as number) : null;
|
||||
|
||||
// We want the amount of rows in a dynamic section to be the height of the section in the outer gridstack
|
||||
/**
|
||||
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
|
||||
* because we need the gridstack object to add the listeners
|
||||
* We want the amount of rows in a dynamic section to be the height of the section in the outer gridstack
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!sectionHeight) return;
|
||||
gridRef.current?.row(sectionHeight);
|
||||
}, [sectionHeight]);
|
||||
|
||||
/**
|
||||
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
|
||||
* because we need the gridstack object to add the listeners
|
||||
*/
|
||||
useCssVariableConfiguration({
|
||||
columnCount,
|
||||
gridRef,
|
||||
wrapperRef,
|
||||
width,
|
||||
height,
|
||||
isDynamic: section.kind === "dynamic",
|
||||
});
|
||||
|
||||
return {
|
||||
refs: {
|
||||
items: itemRefs,
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
import { Combobox, Group, Image, InputBase, Skeleton, Text, useCombobox } from "@mantine/core";
|
||||
import { startTransition, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Combobox,
|
||||
Flex,
|
||||
Image,
|
||||
Indicator,
|
||||
InputBase,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface IconPickerProps {
|
||||
initialValue?: string;
|
||||
@@ -18,10 +32,9 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
const [search, setSearch] = useState(initialValue ?? "");
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
|
||||
|
||||
const t = useI18n();
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const { data, isFetching } = clientApi.icon.findIcons.useQuery({
|
||||
const [data] = clientApi.icon.findIcons.useSuspenseQuery({
|
||||
searchText: search,
|
||||
});
|
||||
|
||||
@@ -29,39 +42,53 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
|
||||
const notNullableData = data?.icons ?? [];
|
||||
|
||||
const totalOptions = notNullableData.reduce((acc, group) => acc + group.icons.length, 0);
|
||||
|
||||
const groups = notNullableData.map((group) => {
|
||||
const totalOptions = data.icons.reduce((acc, group) => acc + group.icons.length, 0);
|
||||
const groups = data.icons.map((group) => {
|
||||
const options = group.icons.map((item) => (
|
||||
<Combobox.Option value={item.url} key={item.id}>
|
||||
<Group>
|
||||
<Image src={item.url} w={20} h={20} />
|
||||
<Text>{item.name}</Text>
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
const value = item.url;
|
||||
startTransition(() => {
|
||||
setValue(value);
|
||||
setPreviewUrl(value);
|
||||
setSearch(value);
|
||||
onChange(value);
|
||||
combobox.closeDropdown();
|
||||
});
|
||||
}}
|
||||
key={item.id}
|
||||
>
|
||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||
<Card
|
||||
p="sm"
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "visible",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Box w={25} h={25}>
|
||||
<Image src={item.url} w={25} h={25} radius="md" />
|
||||
</Box>
|
||||
</Card>
|
||||
</Indicator>
|
||||
</UnstyledButton>
|
||||
));
|
||||
|
||||
return (
|
||||
<Combobox.Group label={group.slug} key={group.id}>
|
||||
{options}
|
||||
</Combobox.Group>
|
||||
<Paper p="xs" key={group.slug} pt={2}>
|
||||
<Text mb={8} size="sm" fw="bold">
|
||||
{group.slug}
|
||||
</Text>
|
||||
<Flex gap={8} wrap={"wrap"}>
|
||||
{options}
|
||||
</Flex>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
onOptionSubmit={(value) => {
|
||||
setValue(value);
|
||||
setPreviewUrl(value);
|
||||
setSearch(value);
|
||||
onChange(value);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
store={combobox}
|
||||
withinPortal
|
||||
>
|
||||
<Combobox store={combobox} withinPortal>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
rightSection={<Combobox.Chevron />}
|
||||
@@ -91,18 +118,14 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
withAsterisk
|
||||
error={error}
|
||||
label={tCommon("iconPicker.label")}
|
||||
placeholder={tCommon("iconPicker.header", { countIcons: data.countIcons })}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Header>
|
||||
<Text c="dimmed">{tCommon("iconPicker.header", { countIcons: data?.countIcons })}</Text>
|
||||
</Combobox.Header>
|
||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||
{totalOptions > 0 ? (
|
||||
groups
|
||||
) : !isFetching ? (
|
||||
<Combobox.Empty>{t("search.nothingFound")}</Combobox.Empty>
|
||||
<Stack gap={4}>{groups}</Stack>
|
||||
) : (
|
||||
Array(15)
|
||||
.fill(0)
|
||||
|
||||
23
apps/nextjs/src/errors/trpc-catch-error.ts
Normal file
23
apps/nextjs/src/errors/trpc-catch-error.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import "server-only";
|
||||
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
export const catchTrpcNotFound = (err: unknown) => {
|
||||
if (err instanceof TRPCError && err.code === "NOT_FOUND") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw err;
|
||||
};
|
||||
|
||||
export const catchTrpcUnauthorized = (err: unknown) => {
|
||||
if (err instanceof TRPCError && err.code === "UNAUTHORIZED") {
|
||||
logger.info("Somebody tried to access a protected route without being authenticated, redirecting to login page");
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
throw err;
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const catchTrpcNotFound = (err: unknown) => {
|
||||
if (err instanceof TRPCError && err.code === "NOT_FOUND") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw err;
|
||||
};
|
||||
@@ -44,9 +44,9 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^9.14.0",
|
||||
"@types/node": "^22.9.1",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "^9.15.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
|
||||
99
apps/tasks/src/test/undici-log-agent-override.spec.ts
Normal file
99
apps/tasks/src/test/undici-log-agent-override.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Dispatcher } from "undici";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { LoggingAgent } from "~/undici-log-agent-override";
|
||||
|
||||
vi.mock("undici", () => {
|
||||
return {
|
||||
Agent: class Agent {
|
||||
dispatch(_options: Dispatcher.DispatchOptions, _handler: Dispatcher.DispatchHandlers): boolean {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
setGlobalDispatcher: () => undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const REDACTED = "REDACTED";
|
||||
|
||||
describe("LoggingAgent should log all requests", () => {
|
||||
test("should log all requests", () => {
|
||||
// Arrange
|
||||
const infoLogSpy = vi.spyOn(logger, "info");
|
||||
const agent = new LoggingAgent();
|
||||
|
||||
// Act
|
||||
agent.dispatch({ origin: "https://homarr.dev", path: "/", method: "GET" }, {});
|
||||
|
||||
// Assert
|
||||
expect(infoLogSpy).toHaveBeenCalledWith("Dispatching request https://homarr.dev/ (0 headers)");
|
||||
});
|
||||
|
||||
test("should show amount of headers", () => {
|
||||
// Arrange
|
||||
const infoLogSpy = vi.spyOn(logger, "info");
|
||||
const agent = new LoggingAgent();
|
||||
|
||||
// Act
|
||||
agent.dispatch(
|
||||
{
|
||||
origin: "https://homarr.dev",
|
||||
path: "/",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(infoLogSpy).toHaveBeenCalledWith(expect.stringContaining("(2 headers)"));
|
||||
});
|
||||
|
||||
test.each([
|
||||
["/?hex=a3815e8ada2ef9a31", `/?hex=${REDACTED}`],
|
||||
["/?uuid=f7c3f65e-c511-4f90-ba9a-3fd31418bd49", `/?uuid=${REDACTED}`],
|
||||
["/?password=complexPassword123", `/?password=${REDACTED}`],
|
||||
[
|
||||
// JWT for John Doe
|
||||
"/?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
|
||||
`/?jwt=${REDACTED}`,
|
||||
],
|
||||
["/?one=a1&two=b2&three=c3", `/?one=${REDACTED}&two=${REDACTED}&three=${REDACTED}`],
|
||||
["/?numberWith13Chars=1234567890123", `/?numberWith13Chars=${REDACTED}`],
|
||||
[`/?stringWith13Chars=${"a".repeat(13)}`, `/?stringWith13Chars=${REDACTED}`],
|
||||
])("should redact sensitive data in url https://homarr.dev%s", (path, expected) => {
|
||||
// Arrange
|
||||
const infoLogSpy = vi.spyOn(logger, "info");
|
||||
const agent = new LoggingAgent();
|
||||
|
||||
// Act
|
||||
agent.dispatch({ origin: "https://homarr.dev", path, method: "GET" }, {});
|
||||
|
||||
// Assert
|
||||
expect(infoLogSpy).toHaveBeenCalledWith(expect.stringContaining(` https://homarr.dev${expected} `));
|
||||
});
|
||||
test.each([
|
||||
["empty", "/?empty"],
|
||||
["numbers with max 12 chars", "/?number=123456789012"],
|
||||
["true", "/?true=true"],
|
||||
["false", "/?false=false"],
|
||||
["strings with max 12 chars", `/?short=${"a".repeat(12)}`],
|
||||
["dates", "/?date=2022-01-01"],
|
||||
["date times", "/?datetime=2022-01-01T00:00:00.000Z"],
|
||||
])("should not redact values that are %s", (_reason, path) => {
|
||||
// Arrange
|
||||
const infoLogSpy = vi.spyOn(logger, "info");
|
||||
const agent = new LoggingAgent();
|
||||
|
||||
// Act
|
||||
agent.dispatch({ origin: "https://homarr.dev", path, method: "GET" }, {});
|
||||
|
||||
// Assert
|
||||
expect(infoLogSpy).toHaveBeenCalledWith(expect.stringContaining(` https://homarr.dev${path} `));
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { Agent, setGlobalDispatcher } from "undici";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
class LoggingAgent extends Agent {
|
||||
export class LoggingAgent extends Agent {
|
||||
constructor(...props: ConstructorParameters<typeof Agent>) {
|
||||
super(...props);
|
||||
}
|
||||
@@ -15,15 +15,17 @@ class LoggingAgent extends Agent {
|
||||
// some integrations use query parameters for auth
|
||||
url.searchParams.forEach((value, key) => {
|
||||
if (value === "") return; // Skip empty values
|
||||
if (/^\d{1,12}$/.test(value)) return; // Skip small numbers
|
||||
if (/^-?\d{1,12}$/.test(value)) return; // Skip small numbers
|
||||
if (value === "true" || value === "false") return; // Skip boolean values
|
||||
if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
|
||||
|
||||
url.searchParams.set(key, "REDACTED");
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers as object).length} headers)`,
|
||||
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
|
||||
);
|
||||
return super.dispatch(options, handler);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -28,20 +28,20 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"devDependencies": {
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@turbo/gen": "^2.3.0",
|
||||
"@turbo/gen": "^2.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@vitest/coverage-v8": "^2.1.5",
|
||||
"@vitest/ui": "^2.1.5",
|
||||
"cross-env": "^7.0.3",
|
||||
"jsdom": "^25.0.1",
|
||||
"prettier": "^3.3.3",
|
||||
"testcontainers": "^10.14.0",
|
||||
"turbo": "^2.3.0",
|
||||
"testcontainers": "^10.15.0",
|
||||
"turbo": "^2.3.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-tsconfig-paths": "^5.1.2",
|
||||
"vite-tsconfig-paths": "^5.1.3",
|
||||
"vitest": "^2.1.5"
|
||||
},
|
||||
"packageManager": "pnpm@9.13.2",
|
||||
"packageManager": "pnpm@9.14.2",
|
||||
"engines": {
|
||||
"node": ">=22.11.0"
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/dockerode": "^3.3.31",
|
||||
"eslint": "^9.14.0",
|
||||
"@types/dockerode": "^3.3.32",
|
||||
"eslint": "^9.15.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
|
||||
@@ -7,13 +7,9 @@ export const clientApi = createTRPCReact<AppRouter>();
|
||||
export const fetchApi = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
url: getTrpcUrl(),
|
||||
transformer: SuperJSON,
|
||||
headers() {
|
||||
const headers = new Headers();
|
||||
headers.set("x-trpc-source", "fetch");
|
||||
return headers;
|
||||
},
|
||||
headers: createHeadersCallbackForSource("fetch"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -23,3 +19,50 @@ function getBaseUrl() {
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the full url for the trpc api endpoint
|
||||
* @returns
|
||||
*/
|
||||
export function getTrpcUrl() {
|
||||
return `${getBaseUrl()}/api/trpc`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a headers callback for a given source
|
||||
* It will set the x-trpc-source header and cookies if needed
|
||||
* @param source trpc source request comes from
|
||||
* @returns headers callback
|
||||
*/
|
||||
export function createHeadersCallbackForSource(source: string) {
|
||||
return async () => {
|
||||
const headers = new Headers();
|
||||
headers.set("x-trpc-source", source);
|
||||
|
||||
const cookies = await importCookiesAsync();
|
||||
// We need to set cookie for ssr requests (for example with useSuspenseQuery or middleware)
|
||||
if (cookies) {
|
||||
headers.set("cookie", cookies);
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a workarround as cookies are not passed to the server
|
||||
* when using useSuspenseQuery or middleware
|
||||
* @returns cookie string on server or null on client
|
||||
*/
|
||||
async function importCookiesAsync() {
|
||||
if (typeof window === "undefined") {
|
||||
return await import("next/headers").then(({ cookies }) =>
|
||||
cookies()
|
||||
.getAll()
|
||||
.map(({ name, value }) => `${name}=${value}`)
|
||||
.join(";"),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -28,14 +28,15 @@ export const apiKeysRouter = createTRPCRouter({
|
||||
const salt = await createSaltAsync();
|
||||
const randomToken = generateSecureRandomToken(64);
|
||||
const hashedRandomToken = await hashPasswordAsync(randomToken, salt);
|
||||
const id = createId();
|
||||
await db.insert(apiKeys).values({
|
||||
id: createId(),
|
||||
id,
|
||||
apiKey: hashedRandomToken,
|
||||
salt,
|
||||
userId: ctx.session.user.id,
|
||||
});
|
||||
return {
|
||||
randomToken,
|
||||
apiKey: `${id}.${randomToken}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,10 +4,11 @@ import { asc, createId, eq, inArray, like } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema/sqlite";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { canUserSeeAppAsync } from "./app/app-access-control";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
all: publicProcedure
|
||||
all: protectedProcedure
|
||||
.input(z.void())
|
||||
.output(
|
||||
z.array(
|
||||
@@ -26,7 +27,7 @@ export const appRouter = createTRPCRouter({
|
||||
orderBy: asc(apps.name),
|
||||
});
|
||||
}),
|
||||
search: publicProcedure
|
||||
search: protectedProcedure
|
||||
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||
.output(
|
||||
z.array(
|
||||
@@ -47,7 +48,7 @@ export const appRouter = createTRPCRouter({
|
||||
limit: input.limit,
|
||||
});
|
||||
}),
|
||||
selectable: publicProcedure
|
||||
selectable: protectedProcedure
|
||||
.input(z.void())
|
||||
.output(
|
||||
z.array(
|
||||
@@ -104,14 +105,23 @@ export const appRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const canUserSeeApp = await canUserSeeAppAsync(ctx.session?.user ?? null, app.id);
|
||||
if (!canUserSeeApp) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "App not found",
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}),
|
||||
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
|
||||
byIds: protectedProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.apps.findMany({
|
||||
where: inArray(apps.id, input),
|
||||
});
|
||||
}),
|
||||
create: protectedProcedure
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("app-create")
|
||||
.input(validation.app.manage)
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
|
||||
@@ -124,29 +134,33 @@ export const appRouter = createTRPCRouter({
|
||||
href: input.href,
|
||||
});
|
||||
}),
|
||||
update: protectedProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => {
|
||||
const app = await ctx.db.query.apps.findFirst({
|
||||
where: eq(apps.id, input.id),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "App not found",
|
||||
update: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
.input(validation.app.edit)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const app = await ctx.db.query.apps.findFirst({
|
||||
where: eq(apps.id, input.id),
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(apps)
|
||||
.set({
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
href: input.href,
|
||||
})
|
||||
.where(eq(apps.id, input.id));
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
if (!app) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "App not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(apps)
|
||||
.set({
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
href: input.href,
|
||||
})
|
||||
.where(eq(apps.id, input.id));
|
||||
}),
|
||||
delete: permissionRequiredProcedure
|
||||
.requiresPermission("app-full-all")
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
|
||||
.input(validation.common.byId)
|
||||
|
||||
50
packages/api/src/router/app/app-access-control.ts
Normal file
50
packages/api/src/router/app/app-access-control.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { db, eq, or } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema/sqlite";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../../widgets/src";
|
||||
|
||||
export const canUserSeeAppAsync = async (user: Session["user"] | null, appId: string) => {
|
||||
return await canUserSeeAppsAsync(user, [appId]);
|
||||
};
|
||||
|
||||
export const canUserSeeAppsAsync = async (user: Session["user"] | null, appIds: string[]) => {
|
||||
if (user) return true;
|
||||
|
||||
const appIdsOnPublicBoards = await getAllAppIdsOnPublicBoardsAsync();
|
||||
return appIds.every((appId) => appIdsOnPublicBoards.includes(appId));
|
||||
};
|
||||
|
||||
const getAllAppIdsOnPublicBoardsAsync = async () => {
|
||||
const itemsWithApps = await db.query.items.findMany({
|
||||
where: or(eq(items.kind, "app"), eq(items.kind, "bookmarks")),
|
||||
with: {
|
||||
section: {
|
||||
columns: {}, // Nothing
|
||||
with: {
|
||||
board: {
|
||||
columns: {
|
||||
isPublic: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return itemsWithApps
|
||||
.filter((item) => item.section.board.isPublic)
|
||||
.flatMap((item) => {
|
||||
if (item.kind === "app") {
|
||||
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"app">["options"]>(item.options);
|
||||
return [parsedOptions.appId];
|
||||
} else if (item.kind === "bookmarks") {
|
||||
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"bookmarks">["options"]>(item.options);
|
||||
return parsedOptions.items;
|
||||
}
|
||||
|
||||
throw new Error("Failed to get app ids from board. Invalid item kind: 'test'");
|
||||
});
|
||||
};
|
||||
@@ -16,7 +16,7 @@ export const iconsRouter = createTRPCRouter({
|
||||
url: true,
|
||||
},
|
||||
where: (input.searchText?.length ?? 0) > 0 ? like(icons.name, `%${input.searchText}%`) : undefined,
|
||||
limit: 5,
|
||||
limit: input.limitPerGroup,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -52,7 +52,6 @@ export const testConnectionAsync = async (
|
||||
|
||||
const { secrets: _, ...baseIntegration } = integration;
|
||||
|
||||
// @ts-expect-error - For now we expect an error here as not all integrations have been implemented
|
||||
const integrationInstance = integrationCreator({
|
||||
...baseIntegration,
|
||||
decryptedSecrets,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { loggingChannel } from "@homarr/redis";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
export const logRouter = createTRPCRouter({
|
||||
subscribe: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
|
||||
subscribe: permissionRequiredProcedure.requiresPermission("other-view-logs").subscription(() => {
|
||||
return observable<LoggerMessage>((emit) => {
|
||||
const unsubscribe = loggingChannel.subscribe((data) => {
|
||||
emit.next(data);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { and, createId, desc, eq, like } from "@homarr/db";
|
||||
import { medias } from "@homarr/db/schema/sqlite";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||
|
||||
export const mediaRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure
|
||||
@@ -14,7 +14,7 @@ export const mediaRouter = createTRPCRouter({
|
||||
),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const includeFromAllUsers = ctx.session.user.permissions.includes("admin") && input.includeFromAllUsers;
|
||||
const includeFromAllUsers = ctx.session.user.permissions.includes("media-view-all") && input.includeFromAllUsers;
|
||||
|
||||
const where = and(
|
||||
input.search.length >= 1 ? like(medias.name, `%${input.search}%`) : undefined,
|
||||
@@ -46,20 +46,23 @@ export const mediaRouter = createTRPCRouter({
|
||||
totalCount,
|
||||
};
|
||||
}),
|
||||
uploadMedia: protectedProcedure.input(validation.media.uploadMedia).mutation(async ({ ctx, input }) => {
|
||||
const content = Buffer.from(await input.file.arrayBuffer());
|
||||
const id = createId();
|
||||
await ctx.db.insert(medias).values({
|
||||
id,
|
||||
creatorId: ctx.session.user.id,
|
||||
content,
|
||||
size: input.file.size,
|
||||
contentType: input.file.type,
|
||||
name: input.file.name,
|
||||
});
|
||||
uploadMedia: permissionRequiredProcedure
|
||||
.requiresPermission("media-upload")
|
||||
.input(validation.media.uploadMedia)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const content = Buffer.from(await input.file.arrayBuffer());
|
||||
const id = createId();
|
||||
await ctx.db.insert(medias).values({
|
||||
id,
|
||||
creatorId: ctx.session.user.id,
|
||||
content,
|
||||
size: input.file.size,
|
||||
contentType: input.file.type,
|
||||
name: input.file.name,
|
||||
});
|
||||
|
||||
return id;
|
||||
}),
|
||||
return id;
|
||||
}),
|
||||
deleteMedia: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
|
||||
const dbMedia = await ctx.db.query.medias.findFirst({
|
||||
where: eq(medias.id, input.id),
|
||||
@@ -75,8 +78,8 @@ export const mediaRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Only allow admins and the creator of the media to delete it
|
||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== dbMedia.creatorId) {
|
||||
// Only allow users with media-full-all permission and the creator of the media to delete it
|
||||
if (!ctx.session.user.permissions.includes("media-full-all") && ctx.session.user.id !== dbMedia.creatorId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to delete this media",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createId, eq, like, sql } from "@homarr/db";
|
||||
import { searchEngines } from "@homarr/db/schema/sqlite";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||
|
||||
export const searchEngineRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
|
||||
@@ -59,43 +59,52 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
limit: input.limit,
|
||||
});
|
||||
}),
|
||||
create: protectedProcedure.input(validation.searchEngine.manage).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.insert(searchEngines).values({
|
||||
id: createId(),
|
||||
name: input.name,
|
||||
short: input.short.toLowerCase(),
|
||||
iconUrl: input.iconUrl,
|
||||
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
|
||||
description: input.description,
|
||||
type: input.type,
|
||||
integrationId: "integrationId" in input ? input.integrationId : null,
|
||||
});
|
||||
}),
|
||||
update: protectedProcedure.input(validation.searchEngine.edit).mutation(async ({ ctx, input }) => {
|
||||
const searchEngine = await ctx.db.query.searchEngines.findFirst({
|
||||
where: eq(searchEngines.id, input.id),
|
||||
});
|
||||
|
||||
if (!searchEngine) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Search engine not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(searchEngines)
|
||||
.set({
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("search-engine-create")
|
||||
.input(validation.searchEngine.manage)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.insert(searchEngines).values({
|
||||
id: createId(),
|
||||
name: input.name,
|
||||
short: input.short.toLowerCase(),
|
||||
iconUrl: input.iconUrl,
|
||||
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
|
||||
description: input.description,
|
||||
integrationId: "integrationId" in input ? input.integrationId : null,
|
||||
type: input.type,
|
||||
})
|
||||
.where(eq(searchEngines.id, input.id));
|
||||
}),
|
||||
delete: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id));
|
||||
}),
|
||||
integrationId: "integrationId" in input ? input.integrationId : null,
|
||||
});
|
||||
}),
|
||||
update: permissionRequiredProcedure
|
||||
.requiresPermission("search-engine-modify-all")
|
||||
.input(validation.searchEngine.edit)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const searchEngine = await ctx.db.query.searchEngines.findFirst({
|
||||
where: eq(searchEngines.id, input.id),
|
||||
});
|
||||
|
||||
if (!searchEngine) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Search engine not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(searchEngines)
|
||||
.set({
|
||||
name: input.name,
|
||||
iconUrl: input.iconUrl,
|
||||
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
|
||||
description: input.description,
|
||||
integrationId: "integrationId" in input ? input.integrationId : null,
|
||||
type: input.type,
|
||||
})
|
||||
.where(eq(searchEngines.id, input.id));
|
||||
}),
|
||||
delete: permissionRequiredProcedure
|
||||
.requiresPermission("search-engine-full-all")
|
||||
.input(validation.common.byId)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id));
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,23 +5,26 @@ import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
import { appRouter } from "../app";
|
||||
import * as appAccessControl from "../app/app-access-control";
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
|
||||
const defaultSession: Session = {
|
||||
user: { id: createId(), permissions: [], colorScheme: "light" },
|
||||
const createDefaultSession = (permissions: GroupPermissionKey[] = []): Session => ({
|
||||
user: { id: createId(), permissions, colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("all should return all apps", () => {
|
||||
test("should return all apps", async () => {
|
||||
test("should return all apps with session", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
await db.insert(apps).values([
|
||||
@@ -48,15 +51,30 @@ describe("all should return all apps", () => {
|
||||
expect(result[1]!.href).toBeNull();
|
||||
expect(result[1]!.description).toBeNull();
|
||||
});
|
||||
test("should throw UNAUTHORIZED if the user is not authenticated", async () => {
|
||||
// Arrange
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
session: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.all();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("byId should return an app by id", () => {
|
||||
test("should return an app by id", async () => {
|
||||
test("should return an app by id when canUserSeeAppAsync returns true", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
});
|
||||
vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(true));
|
||||
|
||||
await db.insert(apps).values([
|
||||
{
|
||||
@@ -73,28 +91,61 @@ describe("byId should return an app by id", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
const result = await caller.byId({ id: "2" });
|
||||
|
||||
// Assert
|
||||
expect(result.name).toBe("Mantine");
|
||||
});
|
||||
|
||||
test("should throw NOT_FOUND error when canUserSeeAppAsync returns false", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
});
|
||||
await db.insert(apps).values([
|
||||
{
|
||||
id: "2",
|
||||
name: "Mantine",
|
||||
description: "React components and hooks library",
|
||||
iconUrl: "https://mantine.dev/favicon.svg",
|
||||
href: "https://mantine.dev",
|
||||
},
|
||||
]);
|
||||
vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(false));
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.byId({ id: "2" });
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("App not found");
|
||||
});
|
||||
|
||||
test("should throw an error if the app does not exist", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.byId({ id: "2" });
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("App not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create should create a new app with all arguments", () => {
|
||||
test("should create a new app", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
session: createDefaultSession(["app-create"]),
|
||||
});
|
||||
const input = {
|
||||
name: "Mantine",
|
||||
@@ -103,8 +154,10 @@ describe("create should create a new app with all arguments", () => {
|
||||
href: "https://mantine.dev",
|
||||
};
|
||||
|
||||
// Act
|
||||
await caller.create(input);
|
||||
|
||||
// Assert
|
||||
const dbApp = await db.query.apps.findFirst();
|
||||
expect(dbApp).toBeDefined();
|
||||
expect(dbApp!.name).toBe(input.name);
|
||||
@@ -114,10 +167,11 @@ describe("create should create a new app with all arguments", () => {
|
||||
});
|
||||
|
||||
test("should create a new app only with required arguments", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
session: createDefaultSession(["app-create"]),
|
||||
});
|
||||
const input = {
|
||||
name: "Mantine",
|
||||
@@ -126,8 +180,10 @@ describe("create should create a new app with all arguments", () => {
|
||||
href: null,
|
||||
};
|
||||
|
||||
// Act
|
||||
await caller.create(input);
|
||||
|
||||
// Assert
|
||||
const dbApp = await db.query.apps.findFirst();
|
||||
expect(dbApp).toBeDefined();
|
||||
expect(dbApp!.name).toBe(input.name);
|
||||
@@ -139,10 +195,11 @@ describe("create should create a new app with all arguments", () => {
|
||||
|
||||
describe("update should update an app", () => {
|
||||
test("should update an app", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
});
|
||||
|
||||
const appId = createId();
|
||||
@@ -162,8 +219,10 @@ describe("update should update an app", () => {
|
||||
href: "https://mantine.dev",
|
||||
};
|
||||
|
||||
// Act
|
||||
await caller.update(input);
|
||||
|
||||
// Assert
|
||||
const dbApp = await db.query.apps.findFirst();
|
||||
|
||||
expect(dbApp).toBeDefined();
|
||||
@@ -174,12 +233,14 @@ describe("update should update an app", () => {
|
||||
});
|
||||
|
||||
test("should throw an error if the app does not exist", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.update({
|
||||
id: createId(),
|
||||
@@ -188,16 +249,19 @@ describe("update should update an app", () => {
|
||||
description: null,
|
||||
href: null,
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("App not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete should delete an app", () => {
|
||||
test("should delete an app", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
session: createDefaultSession(["app-full-all"]),
|
||||
});
|
||||
|
||||
const appId = createId();
|
||||
@@ -207,8 +271,10 @@ describe("delete should delete an app", () => {
|
||||
iconUrl: "https://mantine.dev/favicon.svg",
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.delete({ id: appId });
|
||||
|
||||
// Assert
|
||||
const dbApp = await db.query.apps.findFirst();
|
||||
expect(dbApp).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.37.3",
|
||||
"@auth/drizzle-adapter": "^1.7.3",
|
||||
"@auth/core": "^0.37.4",
|
||||
"@auth/drizzle-adapter": "^1.7.4",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
@@ -45,7 +45,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cookies": "0.9.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,13 +29,13 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "^14.2.18",
|
||||
"react": "^18.3.1",
|
||||
"tldts": "^6.1.61"
|
||||
"tldts": "^6.1.63"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,14 +49,17 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
||||
* We are not using the runOnInit method as we want to run the job only once we start the cron job schedule manually.
|
||||
* This allows us to always run it once we start it. Additionally it will not run the callback if only the cron job file is imported.
|
||||
*/
|
||||
const scheduledTask = cron.schedule(cronExpression, () => void catchingCallbackAsync(), {
|
||||
scheduled: false,
|
||||
name,
|
||||
timezone: creatorOptions.timezone,
|
||||
});
|
||||
creatorOptions.logger.logDebug(
|
||||
`The cron job '${name}' was created with expression ${cronExpression} in timezone ${creatorOptions.timezone} and runOnStart ${options.runOnStart}`,
|
||||
);
|
||||
let scheduledTask: cron.ScheduledTask | null = null;
|
||||
if (cronExpression !== "never") {
|
||||
scheduledTask = cron.schedule(cronExpression, () => void catchingCallbackAsync(), {
|
||||
scheduled: false,
|
||||
name,
|
||||
timezone: creatorOptions.timezone,
|
||||
});
|
||||
creatorOptions.logger.logDebug(
|
||||
`The cron job '${name}' was created with expression ${cronExpression} in timezone ${creatorOptions.timezone} and runOnStart ${options.runOnStart}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
@@ -90,7 +93,7 @@ export const createCronJobCreator = <TAllowedNames extends string = string>(
|
||||
options: CreateCronJobOptions = { runOnStart: false },
|
||||
) => {
|
||||
creatorOptions.logger.logDebug(`Validating cron expression '${cronExpression}' for job: ${name}`);
|
||||
if (!cron.validate(cronExpression)) {
|
||||
if (cronExpression !== "never" && !cron.validate(cronExpression)) {
|
||||
throw new Error(`Invalid cron expression '${cronExpression}' for job '${name}'`);
|
||||
}
|
||||
creatorOptions.logger.logDebug(`Cron job expression '${cronExpression}' for job ${name} is valid`);
|
||||
@@ -102,6 +105,8 @@ export const createCronJobCreator = <TAllowedNames extends string = string>(
|
||||
// This is a type guard to check if the cron expression is valid and give the user a type hint
|
||||
return returnValue as unknown as ValidateCron<TExpression> extends true
|
||||
? typeof returnValue
|
||||
: "Invalid cron expression";
|
||||
: TExpression extends "never"
|
||||
? typeof returnValue
|
||||
: "Invalid cron expression";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,3 +7,4 @@ export const EVERY_10_MINUTES = checkCron("*/10 * * * *") satisfies string;
|
||||
export const EVERY_HOUR = checkCron("0 * * * *") satisfies string;
|
||||
export const EVERY_DAY = checkCron("0 0 * * */1") satisfies string;
|
||||
export const EVERY_WEEK = checkCron("0 0 * * 1") satisfies string;
|
||||
export const NEVER = "never";
|
||||
|
||||
@@ -34,13 +34,13 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
|
||||
options.logger.logInfo(`Starting schedule cron job ${job.name}.`);
|
||||
await job.onStartAsync();
|
||||
job.scheduledTask.start();
|
||||
job.scheduledTask?.start();
|
||||
},
|
||||
startAllAsync: async () => {
|
||||
for (const job of jobRegistry.values()) {
|
||||
options.logger.logInfo(`Starting schedule of cron job ${job.name}.`);
|
||||
await job.onStartAsync();
|
||||
job.scheduledTask.start();
|
||||
job.scheduledTask?.start();
|
||||
}
|
||||
},
|
||||
runManually: (name: keyof TJobs) => {
|
||||
@@ -48,19 +48,19 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
|
||||
if (!job) return;
|
||||
|
||||
options.logger.logInfo(`Running schedule cron job ${job.name} manually.`);
|
||||
job.scheduledTask.now();
|
||||
job.scheduledTask?.now();
|
||||
},
|
||||
stop: (name: keyof TJobs) => {
|
||||
const job = jobRegistry.get(name as string);
|
||||
if (!job) return;
|
||||
|
||||
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
|
||||
job.scheduledTask.stop();
|
||||
job.scheduledTask?.stop();
|
||||
},
|
||||
stopAll: () => {
|
||||
for (const job of jobRegistry.values()) {
|
||||
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
|
||||
job.scheduledTask.stop();
|
||||
job.scheduledTask?.stop();
|
||||
}
|
||||
},
|
||||
getJobRegistry() {
|
||||
|
||||
@@ -24,10 +24,12 @@
|
||||
"dependencies": {
|
||||
"@extractus/feed-extractor": "^7.1.3",
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs-core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
@@ -41,7 +43,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import type { RssFeed } from "./jobs/rss-feeds";
|
||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
||||
import { sessionCleanupJob } from "./jobs/session-cleanup";
|
||||
import { createCronJobGroup } from "./lib";
|
||||
|
||||
export const jobGroup = createCronJobGroup({
|
||||
@@ -26,6 +27,7 @@ export const jobGroup = createCronJobGroup({
|
||||
rssFeeds: rssFeedsJob,
|
||||
indexerManager: indexerManagerJob,
|
||||
healthMonitoring: healthMonitoringJob,
|
||||
sessionCleanup: sessionCleanupJob,
|
||||
});
|
||||
|
||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db } from "@homarr/db";
|
||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
||||
@@ -26,9 +25,7 @@ export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).w
|
||||
const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate();
|
||||
|
||||
//Asserting the integration kind until all of them get implemented
|
||||
const integrationInstance = integrationCreatorFromSecrets(
|
||||
integration as Modify<typeof integration, { kind: "sonarr" | "radarr" }>,
|
||||
);
|
||||
const integrationInstance = integrationCreatorFromSecrets(integration);
|
||||
|
||||
const events = await integrationInstance.getCalendarEventsAsync(start, end);
|
||||
|
||||
|
||||
38
packages/cron-jobs/src/jobs/session-cleanup.ts
Normal file
38
packages/cron-jobs/src/jobs/session-cleanup.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { env } from "@homarr/auth/env.mjs";
|
||||
import { NEVER } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db, eq, inArray } from "@homarr/db";
|
||||
import { sessions, users } from "@homarr/db/schema/sqlite";
|
||||
import { supportedAuthProviders } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
/**
|
||||
* Deletes sessions for users that have inactive auth providers.
|
||||
* Sessions from other providers are deleted so they can no longer be used.
|
||||
*/
|
||||
export const sessionCleanupJob = createCronJob("sessionCleanup", NEVER, {
|
||||
runOnStart: true,
|
||||
}).withCallback(async () => {
|
||||
const currentAuthProviders = env.AUTH_PROVIDERS;
|
||||
|
||||
const inactiveAuthProviders = supportedAuthProviders.filter((provider) => !currentAuthProviders.includes(provider));
|
||||
const subQuery = db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(inArray(users.provider, inactiveAuthProviders))
|
||||
.as("sq");
|
||||
const sessionsWithInactiveProviders = await db
|
||||
.select({ userId: sessions.userId })
|
||||
.from(sessions)
|
||||
.rightJoin(subQuery, eq(sessions.userId, subQuery.id));
|
||||
|
||||
const userIds = sessionsWithInactiveProviders.map(({ userId }) => userId).filter((value) => value !== null);
|
||||
await db.delete(sessions).where(inArray(sessions.userId, userIds));
|
||||
|
||||
if (sessionsWithInactiveProviders.length > 0) {
|
||||
logger.info(`Deleted sessions for inactive providers count=${userIds.length}`);
|
||||
} else {
|
||||
logger.debug("No sessions to delete");
|
||||
}
|
||||
});
|
||||
@@ -35,26 +35,26 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.37.3",
|
||||
"@auth/core": "^0.37.4",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@testcontainers/mysql": "^10.14.0",
|
||||
"@testcontainers/mysql": "^10.15.0",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-kit": "^0.28.1",
|
||||
"drizzle-orm": "^0.36.3",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"mysql2": "3.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/better-sqlite3": "7.6.11",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^9.14.0",
|
||||
"@types/better-sqlite3": "7.6.12",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "^9.15.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,13 @@ export type IntegrationPermission = (typeof integrationPermissions)[number];
|
||||
* For example "board-create" is a generated key
|
||||
*/
|
||||
export const groupPermissions = {
|
||||
// Order is the same in the UI, inspired from order in navigation here
|
||||
board: ["create", "view-all", "modify-all", "full-all"],
|
||||
app: ["create", "use-all", "modify-all", "full-all"],
|
||||
integration: ["create", "use-all", "interact-all", "full-all"],
|
||||
"search-engine": ["create", "modify-all", "full-all"],
|
||||
media: ["upload", "view-all", "full-all"],
|
||||
other: ["view-logs"],
|
||||
admin: true,
|
||||
} as const;
|
||||
|
||||
@@ -49,9 +54,21 @@ export const groupPermissions = {
|
||||
const groupPermissionParents = {
|
||||
"board-modify-all": ["board-view-all"],
|
||||
"board-full-all": ["board-modify-all", "board-create"],
|
||||
"app-modify-all": ["app-create"],
|
||||
"app-full-all": ["app-modify-all", "app-use-all"],
|
||||
"integration-interact-all": ["integration-use-all"],
|
||||
"integration-full-all": ["integration-interact-all", "integration-create"],
|
||||
admin: ["board-full-all", "integration-full-all"],
|
||||
"search-engine-modify-all": ["search-engine-create"],
|
||||
"search-engine-full-all": ["search-engine-modify-all"],
|
||||
"media-full-all": ["media-upload", "media-view-all"],
|
||||
admin: [
|
||||
"board-full-all",
|
||||
"app-full-all",
|
||||
"integration-full-all",
|
||||
"search-engine-full-all",
|
||||
"media-full-all",
|
||||
"other-view-logs",
|
||||
],
|
||||
} satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey[]>>;
|
||||
|
||||
export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => {
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
"dependencies": {
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/form": "^7.14.0"
|
||||
"@mantine/form": "^7.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@ctrl/deluge": "^6.1.0",
|
||||
"@ctrl/qbittorrent": "^9.0.1",
|
||||
"@ctrl/deluge": "^7.0.0",
|
||||
"@ctrl/qbittorrent": "^9.1.0",
|
||||
"@ctrl/transmission": "^7.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
@@ -41,7 +41,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ import { TransmissionIntegration } from "../download-client/transmission/transmi
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
||||
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
|
||||
import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration";
|
||||
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
|
||||
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
|
||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||
@@ -64,4 +66,6 @@ export const integrationCreators = {
|
||||
overseerr: OverseerrIntegration,
|
||||
prowlarr: ProwlarrIntegration,
|
||||
openmediavault: OpenMediaVaultIntegration,
|
||||
} satisfies Partial<Record<IntegrationKind, new (integration: IntegrationInput) => Integration>>;
|
||||
lidarr: LidarrIntegration,
|
||||
readarr: ReadarrIntegration,
|
||||
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
|
||||
|
||||
@@ -16,6 +16,8 @@ export { OverseerrIntegration } from "./overseerr/overseerr-integration";
|
||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||
export { PlexIntegration } from "./plex/plex-integration";
|
||||
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
||||
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
|
||||
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
|
||||
|
||||
// Types
|
||||
export type { IntegrationInput } from "./base/integration";
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import type { CalendarEvent } from "../../calendar-types";
|
||||
import { MediaOrganizerIntegration } from "../media-organizer-integration";
|
||||
|
||||
export class LidarrIntegration extends MediaOrganizerIntegration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(`${this.integration.url}/api`, {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the events in the Lidarr calendar between two dates.
|
||||
* @param start The start date
|
||||
* @param end The end date
|
||||
* @param includeUnmonitored When true results will include unmonitored items of the Tadarr library.
|
||||
*/
|
||||
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> {
|
||||
const url = new URL(this.integration.url);
|
||||
url.pathname = "/api/v1/calendar";
|
||||
url.searchParams.append("start", start.toISOString());
|
||||
url.searchParams.append("end", end.toISOString());
|
||||
url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false");
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const lidarrCalendarEvents = await z.array(lidarrCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return lidarrCalendarEvents.map((lidarrCalendarEvent): CalendarEvent => {
|
||||
return {
|
||||
name: lidarrCalendarEvent.title,
|
||||
subName: lidarrCalendarEvent.artist.artistName,
|
||||
description: lidarrCalendarEvent.overview,
|
||||
thumbnail: this.chooseBestImageAsURL(lidarrCalendarEvent),
|
||||
date: lidarrCalendarEvent.releaseDate,
|
||||
mediaInformation: {
|
||||
type: "audio",
|
||||
},
|
||||
links: this.getLinksForLidarrCalendarEvent(lidarrCalendarEvent),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getLinksForLidarrCalendarEvent = (event: z.infer<typeof lidarrCalendarEventSchema>) => {
|
||||
const links: CalendarEvent["links"] = [];
|
||||
|
||||
for (const link of event.artist.links) {
|
||||
switch (link.name) {
|
||||
case "vgmdb":
|
||||
links.push({
|
||||
href: link.url,
|
||||
name: "VgmDB",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/vgmdb.svg",
|
||||
notificationColor: "cyan",
|
||||
});
|
||||
break;
|
||||
case "imdb":
|
||||
links.push({
|
||||
href: link.url,
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.png",
|
||||
notificationColor: "cyan",
|
||||
});
|
||||
break;
|
||||
case "last":
|
||||
links.push({
|
||||
href: link.url,
|
||||
name: "LastFM",
|
||||
color: "#cf222a",
|
||||
isDark: false,
|
||||
logo: "/images/apps/lastfm.svg",
|
||||
notificationColor: "cyan",
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
};
|
||||
|
||||
private chooseBestImage = (
|
||||
event: z.infer<typeof lidarrCalendarEventSchema>,
|
||||
): z.infer<typeof lidarrCalendarEventSchema>["images"][number] | undefined => {
|
||||
const flatImages = [...event.images];
|
||||
|
||||
const sortedImages = flatImages.sort(
|
||||
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType),
|
||||
);
|
||||
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
|
||||
return sortedImages[0];
|
||||
};
|
||||
|
||||
private chooseBestImageAsURL = (event: z.infer<typeof lidarrCalendarEventSchema>): string | undefined => {
|
||||
const bestImage = this.chooseBestImage(event);
|
||||
if (!bestImage) {
|
||||
return undefined;
|
||||
}
|
||||
return bestImage.remoteUrl;
|
||||
};
|
||||
}
|
||||
|
||||
const lidarrCalendarEventImageSchema = z.array(
|
||||
z.object({
|
||||
coverType: z.enum(["screenshot", "poster", "banner", "fanart", "clearlogo", "cover"]),
|
||||
remoteUrl: z.string().url(),
|
||||
}),
|
||||
);
|
||||
|
||||
const lidarrCalendarEventSchema = z.object({
|
||||
title: z.string(),
|
||||
overview: z.string().optional(),
|
||||
images: lidarrCalendarEventImageSchema,
|
||||
artist: z.object({ links: z.array(z.object({ url: z.string().url(), name: z.string() })), artistName: z.string() }),
|
||||
releaseDate: z.string().transform((value) => new Date(value)),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Integration } from "../base/integration";
|
||||
|
||||
export abstract class MediaOrganizerIntegration extends Integration {
|
||||
/**
|
||||
* Priority list that determines the quality of images using their order.
|
||||
* Types at the start of the list are better than those at the end.
|
||||
* We do this to attempt to find the best quality image for the show.
|
||||
*/
|
||||
protected readonly priorities: string[] = [
|
||||
"cover", // Official, perfect aspect ratio
|
||||
"poster", // Official, perfect aspect ratio
|
||||
"banner", // Official, bad aspect ratio
|
||||
"fanart", // Unofficial, possibly bad quality
|
||||
"screenshot", // Bad aspect ratio, possibly bad quality
|
||||
"clearlogo", // Without background, bad aspect ratio
|
||||
];
|
||||
}
|
||||
@@ -2,24 +2,11 @@ import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { CalendarEvent } from "../../calendar-types";
|
||||
import { radarrReleaseTypes } from "../../calendar-types";
|
||||
import { MediaOrganizerIntegration } from "../media-organizer-integration";
|
||||
|
||||
export class RadarrIntegration extends Integration {
|
||||
/**
|
||||
* Priority list that determines the quality of images using their order.
|
||||
* Types at the start of the list are better than those at the end.
|
||||
* We do this to attempt to find the best quality image for the show.
|
||||
*/
|
||||
private readonly priorities: z.infer<typeof radarrCalendarEventSchema>["images"][number]["coverType"][] = [
|
||||
"poster", // Official, perfect aspect ratio
|
||||
"banner", // Official, bad aspect ratio
|
||||
"fanart", // Unofficial, possibly bad quality
|
||||
"screenshot", // Bad aspect ratio, possibly bad quality
|
||||
"clearlogo", // Without background, bad aspect ratio
|
||||
];
|
||||
|
||||
export class RadarrIntegration extends MediaOrganizerIntegration {
|
||||
/**
|
||||
* Gets the events in the Radarr calendar between two dates.
|
||||
* @param start The start date
|
||||
@@ -76,7 +63,7 @@ export class RadarrIntegration extends Integration {
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.png",
|
||||
logo: "/images/apps/imdb.svg",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import type { CalendarEvent } from "../../calendar-types";
|
||||
import { MediaOrganizerIntegration } from "../media-organizer-integration";
|
||||
|
||||
export class ReadarrIntegration extends MediaOrganizerIntegration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(`${this.integration.url}/api`, {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the events in the Lidarr calendar between two dates.
|
||||
* @param start The start date
|
||||
* @param end The end date
|
||||
* @param includeUnmonitored When true results will include unmonitored items of the Tadarr library.
|
||||
*/
|
||||
async getCalendarEventsAsync(
|
||||
start: Date,
|
||||
end: Date,
|
||||
includeUnmonitored = true,
|
||||
includeAuthor = true,
|
||||
): Promise<CalendarEvent[]> {
|
||||
const url = new URL(this.integration.url);
|
||||
url.pathname = "/api/v1/calendar";
|
||||
url.searchParams.append("start", start.toISOString());
|
||||
url.searchParams.append("end", end.toISOString());
|
||||
url.searchParams.append("unmonitored", includeUnmonitored.toString());
|
||||
url.searchParams.append("includeAuthor", includeAuthor.toString());
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const readarrCalendarEvents = await z.array(readarrCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return readarrCalendarEvents.map((readarrCalendarEvent): CalendarEvent => {
|
||||
return {
|
||||
name: readarrCalendarEvent.title,
|
||||
subName: readarrCalendarEvent.author.authorName,
|
||||
description: readarrCalendarEvent.overview,
|
||||
thumbnail: this.chooseBestImageAsURL(readarrCalendarEvent),
|
||||
date: readarrCalendarEvent.releaseDate,
|
||||
mediaInformation: {
|
||||
type: "audio",
|
||||
},
|
||||
links: this.getLinksForReadarrCalendarEvent(readarrCalendarEvent),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getLinksForReadarrCalendarEvent = (event: z.infer<typeof readarrCalendarEventSchema>) => {
|
||||
return [
|
||||
{
|
||||
href: `${this.integration.url}/author/${event.author.foreignAuthorId}`,
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/readarr.svg",
|
||||
name: "Readarr",
|
||||
notificationColor: "#f5c518",
|
||||
},
|
||||
] satisfies CalendarEvent["links"];
|
||||
};
|
||||
|
||||
private chooseBestImage = (
|
||||
event: z.infer<typeof readarrCalendarEventSchema>,
|
||||
): z.infer<typeof readarrCalendarEventSchema>["images"][number] | undefined => {
|
||||
const flatImages = [...event.images];
|
||||
|
||||
const sortedImages = flatImages.sort(
|
||||
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType),
|
||||
);
|
||||
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
|
||||
return sortedImages[0];
|
||||
};
|
||||
|
||||
private chooseBestImageAsURL = (event: z.infer<typeof readarrCalendarEventSchema>): string | undefined => {
|
||||
const bestImage = this.chooseBestImage(event);
|
||||
if (!bestImage) {
|
||||
return undefined;
|
||||
}
|
||||
return `${this.integration.url}${bestImage.url}`;
|
||||
};
|
||||
}
|
||||
|
||||
const readarrCalendarEventImageSchema = z.array(
|
||||
z.object({
|
||||
coverType: z.enum(["screenshot", "poster", "banner", "fanart", "clearlogo", "cover"]),
|
||||
url: z.string().transform((url) => url.replace(/\?lastWrite=[0-9]+/, "")), // returns a random string, needs to be removed for loading the image
|
||||
}),
|
||||
);
|
||||
|
||||
const readarrCalendarEventSchema = z.object({
|
||||
title: z.string(),
|
||||
overview: z.string().optional(),
|
||||
images: readarrCalendarEventImageSchema,
|
||||
links: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
),
|
||||
author: z.object({
|
||||
authorName: z.string(),
|
||||
foreignAuthorId: z.string(),
|
||||
}),
|
||||
releaseDate: z.string().transform((value) => new Date(value)),
|
||||
});
|
||||
@@ -1,23 +1,10 @@
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { CalendarEvent } from "../../calendar-types";
|
||||
import { MediaOrganizerIntegration } from "../media-organizer-integration";
|
||||
|
||||
export class SonarrIntegration extends Integration {
|
||||
/**
|
||||
* Priority list that determines the quality of images using their order.
|
||||
* Types at the start of the list are better than those at the end.
|
||||
* We do this to attempt to find the best quality image for the show.
|
||||
*/
|
||||
private readonly priorities: z.infer<typeof sonarrCalendarEventSchema>["images"][number]["coverType"][] = [
|
||||
"poster", // Official, perfect aspect ratio
|
||||
"banner", // Official, bad aspect ratio
|
||||
"fanart", // Unofficial, possibly bad quality
|
||||
"screenshot", // Bad aspect ratio, possibly bad quality
|
||||
"clearlogo", // Without background, bad aspect ratio
|
||||
];
|
||||
|
||||
export class SonarrIntegration extends MediaOrganizerIntegration {
|
||||
/**
|
||||
* Gets the events in the Sonarr calendar between two dates.
|
||||
* @param start The start date
|
||||
@@ -75,7 +62,7 @@ export class SonarrIntegration extends Integration {
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.png",
|
||||
logo: "/images/apps/imdb.svg",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.14.0",
|
||||
"@mantine/core": "^7.14.1",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "^14.2.18",
|
||||
@@ -40,7 +40,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
|
||||
@@ -24,15 +24,15 @@
|
||||
"dependencies": {
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.14.0",
|
||||
"@mantine/hooks": "^7.14.0",
|
||||
"@mantine/core": "^7.14.1",
|
||||
"@mantine/hooks": "^7.14.1",
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,14 +24,14 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@mantine/notifications": "^7.14.0",
|
||||
"@mantine/notifications": "^7.14.1",
|
||||
"@tabler/icons-react": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
|
||||
@@ -4,27 +4,24 @@ import { tileBaseSchema } from "./tile";
|
||||
|
||||
const appBehaviourSchema = z.object({
|
||||
externalUrl: z.string(),
|
||||
isOpeningNewTab: z.boolean(),
|
||||
tooltipDescription: z.string().optional(),
|
||||
isOpeningNewTab: z.boolean().catch(true),
|
||||
tooltipDescription: z.string().optional().catch(undefined),
|
||||
});
|
||||
|
||||
const appNetworkSchema = z.object({
|
||||
enabledStatusChecker: z.boolean(),
|
||||
okStatus: z.array(z.number()).optional(),
|
||||
statusCodes: z.array(z.string()),
|
||||
enabledStatusChecker: z.boolean().catch(true),
|
||||
okStatus: z.array(z.number()).optional().catch([]),
|
||||
statusCodes: z.array(z.string()).catch([]),
|
||||
});
|
||||
|
||||
const appAppearanceSchema = z.object({
|
||||
iconUrl: z.string(),
|
||||
appNameStatus: z.union([z.literal("normal"), z.literal("hover"), z.literal("hidden")]),
|
||||
positionAppName: z.union([
|
||||
z.literal("row"),
|
||||
z.literal("column"),
|
||||
z.literal("row-reverse"),
|
||||
z.literal("column-reverse"),
|
||||
]),
|
||||
appNameFontSize: z.number(),
|
||||
lineClampAppName: z.number(),
|
||||
appNameStatus: z.union([z.literal("normal"), z.literal("hover"), z.literal("hidden")]).catch("normal"),
|
||||
positionAppName: z
|
||||
.union([z.literal("row"), z.literal("column"), z.literal("row-reverse"), z.literal("column-reverse")])
|
||||
.catch("column"),
|
||||
appNameFontSize: z.number().catch(16),
|
||||
lineClampAppName: z.number().catch(1),
|
||||
});
|
||||
|
||||
const integrationSchema = z.enum([
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.14.0",
|
||||
"@mantine/hooks": "^7.14.0",
|
||||
"@mantine/spotlight": "^7.14.0",
|
||||
"@mantine/core": "^7.14.1",
|
||||
"@mantine/hooks": "^7.14.1",
|
||||
"@mantine/spotlight": "^7.14.1",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"jotai": "^2.10.2",
|
||||
"jotai": "^2.10.3",
|
||||
"next": "^14.2.18",
|
||||
"react": "^18.3.1",
|
||||
"use-deep-compare-effect": "^1.8.1"
|
||||
@@ -44,7 +44,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
||||
@@ -9,23 +10,42 @@ import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||
import type { SearchMode } from "../lib/mode";
|
||||
import { searchModes } from "../modes";
|
||||
import { selectAction, spotlightStore } from "../spotlight-store";
|
||||
import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||
|
||||
type SearchModeKey = keyof TranslationObject["search"]["mode"];
|
||||
|
||||
export const Spotlight = () => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [mode, setMode] = useState<keyof TranslationObject["search"]["mode"]>("help");
|
||||
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
||||
const t = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchModeState = useState<SearchModeKey>("help");
|
||||
const mode = searchModeState[0];
|
||||
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
|
||||
|
||||
if (!activeMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We use the "key" below to prevent the 'Different amounts of hooks' error
|
||||
return <SpotlightWithActiveMode key={mode} modeState={searchModeState} activeMode={activeMode} />;
|
||||
};
|
||||
|
||||
interface SpotlightWithActiveModeProps {
|
||||
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
|
||||
activeMode: SearchMode;
|
||||
}
|
||||
|
||||
const SpotlightWithActiveMode = ({ modeState, activeMode }: SpotlightWithActiveModeProps) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [mode, setMode] = modeState;
|
||||
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
||||
const t = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Works as always the same amount of hooks are executed
|
||||
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
||||
const groups = useGroups();
|
||||
|
||||
return (
|
||||
<MantineSpotlight.Root
|
||||
yOffset={8}
|
||||
@@ -115,7 +135,7 @@ export const Spotlight = () => {
|
||||
});
|
||||
}}
|
||||
query={query}
|
||||
groups={activeMode.groups}
|
||||
groups={groups}
|
||||
/>
|
||||
)}
|
||||
</MantineSpotlight.ActionsList>
|
||||
|
||||
@@ -2,8 +2,14 @@ import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
import type { SearchGroup } from "./group";
|
||||
|
||||
export interface SearchMode {
|
||||
export type SearchMode = {
|
||||
modeKey: keyof TranslationObject["search"]["mode"];
|
||||
character: string;
|
||||
groups: SearchGroup[];
|
||||
}
|
||||
} & (
|
||||
| {
|
||||
groups: SearchGroup[];
|
||||
}
|
||||
| {
|
||||
useGroups: () => SearchGroup[];
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
|
||||
import type { SearchGroup } from "../../lib/group";
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { appsSearchGroup } from "./apps-search-group";
|
||||
import { boardsSearchGroup } from "./boards-search-group";
|
||||
@@ -6,5 +9,14 @@ import { integrationsSearchGroup } from "./integrations-search-group";
|
||||
export const appIntegrationBoardMode = {
|
||||
modeKey: "appIntegrationBoard",
|
||||
character: "#",
|
||||
groups: [appsSearchGroup, integrationsSearchGroup, boardsSearchGroup],
|
||||
useGroups() {
|
||||
const { data: session } = useSession();
|
||||
const groups: SearchGroup[] = [boardsSearchGroup];
|
||||
|
||||
if (!session?.user) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
return groups.concat([appsSearchGroup, integrationsSearchGroup]);
|
||||
},
|
||||
} satisfies SearchMode;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Group, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import {
|
||||
IconBox,
|
||||
IconCategoryPlus,
|
||||
IconFileImport,
|
||||
IconLanguage,
|
||||
IconMailForward,
|
||||
IconMoon,
|
||||
IconPackage,
|
||||
IconPlug,
|
||||
IconSun,
|
||||
IconUserPlus,
|
||||
@@ -113,9 +113,10 @@ export const commandMode = {
|
||||
},
|
||||
{
|
||||
commandKey: "newApp",
|
||||
icon: IconPackage,
|
||||
icon: IconBox,
|
||||
name: tOption("newApp.label"),
|
||||
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
},
|
||||
{
|
||||
commandKey: "newIntegration",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Group, Kbd, Text } from "@mantine/core";
|
||||
import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -18,58 +19,67 @@ const searchModesWithoutHelp = [userGroupMode, appIntegrationBoardMode, external
|
||||
const helpMode = {
|
||||
modeKey: "help",
|
||||
character: "?",
|
||||
groups: [
|
||||
createGroup({
|
||||
keyPath: "character",
|
||||
title: (t) => t("search.mode.help.group.mode.title"),
|
||||
options: searchModesWithoutHelp.map(({ character, modeKey }) => ({ character, modeKey })),
|
||||
Component: ({ modeKey, character }) => {
|
||||
const t = useScopedI18n(`search.mode.${modeKey}`);
|
||||
useGroups() {
|
||||
const { data: session } = useSession();
|
||||
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode];
|
||||
|
||||
return (
|
||||
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
|
||||
<Text>{t("help")}</Text>
|
||||
<Kbd size="sm">{character}</Kbd>
|
||||
if (session?.user.permissions.includes("admin")) {
|
||||
visibleSearchModes.unshift(userGroupMode);
|
||||
}
|
||||
|
||||
return [
|
||||
createGroup({
|
||||
keyPath: "character",
|
||||
title: (t) => t("search.mode.help.group.mode.title"),
|
||||
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
|
||||
Component: ({ modeKey, character }) => {
|
||||
const t = useScopedI18n(`search.mode.${modeKey}`);
|
||||
|
||||
return (
|
||||
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
|
||||
<Text>{t("help")}</Text>
|
||||
<Kbd size="sm">{character}</Kbd>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
filter: () => true,
|
||||
useInteraction: interaction.mode(({ modeKey }) => ({ mode: modeKey })),
|
||||
}),
|
||||
createGroup({
|
||||
keyPath: "href",
|
||||
title: (t) => t("search.mode.help.group.help.title"),
|
||||
useOptions() {
|
||||
const t = useScopedI18n("search.mode.help.group.help.option");
|
||||
|
||||
return [
|
||||
{
|
||||
label: t("documentation.label"),
|
||||
icon: IconBook2,
|
||||
href: createDocumentationLink("/docs/getting-started"),
|
||||
},
|
||||
{
|
||||
label: t("submitIssue.label"),
|
||||
icon: IconBrandGithub,
|
||||
href: "https://github.com/ajnart/homarr/issues/new/choose",
|
||||
},
|
||||
{
|
||||
label: t("discord.label"),
|
||||
icon: IconBrandDiscord,
|
||||
href: "https://discord.com/invite/aCsmEV5RgA",
|
||||
},
|
||||
];
|
||||
},
|
||||
Component: (props) => (
|
||||
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
|
||||
<props.icon />
|
||||
<Text>{props.label}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
filter: () => true,
|
||||
useInteraction: interaction.mode(({ modeKey }) => ({ mode: modeKey })),
|
||||
}),
|
||||
createGroup({
|
||||
keyPath: "href",
|
||||
title: (t) => t("search.mode.help.group.help.title"),
|
||||
useOptions() {
|
||||
const t = useScopedI18n("search.mode.help.group.help.option");
|
||||
|
||||
return [
|
||||
{
|
||||
label: t("documentation.label"),
|
||||
icon: IconBook2,
|
||||
href: createDocumentationLink("/docs/getting-started"),
|
||||
},
|
||||
{
|
||||
label: t("submitIssue.label"),
|
||||
icon: IconBrandGithub,
|
||||
href: "https://github.com/ajnart/homarr/issues/new/choose",
|
||||
},
|
||||
{
|
||||
label: t("discord.label"),
|
||||
icon: IconBrandDiscord,
|
||||
href: "https://discord.com/invite/aCsmEV5RgA",
|
||||
},
|
||||
];
|
||||
},
|
||||
Component: (props) => (
|
||||
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
|
||||
<props.icon />
|
||||
<Text>{props.label}</Text>
|
||||
</Group>
|
||||
),
|
||||
filter: () => true,
|
||||
useInteraction: interaction.link(({ href }) => ({ href, newTab: true })),
|
||||
}),
|
||||
],
|
||||
),
|
||||
filter: () => true,
|
||||
useInteraction: interaction.link(({ href }) => ({ href, newTab: true })),
|
||||
}),
|
||||
];
|
||||
},
|
||||
} satisfies SearchMode;
|
||||
|
||||
export const searchModes = [...searchModesWithoutHelp, helpMode] as const;
|
||||
|
||||
@@ -130,7 +130,7 @@ export const pagesSearchGroup = createGroup<{
|
||||
icon: IconLogs,
|
||||
path: "/manage/tools/logs",
|
||||
name: t("manageLog.label"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||
},
|
||||
{
|
||||
icon: IconReport,
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"title": "Apps",
|
||||
"item": {
|
||||
"create": {
|
||||
"label": "Create apps",
|
||||
"description": "Allow members to create apps"
|
||||
},
|
||||
"use-all": {
|
||||
"label": "Use all apps",
|
||||
"description": "Allow members to add any apps to their boards"
|
||||
},
|
||||
"modify-all": {
|
||||
"label": "Modify all apps",
|
||||
"description": "Allow members to modify all apps"
|
||||
},
|
||||
"full-all": {
|
||||
"label": "Full app access",
|
||||
"description": "Allow members to manage, use and delete any app"
|
||||
}
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
"title": "Boards",
|
||||
"item": {
|
||||
@@ -237,6 +258,49 @@
|
||||
"description": "Allow members to manage, use and interact with any integration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"title": "Medias",
|
||||
"item": {
|
||||
"upload": {
|
||||
"label": "Upload medias",
|
||||
"description": "Allow members to upload medias"
|
||||
},
|
||||
"view-all": {
|
||||
"label": "View all medias",
|
||||
"description": "Allow members to view all medias"
|
||||
},
|
||||
"full-all": {
|
||||
"label": "Full media access",
|
||||
"description": "Allow members to manage and delete any media"
|
||||
}
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"title": "Other",
|
||||
"item": {
|
||||
"view-logs": {
|
||||
"label": "View logs",
|
||||
"description": "Allow members to view logs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search-engine": {
|
||||
"title": "Search engines",
|
||||
"item": {
|
||||
"create": {
|
||||
"label": "Create search engines",
|
||||
"description": "Allow members to create search engines"
|
||||
},
|
||||
"modify-all": {
|
||||
"label": "Modify all search engines",
|
||||
"description": "Allow members to modify all search engines"
|
||||
},
|
||||
"full-all": {
|
||||
"label": "Full search engine access",
|
||||
"description": "Allow members to manage and delete any search engine"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"memberNotice": {
|
||||
@@ -244,7 +308,7 @@
|
||||
"external": "All members are from external providers and cannot be managed here"
|
||||
},
|
||||
"reservedNotice": {
|
||||
"message": "This group is reserved for system use and restricts some actions. {checkoutDocs}"
|
||||
"message": "This group is reserved for system use and restricts some actions. <checkoutDocs></checkoutDocs>"
|
||||
},
|
||||
"action": {
|
||||
"create": {
|
||||
@@ -2073,6 +2137,9 @@
|
||||
},
|
||||
"dnsHole": {
|
||||
"label": "DNS Hole Data"
|
||||
},
|
||||
"sessionCleanup": {
|
||||
"label": "Session Cleanup"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.14.0",
|
||||
"@mantine/dates": "^7.14.0",
|
||||
"@mantine/hooks": "^7.14.0",
|
||||
"@mantine/core": "^7.14.1",
|
||||
"@mantine/dates": "^7.14.1",
|
||||
"@mantine/hooks": "^7.14.1",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"mantine-react-table": "2.0.0-beta.7",
|
||||
"next": "^14.2.18",
|
||||
@@ -41,7 +41,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/css-modules": "^1.0.5",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
const findIconsSchema = z.object({
|
||||
searchText: z.string().optional(),
|
||||
limitPerGroup: z.number().min(1).max(500).default(12),
|
||||
});
|
||||
|
||||
export const iconsSchemas = {
|
||||
|
||||
@@ -40,24 +40,24 @@
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.14.0",
|
||||
"@mantine/hooks": "^7.14.0",
|
||||
"@mantine/core": "^7.14.1",
|
||||
"@mantine/hooks": "^7.14.1",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"@tiptap/extension-color": "2.9.1",
|
||||
"@tiptap/extension-highlight": "2.9.1",
|
||||
"@tiptap/extension-image": "2.9.1",
|
||||
"@tiptap/extension-link": "^2.9.1",
|
||||
"@tiptap/extension-table": "2.9.1",
|
||||
"@tiptap/extension-table-cell": "2.9.1",
|
||||
"@tiptap/extension-table-header": "2.9.1",
|
||||
"@tiptap/extension-table-row": "2.9.1",
|
||||
"@tiptap/extension-task-item": "2.9.1",
|
||||
"@tiptap/extension-task-list": "2.9.1",
|
||||
"@tiptap/extension-text-align": "2.9.1",
|
||||
"@tiptap/extension-text-style": "2.9.1",
|
||||
"@tiptap/extension-underline": "2.9.1",
|
||||
"@tiptap/react": "^2.9.1",
|
||||
"@tiptap/starter-kit": "^2.9.1",
|
||||
"@tiptap/extension-color": "2.10.2",
|
||||
"@tiptap/extension-highlight": "2.10.2",
|
||||
"@tiptap/extension-image": "2.10.2",
|
||||
"@tiptap/extension-link": "^2.10.2",
|
||||
"@tiptap/extension-table": "2.10.2",
|
||||
"@tiptap/extension-table-cell": "2.10.2",
|
||||
"@tiptap/extension-table-header": "2.10.2",
|
||||
"@tiptap/extension-table-row": "2.10.2",
|
||||
"@tiptap/extension-task-item": "2.10.2",
|
||||
"@tiptap/extension-task-list": "2.10.2",
|
||||
"@tiptap/extension-text-align": "2.10.2",
|
||||
"@tiptap/extension-text-style": "2.10.2",
|
||||
"@tiptap/extension-underline": "2.10.2",
|
||||
"@tiptap/react": "^2.10.2",
|
||||
"@tiptap/starter-kit": "^2.10.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"mantine-react-table": "2.0.0-beta.7",
|
||||
@@ -70,7 +70,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/video.js": "^7.3.58",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
1648
pnpm-lock.yaml
generated
1648
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user