chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-11-22 19:14:01 +00:00
committed by GitHub
101 changed files with 2231 additions and 1285 deletions

View File

@@ -3,6 +3,7 @@
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors", "yoavbls.pretty-ts-errors",
"million.million-lint" "million.million-lint",
"lokalise.i18n-ally"
] ]
} }

36
.vscode/i18n-ally-custom-framework.yml vendored Normal file
View 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
View File

@@ -27,9 +27,12 @@
"Umami" "Umami"
], ],
"i18n-ally.dirStructure": "auto", "i18n-ally.dirStructure": "auto",
"i18n-ally.enabledFrameworks": ["next-intl"], "i18n-ally.displayLanguage": "en",
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"], "i18n-ally.enabledFrameworks": [
"i18n-ally.enabledParsers": ["ts"], "custom"
"i18n-ally.extract.keyMaxLength": 0, ],
"i18n-ally.keystyle": "flat" "i18n-ally.localesPaths": [
"packages/translation/src/lang",
],
"i18n-ally.keystyle": "auto",
} }

View File

@@ -37,17 +37,17 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.14.0", "@mantine/colors-generator": "^7.14.1",
"@mantine/core": "^7.14.0", "@mantine/core": "^7.14.1",
"@mantine/hooks": "^7.14.0", "@mantine/hooks": "^7.14.1",
"@mantine/modals": "^7.14.0", "@mantine/modals": "^7.14.1",
"@mantine/tiptap": "^7.14.0", "@mantine/tiptap": "^7.14.1",
"@million/lint": "1.0.12", "@million/lint": "1.0.12",
"@t3-oss/env-nextjs": "^0.11.1", "@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.22.0", "@tabler/icons-react": "^3.22.0",
"@tanstack/react-query": "^5.60.5", "@tanstack/react-query": "^5.61.0",
"@tanstack/react-query-devtools": "^5.60.5", "@tanstack/react-query-devtools": "^5.61.0",
"@tanstack/react-query-next-experimental": "5.60.5", "@tanstack/react-query-next-experimental": "5.61.0",
"@trpc/client": "next", "@trpc/client": "next",
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
@@ -61,7 +61,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"flag-icons": "^7.2.3", "flag-icons": "^7.2.3",
"glob": "^11.0.0", "glob": "^11.0.0",
"jotai": "^2.10.2", "jotai": "^2.10.3",
"mantine-react-table": "2.0.0-beta.7", "mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.18", "next": "^14.2.18",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
@@ -80,13 +80,13 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4", "@types/chroma-js": "2.4.4",
"@types/node": "^22.9.0", "@types/node": "^22.9.1",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/swagger-ui-react": "^4.18.3", "@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.1.0", "concurrently": "^9.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"node-loader": "^2.1.0", "node-loader": "^2.1.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.6.3" "typescript": "^5.6.3"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

View 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

View 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

View 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

View 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

View File

@@ -17,7 +17,7 @@ import {
import superjson from "superjson"; import superjson from "superjson";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
import { env } from "~/env.mjs"; import { env } from "~/env.mjs";
@@ -86,16 +86,13 @@ export function TRPCReactProvider(props: PropsWithChildren) {
return data; return data;
}, },
}, },
url: `${getBaseUrl()}/api/trpc`, url: getTrpcUrl(),
headers: createHeadersCallbackForSource("nextjs-react (form-data)"),
}), }),
false: unstable_httpBatchStreamLink({ false: unstable_httpBatchStreamLink({
transformer: superjson, transformer: superjson,
url: `${getBaseUrl()}/api/trpc`, url: getTrpcUrl(),
headers() { headers: createHeadersCallbackForSource("nextjs-react (json)"),
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}), }),
}), }),
}), }),
@@ -112,9 +109,3 @@ export function TRPCReactProvider(props: PropsWithChildren) {
</clientApi.Provider> </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}`;
}

View File

@@ -1,6 +1,8 @@
import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core"; import { Container, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -11,6 +13,11 @@ interface AppEditPageProps {
} }
export default async function AppEditPage({ params }: 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 app = await api.app.byId({ id: params.id });
const t = await getI18n(); const t = await getI18n();

View File

@@ -1,11 +1,19 @@
import { notFound } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core"; import { Container, Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppNewForm } from "./_app-new-form"; import { AppNewForm } from "./_app-new-form";
export default async function AppNewPage() { export default async function AppNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("app-create")) {
notFound();
}
const t = await getI18n(); const t = await getI18n();
return ( return (

View File

@@ -1,9 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconApps, IconPencil } from "@tabler/icons-react"; import { IconApps, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server"; import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import { getI18n, getScopedI18n } from "@homarr/translation/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"; import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() { export default async function AppsPage() {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
const apps = await api.app.all(); const apps = await api.app.all();
const t = await getScopedI18n("app"); const t = await getScopedI18n("app");
@@ -22,9 +30,11 @@ export default async function AppsPage() {
<Stack> <Stack>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title> <Title>{t("page.list.title")}</Title>
<MobileAffixButton component={Link} href="/manage/apps/new"> {session.user.permissions.includes("app-create") && (
{t("page.create.title")} <MobileAffixButton component={Link} href="/manage/apps/new">
</MobileAffixButton> {t("page.create.title")}
</MobileAffixButton>
)}
</Group> </Group>
{apps.length === 0 && <AppNoResults />} {apps.length === 0 && <AppNoResults />}
{apps.length > 0 && ( {apps.length > 0 && (
@@ -45,6 +55,7 @@ interface AppCardProps {
const AppCard = async ({ app }: AppCardProps) => { const AppCard = async ({ app }: AppCardProps) => {
const t = await getScopedI18n("app"); const t = await getScopedI18n("app");
const session = await auth();
return ( return (
<Card> <Card>
@@ -78,16 +89,18 @@ const AppCard = async ({ app }: AppCardProps) => {
</Group> </Group>
<Group> <Group>
<ActionIconGroup> <ActionIconGroup>
<ActionIcon {session?.user.permissions.includes("app-modify-all") && (
component={Link} <ActionIcon
href={`/manage/apps/edit/${app.id}`} component={Link}
variant="subtle" href={`/manage/apps/edit/${app.id}`}
color="gray" variant="subtle"
aria-label={t("page.edit.title")} color="gray"
> aria-label={t("page.edit.title")}
<IconPencil size={16} stroke={1.5} /> >
</ActionIcon> <IconPencil size={16} stroke={1.5} />
<AppDeleteButton app={app} /> </ActionIcon>
)}
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
</ActionIconGroup> </ActionIconGroup>
</Group> </Group>
</Group> </Group>
@@ -97,6 +110,7 @@ const AppCard = async ({ app }: AppCardProps) => {
const AppNoResults = async () => { const AppNoResults = async () => {
const t = await getI18n(); const t = await getI18n();
const session = await auth();
return ( return (
<Card withBorder bg="transparent"> <Card withBorder bg="transparent">
@@ -105,7 +119,9 @@ const AppNoResults = async () => {
<Text fw={500} size="lg"> <Text fw={500} size="lg">
{t("app.page.list.noResults.title")} {t("app.page.list.noResults.title")}
</Text> </Text>
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor> {session?.user.permissions.includes("app-create") && (
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
)}
</Stack> </Stack>
</Card> </Card>
); );

View File

@@ -6,7 +6,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; 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 { IntegrationAccessSettings } from "../../_components/integration-access-settings";
import { EditIntegrationForm } from "./_integration-edit-form"; import { EditIntegrationForm } from "./_integration-edit-form";

View File

@@ -1,6 +1,7 @@
import { Fragment } from "react"; import { Fragment } from "react";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { import {
AccordionControl, AccordionControl,
AccordionItem, AccordionItem,
@@ -50,11 +51,16 @@ interface IntegrationsPageProps {
} }
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) { export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
const integrations = await api.integration.all();
const session = await auth(); const session = await auth();
if (!session) {
redirect("/auth/login");
}
const integrations = await api.integration.all();
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
const canCreateIntegrations = session?.user.permissions.includes("integration-create") ?? false; const canCreateIntegrations = session.user.permissions.includes("integration-create");
return ( return (
<ManageContainer> <ManageContainer>

View File

@@ -52,16 +52,19 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox, icon: IconBox,
href: "/manage/apps", href: "/manage/apps",
label: t("items.apps"), label: t("items.apps"),
hidden: !session,
}, },
{ {
icon: IconPlug, icon: IconPlug,
href: "/manage/integrations", href: "/manage/integrations",
label: t("items.integrations"), label: t("items.integrations"),
hidden: !session,
}, },
{ {
icon: IconSearch, icon: IconSearch,
href: "/manage/search-engines", href: "/manage/search-engines",
label: t("items.searchEngies"), label: t("items.searchEngies"),
hidden: !session,
}, },
{ {
icon: IconPhoto, icon: IconPhoto,
@@ -95,27 +98,32 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{ {
label: t("items.tools.label"), label: t("items.tools.label"),
icon: IconTool, icon: IconTool,
hidden: !session?.user.permissions.includes("admin"), // 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: [ items: [
{ {
label: t("items.tools.items.docker"), label: t("items.tools.items.docker"),
icon: IconBrandDocker, icon: IconBrandDocker,
href: "/manage/tools/docker", href: "/manage/tools/docker",
hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.tools.items.api"), label: t("items.tools.items.api"),
icon: IconPlug, icon: IconPlug,
href: "/manage/tools/api", href: "/manage/tools/api",
hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.tools.items.logs"), label: t("items.tools.items.logs"),
icon: IconLogs, icon: IconLogs,
href: "/manage/tools/logs", href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"),
}, },
{ {
label: t("items.tools.items.tasks"), label: t("items.tools.items.tasks"),
icon: IconReport, icon: IconReport,
href: "/manage/tools/tasks", href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
}, },
], ],
}, },

View File

@@ -47,7 +47,6 @@ export default async function GroupsListPage(props: MediaListPageProps) {
const t = await getI18n(); const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams); const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: medias, totalCount } = await api.media.getPaginated(searchParams); const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
const isAdmin = session.user.permissions.includes("admin");
return ( return (
<ManageContainer size="xl"> <ManageContainer size="xl">
@@ -57,10 +56,12 @@ export default async function GroupsListPage(props: MediaListPageProps) {
<Group justify="space-between"> <Group justify="space-between">
<Group> <Group>
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} /> <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> </Group>
<UploadMedia /> {session.user.permissions.includes("media-upload") && <UploadMedia />}
</Group> </Group>
<Table striped highlightOnHover> <Table striped highlightOnHover>
<TableThead> <TableThead>
@@ -91,7 +92,10 @@ interface RowProps {
media: RouterOutputs["media"]["getPaginated"]["items"][number]; 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 ( return (
<TableTr> <TableTr>
<TableTd w={64}> <TableTd w={64}>
@@ -120,7 +124,7 @@ const Row = ({ media }: RowProps) => {
<TableTd w={64}> <TableTd w={64}>
<Group wrap="nowrap" gap="xs"> <Group wrap="nowrap" gap="xs">
<CopyMedia media={media} /> <CopyMedia media={media} />
<DeleteMedia media={media} /> {canDelete && <DeleteMedia media={media} />}
</Group> </Group>
</TableTd> </TableTd>
</TableTr> </TableTr>

View File

@@ -64,6 +64,7 @@ export default async function ManagementPage() {
href: "/manage/apps", href: "/manage/apps",
subtitle: t("statisticLabel.resources"), subtitle: t("statisticLabel.resources"),
title: t("statistic.app"), title: t("statistic.app"),
hidden: !session?.user,
}, },
{ {
count: statistics.countGroups, count: statistics.countGroups,

View File

@@ -1,6 +1,8 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core"; import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
@@ -12,6 +14,12 @@ interface SearchEngineEditPageProps {
} }
export default async function SearchEngineEditPage({ params }: 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 searchEngine = await api.searchEngine.byId({ id: params.id });
const t = await getI18n(); const t = await getI18n();

View File

@@ -1,5 +1,7 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core"; import { Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container"; 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"; import { SearchEngineNewForm } from "./_search-engine-new-form";
export default async function SearchEngineNewPage() { export default async function SearchEngineNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("search-engine-create")) {
notFound();
}
const t = await getI18n(); const t = await getI18n();
return ( return (

View File

@@ -1,9 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react"; import { IconPencil, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination } from "@homarr/ui"; import { SearchInput, TablePagination } from "@homarr/ui";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
@@ -28,6 +30,12 @@ interface SearchEnginesPageProps {
} }
export default async function SearchEnginesPage(props: SearchEnginesPageProps) { export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
const searchParams = searchParamsSchema.parse(props.searchParams); const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(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> <Title>{tEngine("page.list.title")}</Title>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} /> <SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
<MobileAffixButton component={Link} href="/manage/search-engines/new"> {session.user.permissions.includes("search-engine-create") && (
{tEngine("page.create.title")} <MobileAffixButton component={Link} href="/manage/search-engines/new">
</MobileAffixButton> {tEngine("page.create.title")}
</MobileAffixButton>
)}
</Group> </Group>
{searchEngines.length === 0 && <SearchEngineNoResults />} {searchEngines.length === 0 && <SearchEngineNoResults />}
{searchEngines.length > 0 && ( {searchEngines.length > 0 && (
@@ -67,6 +77,7 @@ interface SearchEngineCardProps {
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => { const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const t = await getScopedI18n("search.engine"); const t = await getScopedI18n("search.engine");
const session = await auth();
return ( return (
<Card> <Card>
@@ -105,16 +116,20 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
</Group> </Group>
<Group> <Group>
<ActionIconGroup> <ActionIconGroup>
<ActionIcon {session?.user.permissions.includes("search-engine-modify-all") && (
component={Link} <ActionIcon
href={`/manage/search-engines/edit/${searchEngine.id}`} component={Link}
variant="subtle" href={`/manage/search-engines/edit/${searchEngine.id}`}
color="gray" variant="subtle"
aria-label={t("page.edit.title")} color="gray"
> aria-label={t("page.edit.title")}
<IconPencil size={16} stroke={1.5} /> >
</ActionIcon> <IconPencil size={16} stroke={1.5} />
<SearchEngineDeleteButton searchEngine={searchEngine} /> </ActionIcon>
)}
{session?.user.permissions.includes("search-engine-full-all") && (
<SearchEngineDeleteButton searchEngine={searchEngine} />
)}
</ActionIconGroup> </ActionIconGroup>
</Group> </Group>
</Group> </Group>
@@ -124,6 +139,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const SearchEngineNoResults = async () => { const SearchEngineNoResults = async () => {
const t = await getI18n(); const t = await getI18n();
const session = await auth();
return ( return (
<Card withBorder bg="transparent"> <Card withBorder bg="transparent">
@@ -132,7 +148,9 @@ const SearchEngineNoResults = async () => {
<Text fw={500} size="lg"> <Text fw={500} size="lg">
{t("search.engine.page.list.noResults.title")} {t("search.engine.page.list.noResults.title")}
</Text> </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> </Stack>
</Card> </Card>
); );

View File

@@ -23,7 +23,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
const { mutate, isPending } = clientApi.apiKeys.create.useMutation({ const { mutate, isPending } = clientApi.apiKeys.create.useMutation({
async onSuccess(data) { async onSuccess(data) {
openModal({ openModal({
apiKey: data.randomToken, apiKey: data.apiKey,
}); });
await revalidatePathActionAsync("/manage/tools/api"); await revalidatePathActionAsync("/manage/tools/api");
}, },

View File

@@ -27,7 +27,7 @@ export async function generateMetadata() {
export default async function LogsManagementPage() { export default async function LogsManagementPage() {
const session = await auth(); const session = await auth();
if (!session?.user || !session.user.permissions.includes("admin")) { if (!session?.user || !session.user.permissions.includes("other-view-logs")) {
notFound(); notFound();
} }

View File

@@ -8,7 +8,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-not-found"; import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access"; import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board"; import { ChangeHomeBoardForm } from "./_components/_change-home-board";

View File

@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-not-found"; import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { NavigationLink } from "../groups/[id]/_navigation"; import { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access"; import { canAccessUserEditPage } from "./access";

View File

@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; 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 { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_components/_change-password-form"; import { ChangePasswordForm } from "./_components/_change-password-form";

View File

@@ -1,12 +1,14 @@
"use client";
import Link from "next/link"; import Link from "next/link";
import { Alert, Anchor } from "@mantine/core"; import { Alert, Anchor } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react"; import { IconExclamationCircle } from "@tabler/icons-react";
import { createDocumentationLink } from "@homarr/definitions"; import { createDocumentationLink } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server"; import { useI18n } from "@homarr/translation/client";
export const ReservedGroupAlert = async () => { export const ReservedGroupAlert = () => {
const t = await getI18n(); const t = useI18n();
return ( return (
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}> <Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>

View File

@@ -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 { createOpenApiFetchHandler } from "trpc-swagger/build/index.mjs";
import { appRouter, createTRPCContext } from "@homarr/api"; import { appRouter, createTRPCContext } from "@homarr/api";
import { hashPasswordAsync } from "@homarr/auth";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { createSessionAsync } from "@homarr/auth/server"; import { createSessionAsync } from "@homarr/auth/server";
import { db, eq } from "@homarr/db"; import { db, eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema/sqlite"; import { apiKeys } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
const handlerAsync = async (req: Request) => { const handlerAsync = async (req: NextRequest) => {
const apiKeyHeaderValue = req.headers.get("ApiKey"); 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({ return createOpenApiFetchHandler({
req, 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( logger.info(
`Creating OpenAPI fetch handler for user ${apiKeyHeaderValue ? "with an api key" : "without an api key"}`, `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; 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({ const apiKeyFromDb = await db.query.apiKeys.findFirst({
where: eq(apiKeys.apiKey, apiKeyHeaderValue), where: eq(apiKeys.id, apiKeyId),
columns: { columns: {
id: true, id: true,
apiKey: false, apiKey: true,
salt: false, salt: true,
}, },
with: { with: {
user: { user: {
@@ -47,8 +66,15 @@ const getSessionOrDefaultFromHeadersAsync = async (apiKeyHeaderValue: string | n
}, },
}); });
if (apiKeyFromDb === undefined) { if (!apiKeyFromDb) {
logger.warn("An attempt to authenticate over API has failed"); 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; return null;
} }

View File

@@ -71,15 +71,6 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
? section.width ? section.width
: board.columnCount; : board.columnCount;
useCssVariableConfiguration({
columnCount,
gridRef,
wrapperRef,
width,
height,
isDynamic: section.kind === "dynamic",
});
const itemRefKeys = Object.keys(itemRefs.current); const itemRefKeys = Object.keys(itemRefs.current);
// define items in itemRefs for easy access and reference to items // define items in itemRefs for easy access and reference to items
if (itemRefKeys.length !== itemIds.length) { 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( const onChange = useCallback(
(changedNode: GridStackNode) => { (changedNode: GridStackNode) => {
const id = changedNode.el?.getAttribute("data-id"); const id = changedNode.el?.getAttribute("data-id");
@@ -258,14 +244,40 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
}; };
}, [isEditMode, onAdd, onChange]); }, [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; 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(() => { useEffect(() => {
if (!sectionHeight) return; if (!sectionHeight) return;
gridRef.current?.row(sectionHeight); gridRef.current?.row(sectionHeight);
}, [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 { return {
refs: { refs: {
items: itemRefs, items: itemRefs,

View File

@@ -1,9 +1,23 @@
import type { FocusEventHandler } from "react"; import type { FocusEventHandler } from "react";
import { useState } from "react"; import { startTransition, useState } from "react";
import { Combobox, Group, Image, InputBase, Skeleton, Text, useCombobox } from "@mantine/core"; import {
Box,
Card,
Combobox,
Flex,
Image,
Indicator,
InputBase,
Paper,
Skeleton,
Stack,
Text,
UnstyledButton,
useCombobox,
} from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
interface IconPickerProps { interface IconPickerProps {
initialValue?: string; initialValue?: string;
@@ -18,10 +32,9 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
const [search, setSearch] = useState(initialValue ?? ""); const [search, setSearch] = useState(initialValue ?? "");
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null); const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
const t = useI18n();
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
const { data, isFetching } = clientApi.icon.findIcons.useQuery({ const [data] = clientApi.icon.findIcons.useSuspenseQuery({
searchText: search, searchText: search,
}); });
@@ -29,39 +42,53 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
onDropdownClose: () => combobox.resetSelectedOption(), onDropdownClose: () => combobox.resetSelectedOption(),
}); });
const notNullableData = data?.icons ?? []; const totalOptions = data.icons.reduce((acc, group) => acc + group.icons.length, 0);
const groups = data.icons.map((group) => {
const totalOptions = notNullableData.reduce((acc, group) => acc + group.icons.length, 0);
const groups = notNullableData.map((group) => {
const options = group.icons.map((item) => ( const options = group.icons.map((item) => (
<Combobox.Option value={item.url} key={item.id}> <UnstyledButton
<Group> onClick={() => {
<Image src={item.url} w={20} h={20} /> const value = item.url;
<Text>{item.name}</Text> startTransition(() => {
</Group> setValue(value);
</Combobox.Option> 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 ( return (
<Combobox.Group label={group.slug} key={group.id}> <Paper p="xs" key={group.slug} pt={2}>
{options} <Text mb={8} size="sm" fw="bold">
</Combobox.Group> {group.slug}
</Text>
<Flex gap={8} wrap={"wrap"}>
{options}
</Flex>
</Paper>
); );
}); });
return ( return (
<Combobox <Combobox store={combobox} withinPortal>
onOptionSubmit={(value) => {
setValue(value);
setPreviewUrl(value);
setSearch(value);
onChange(value);
combobox.closeDropdown();
}}
store={combobox}
withinPortal
>
<Combobox.Target> <Combobox.Target>
<InputBase <InputBase
rightSection={<Combobox.Chevron />} rightSection={<Combobox.Chevron />}
@@ -91,18 +118,14 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
withAsterisk withAsterisk
error={error} error={error}
label={tCommon("iconPicker.label")} label={tCommon("iconPicker.label")}
placeholder={tCommon("iconPicker.header", { countIcons: data.countIcons })}
/> />
</Combobox.Target> </Combobox.Target>
<Combobox.Dropdown> <Combobox.Dropdown>
<Combobox.Header>
<Text c="dimmed">{tCommon("iconPicker.header", { countIcons: data?.countIcons })}</Text>
</Combobox.Header>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}> <Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{totalOptions > 0 ? ( {totalOptions > 0 ? (
groups <Stack gap={4}>{groups}</Stack>
) : !isFetching ? (
<Combobox.Empty>{t("search.nothingFound")}</Combobox.Empty>
) : ( ) : (
Array(15) Array(15)
.fill(0) .fill(0)

View 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;
};

View File

@@ -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;
};

View File

@@ -44,9 +44,9 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.9.0", "@types/node": "^22.9.1",
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.4",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"tsx": "4.19.2", "tsx": "4.19.2",
"typescript": "^5.6.3" "typescript": "^5.6.3"

View 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} `));
});
});

View File

@@ -3,7 +3,7 @@ import { Agent, setGlobalDispatcher } from "undici";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
class LoggingAgent extends Agent { export class LoggingAgent extends Agent {
constructor(...props: ConstructorParameters<typeof Agent>) { constructor(...props: ConstructorParameters<typeof Agent>) {
super(...props); super(...props);
} }
@@ -15,15 +15,17 @@ class LoggingAgent extends Agent {
// some integrations use query parameters for auth // some integrations use query parameters for auth
url.searchParams.forEach((value, key) => { url.searchParams.forEach((value, key) => {
if (value === "") return; // Skip empty values 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 (value === "true" || value === "false") return; // Skip boolean values
if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings 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"); url.searchParams.set(key, "REDACTED");
}); });
logger.info( 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); return super.dispatch(options, handler);
} }

View File

@@ -34,7 +34,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.5.13", "@types/ws": "^8.5.13",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }

View File

@@ -28,20 +28,20 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"devDependencies": { "devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@turbo/gen": "^2.3.0", "@turbo/gen": "^2.3.1",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"@vitest/coverage-v8": "^2.1.5", "@vitest/coverage-v8": "^2.1.5",
"@vitest/ui": "^2.1.5", "@vitest/ui": "^2.1.5",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"testcontainers": "^10.14.0", "testcontainers": "^10.15.0",
"turbo": "^2.3.0", "turbo": "^2.3.1",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite-tsconfig-paths": "^5.1.2", "vite-tsconfig-paths": "^5.1.3",
"vitest": "^2.1.5" "vitest": "^2.1.5"
}, },
"packageManager": "pnpm@9.13.2", "packageManager": "pnpm@9.14.2",
"engines": { "engines": {
"node": ">=22.11.0" "node": ">=22.11.0"
}, },

View File

@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -48,8 +48,8 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.31", "@types/dockerode": "^3.3.32",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }

View File

@@ -7,13 +7,9 @@ export const clientApi = createTRPCReact<AppRouter>();
export const fetchApi = createTRPCClient<AppRouter>({ export const fetchApi = createTRPCClient<AppRouter>({
links: [ links: [
httpLink({ httpLink({
url: `${getBaseUrl()}/api/trpc`, url: getTrpcUrl(),
transformer: SuperJSON, transformer: SuperJSON,
headers() { headers: createHeadersCallbackForSource("fetch"),
const headers = new Headers();
headers.set("x-trpc-source", "fetch");
return headers;
},
}), }),
], ],
}); });
@@ -23,3 +19,50 @@ function getBaseUrl() {
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`; 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;
}

View File

@@ -28,14 +28,15 @@ export const apiKeysRouter = createTRPCRouter({
const salt = await createSaltAsync(); const salt = await createSaltAsync();
const randomToken = generateSecureRandomToken(64); const randomToken = generateSecureRandomToken(64);
const hashedRandomToken = await hashPasswordAsync(randomToken, salt); const hashedRandomToken = await hashPasswordAsync(randomToken, salt);
const id = createId();
await db.insert(apiKeys).values({ await db.insert(apiKeys).values({
id: createId(), id,
apiKey: hashedRandomToken, apiKey: hashedRandomToken,
salt, salt,
userId: ctx.session.user.id, userId: ctx.session.user.id,
}); });
return { return {
randomToken, apiKey: `${id}.${randomToken}`,
}; };
}), }),
}); });

View File

@@ -4,10 +4,11 @@ import { asc, createId, eq, inArray, like } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite"; import { apps } from "@homarr/db/schema/sqlite";
import { validation, z } from "@homarr/validation"; 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({ export const appRouter = createTRPCRouter({
all: publicProcedure all: protectedProcedure
.input(z.void()) .input(z.void())
.output( .output(
z.array( z.array(
@@ -26,7 +27,7 @@ export const appRouter = createTRPCRouter({
orderBy: asc(apps.name), orderBy: asc(apps.name),
}); });
}), }),
search: publicProcedure search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.output( .output(
z.array( z.array(
@@ -47,7 +48,7 @@ export const appRouter = createTRPCRouter({
limit: input.limit, limit: input.limit,
}); });
}), }),
selectable: publicProcedure selectable: protectedProcedure
.input(z.void()) .input(z.void())
.output( .output(
z.array( 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; 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({ return await ctx.db.query.apps.findMany({
where: inArray(apps.id, input), where: inArray(apps.id, input),
}); });
}), }),
create: protectedProcedure create: permissionRequiredProcedure
.requiresPermission("app-create")
.input(validation.app.manage) .input(validation.app.manage)
.output(z.void()) .output(z.void())
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } }) .meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
@@ -124,29 +134,33 @@ export const appRouter = createTRPCRouter({
href: input.href, href: input.href,
}); });
}), }),
update: protectedProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => { update: permissionRequiredProcedure
const app = await ctx.db.query.apps.findFirst({ .requiresPermission("app-modify-all")
where: eq(apps.id, input.id), .input(validation.app.edit)
}); .mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
if (!app) { where: eq(apps.id, input.id),
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
}); });
}
await ctx.db if (!app) {
.update(apps) throw new TRPCError({
.set({ code: "NOT_FOUND",
name: input.name, message: "App not found",
description: input.description, });
iconUrl: input.iconUrl, }
href: input.href,
}) await ctx.db
.where(eq(apps.id, input.id)); .update(apps)
}), .set({
delete: protectedProcedure 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()) .output(z.void())
.meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } }) .meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.input(validation.common.byId) .input(validation.common.byId)

View 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'");
});
};

View File

@@ -16,7 +16,7 @@ export const iconsRouter = createTRPCRouter({
url: true, url: true,
}, },
where: (input.searchText?.length ?? 0) > 0 ? like(icons.name, `%${input.searchText}%`) : undefined, where: (input.searchText?.length ?? 0) > 0 ? like(icons.name, `%${input.searchText}%`) : undefined,
limit: 5, limit: input.limitPerGroup,
}, },
}, },
}), }),

View File

@@ -52,7 +52,6 @@ export const testConnectionAsync = async (
const { secrets: _, ...baseIntegration } = integration; const { secrets: _, ...baseIntegration } = integration;
// @ts-expect-error - For now we expect an error here as not all integrations have been implemented
const integrationInstance = integrationCreator({ const integrationInstance = integrationCreator({
...baseIntegration, ...baseIntegration,
decryptedSecrets, decryptedSecrets,

View File

@@ -7,7 +7,7 @@ import { loggingChannel } from "@homarr/redis";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc"; import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const logRouter = createTRPCRouter({ export const logRouter = createTRPCRouter({
subscribe: permissionRequiredProcedure.requiresPermission("admin").subscription(() => { subscribe: permissionRequiredProcedure.requiresPermission("other-view-logs").subscription(() => {
return observable<LoggerMessage>((emit) => { return observable<LoggerMessage>((emit) => {
const unsubscribe = loggingChannel.subscribe((data) => { const unsubscribe = loggingChannel.subscribe((data) => {
emit.next(data); emit.next(data);

View File

@@ -4,7 +4,7 @@ import { and, createId, desc, eq, like } from "@homarr/db";
import { medias } from "@homarr/db/schema/sqlite"; import { medias } from "@homarr/db/schema/sqlite";
import { validation, z } from "@homarr/validation"; import { validation, z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
export const mediaRouter = createTRPCRouter({ export const mediaRouter = createTRPCRouter({
getPaginated: protectedProcedure getPaginated: protectedProcedure
@@ -14,7 +14,7 @@ export const mediaRouter = createTRPCRouter({
), ),
) )
.query(async ({ ctx, input }) => { .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( const where = and(
input.search.length >= 1 ? like(medias.name, `%${input.search}%`) : undefined, input.search.length >= 1 ? like(medias.name, `%${input.search}%`) : undefined,
@@ -46,20 +46,23 @@ export const mediaRouter = createTRPCRouter({
totalCount, totalCount,
}; };
}), }),
uploadMedia: protectedProcedure.input(validation.media.uploadMedia).mutation(async ({ ctx, input }) => { uploadMedia: permissionRequiredProcedure
const content = Buffer.from(await input.file.arrayBuffer()); .requiresPermission("media-upload")
const id = createId(); .input(validation.media.uploadMedia)
await ctx.db.insert(medias).values({ .mutation(async ({ ctx, input }) => {
id, const content = Buffer.from(await input.file.arrayBuffer());
creatorId: ctx.session.user.id, const id = createId();
content, await ctx.db.insert(medias).values({
size: input.file.size, id,
contentType: input.file.type, creatorId: ctx.session.user.id,
name: input.file.name, 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 }) => { deleteMedia: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
const dbMedia = await ctx.db.query.medias.findFirst({ const dbMedia = await ctx.db.query.medias.findFirst({
where: eq(medias.id, input.id), 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 // Only allow users with media-full-all permission and the creator of the media to delete it
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== dbMedia.creatorId) { if (!ctx.session.user.permissions.includes("media-full-all") && ctx.session.user.id !== dbMedia.creatorId) {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "You don't have permission to delete this media", message: "You don't have permission to delete this media",

View File

@@ -4,7 +4,7 @@ import { createId, eq, like, sql } from "@homarr/db";
import { searchEngines } from "@homarr/db/schema/sqlite"; import { searchEngines } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
export const searchEngineRouter = createTRPCRouter({ export const searchEngineRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => { getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
@@ -59,43 +59,52 @@ export const searchEngineRouter = createTRPCRouter({
limit: input.limit, limit: input.limit,
}); });
}), }),
create: protectedProcedure.input(validation.searchEngine.manage).mutation(async ({ ctx, input }) => { create: permissionRequiredProcedure
await ctx.db.insert(searchEngines).values({ .requiresPermission("search-engine-create")
id: createId(), .input(validation.searchEngine.manage)
name: input.name, .mutation(async ({ ctx, input }) => {
short: input.short.toLowerCase(), await ctx.db.insert(searchEngines).values({
iconUrl: input.iconUrl, id: createId(),
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({
name: input.name, name: input.name,
short: input.short.toLowerCase(),
iconUrl: input.iconUrl, iconUrl: input.iconUrl,
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null, urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
description: input.description, description: input.description,
integrationId: "integrationId" in input ? input.integrationId : null,
type: input.type, type: input.type,
}) integrationId: "integrationId" in input ? input.integrationId : null,
.where(eq(searchEngines.id, input.id)); });
}), }),
delete: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => { update: permissionRequiredProcedure
await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id)); .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));
}),
}); });

View File

@@ -5,23 +5,26 @@ import type { Session } from "@homarr/auth";
import { createId } from "@homarr/db"; import { createId } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite"; import { apps } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test"; import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { appRouter } from "../app"; import { appRouter } from "../app";
import * as appAccessControl from "../app/app-access-control";
// Mock the auth module to return an empty session // Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
const defaultSession: Session = { const createDefaultSession = (permissions: GroupPermissionKey[] = []): Session => ({
user: { id: createId(), permissions: [], colorScheme: "light" }, user: { id: createId(), permissions, colorScheme: "light" },
expires: new Date().toISOString(), expires: new Date().toISOString(),
}; });
describe("all should return all apps", () => { 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 db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: null, session: createDefaultSession(),
}); });
await db.insert(apps).values([ await db.insert(apps).values([
@@ -48,15 +51,30 @@ describe("all should return all apps", () => {
expect(result[1]!.href).toBeNull(); expect(result[1]!.href).toBeNull();
expect(result[1]!.description).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", () => { 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 db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: null, session: null,
}); });
vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(true));
await db.insert(apps).values([ 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" }); const result = await caller.byId({ id: "2" });
// Assert
expect(result.name).toBe("Mantine"); 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 () => { test("should throw an error if the app does not exist", async () => {
// Arrange
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: null, session: null,
}); });
// Act
const actAsync = async () => await caller.byId({ id: "2" }); const actAsync = async () => await caller.byId({ id: "2" });
// Assert
await expect(actAsync()).rejects.toThrow("App not found"); await expect(actAsync()).rejects.toThrow("App not found");
}); });
}); });
describe("create should create a new app with all arguments", () => { describe("create should create a new app with all arguments", () => {
test("should create a new app", async () => { test("should create a new app", async () => {
// Arrange
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: defaultSession, session: createDefaultSession(["app-create"]),
}); });
const input = { const input = {
name: "Mantine", name: "Mantine",
@@ -103,8 +154,10 @@ describe("create should create a new app with all arguments", () => {
href: "https://mantine.dev", href: "https://mantine.dev",
}; };
// Act
await caller.create(input); await caller.create(input);
// Assert
const dbApp = await db.query.apps.findFirst(); const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeDefined(); expect(dbApp).toBeDefined();
expect(dbApp!.name).toBe(input.name); 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 () => { test("should create a new app only with required arguments", async () => {
// Arrange
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: defaultSession, session: createDefaultSession(["app-create"]),
}); });
const input = { const input = {
name: "Mantine", name: "Mantine",
@@ -126,8 +180,10 @@ describe("create should create a new app with all arguments", () => {
href: null, href: null,
}; };
// Act
await caller.create(input); await caller.create(input);
// Assert
const dbApp = await db.query.apps.findFirst(); const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeDefined(); expect(dbApp).toBeDefined();
expect(dbApp!.name).toBe(input.name); 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", () => { describe("update should update an app", () => {
test("should update an app", async () => { test("should update an app", async () => {
// Arrange
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: defaultSession, session: createDefaultSession(["app-modify-all"]),
}); });
const appId = createId(); const appId = createId();
@@ -162,8 +219,10 @@ describe("update should update an app", () => {
href: "https://mantine.dev", href: "https://mantine.dev",
}; };
// Act
await caller.update(input); await caller.update(input);
// Assert
const dbApp = await db.query.apps.findFirst(); const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeDefined(); 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 () => { test("should throw an error if the app does not exist", async () => {
// Arrange
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: defaultSession, session: createDefaultSession(["app-modify-all"]),
}); });
// Act
const actAsync = async () => const actAsync = async () =>
await caller.update({ await caller.update({
id: createId(), id: createId(),
@@ -188,16 +249,19 @@ describe("update should update an app", () => {
description: null, description: null,
href: null, href: null,
}); });
// Assert
await expect(actAsync()).rejects.toThrow("App not found"); await expect(actAsync()).rejects.toThrow("App not found");
}); });
}); });
describe("delete should delete an app", () => { describe("delete should delete an app", () => {
test("should delete an app", async () => { test("should delete an app", async () => {
// Arrange
const db = createDb(); const db = createDb();
const caller = appRouter.createCaller({ const caller = appRouter.createCaller({
db, db,
session: defaultSession, session: createDefaultSession(["app-full-all"]),
}); });
const appId = createId(); const appId = createId();
@@ -207,8 +271,10 @@ describe("delete should delete an app", () => {
iconUrl: "https://mantine.dev/favicon.svg", iconUrl: "https://mantine.dev/favicon.svg",
}); });
// Act
await caller.delete({ id: appId }); await caller.delete({ id: appId });
// Assert
const dbApp = await db.query.apps.findFirst(); const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeUndefined(); expect(dbApp).toBeUndefined();
}); });

View File

@@ -23,8 +23,8 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@auth/core": "^0.37.3", "@auth/core": "^0.37.4",
"@auth/drizzle-adapter": "^1.7.3", "@auth/drizzle-adapter": "^1.7.4",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
@@ -45,7 +45,7 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0", "@types/cookies": "0.9.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -29,13 +29,13 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "^14.2.18", "next": "^14.2.18",
"react": "^18.3.1", "react": "^18.3.1",
"tldts": "^6.1.61" "tldts": "^6.1.63"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -32,7 +32,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -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. * 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. * 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(), { let scheduledTask: cron.ScheduledTask | null = null;
scheduled: false, if (cronExpression !== "never") {
name, scheduledTask = cron.schedule(cronExpression, () => void catchingCallbackAsync(), {
timezone: creatorOptions.timezone, scheduled: false,
}); name,
creatorOptions.logger.logDebug( timezone: creatorOptions.timezone,
`The cron job '${name}' was created with expression ${cronExpression} in timezone ${creatorOptions.timezone} and runOnStart ${options.runOnStart}`, });
); creatorOptions.logger.logDebug(
`The cron job '${name}' was created with expression ${cronExpression} in timezone ${creatorOptions.timezone} and runOnStart ${options.runOnStart}`,
);
}
return { return {
name, name,
@@ -90,7 +93,7 @@ export const createCronJobCreator = <TAllowedNames extends string = string>(
options: CreateCronJobOptions = { runOnStart: false }, options: CreateCronJobOptions = { runOnStart: false },
) => { ) => {
creatorOptions.logger.logDebug(`Validating cron expression '${cronExpression}' for job: ${name}`); 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}'`); throw new Error(`Invalid cron expression '${cronExpression}' for job '${name}'`);
} }
creatorOptions.logger.logDebug(`Cron job expression '${cronExpression}' for job ${name} is valid`); 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 // 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 return returnValue as unknown as ValidateCron<TExpression> extends true
? typeof returnValue ? typeof returnValue
: "Invalid cron expression"; : TExpression extends "never"
? typeof returnValue
: "Invalid cron expression";
}; };
}; };

View File

@@ -7,3 +7,4 @@ export const EVERY_10_MINUTES = checkCron("*/10 * * * *") satisfies string;
export const EVERY_HOUR = checkCron("0 * * * *") satisfies string; export const EVERY_HOUR = checkCron("0 * * * *") satisfies string;
export const EVERY_DAY = checkCron("0 0 * * */1") satisfies string; export const EVERY_DAY = checkCron("0 0 * * */1") satisfies string;
export const EVERY_WEEK = checkCron("0 0 * * 1") satisfies string; export const EVERY_WEEK = checkCron("0 0 * * 1") satisfies string;
export const NEVER = "never";

View File

@@ -34,13 +34,13 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
options.logger.logInfo(`Starting schedule cron job ${job.name}.`); options.logger.logInfo(`Starting schedule cron job ${job.name}.`);
await job.onStartAsync(); await job.onStartAsync();
job.scheduledTask.start(); job.scheduledTask?.start();
}, },
startAllAsync: async () => { startAllAsync: async () => {
for (const job of jobRegistry.values()) { for (const job of jobRegistry.values()) {
options.logger.logInfo(`Starting schedule of cron job ${job.name}.`); options.logger.logInfo(`Starting schedule of cron job ${job.name}.`);
await job.onStartAsync(); await job.onStartAsync();
job.scheduledTask.start(); job.scheduledTask?.start();
} }
}, },
runManually: (name: keyof TJobs) => { runManually: (name: keyof TJobs) => {
@@ -48,19 +48,19 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
if (!job) return; if (!job) return;
options.logger.logInfo(`Running schedule cron job ${job.name} manually.`); options.logger.logInfo(`Running schedule cron job ${job.name} manually.`);
job.scheduledTask.now(); job.scheduledTask?.now();
}, },
stop: (name: keyof TJobs) => { stop: (name: keyof TJobs) => {
const job = jobRegistry.get(name as string); const job = jobRegistry.get(name as string);
if (!job) return; if (!job) return;
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`); options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
job.scheduledTask.stop(); job.scheduledTask?.stop();
}, },
stopAll: () => { stopAll: () => {
for (const job of jobRegistry.values()) { for (const job of jobRegistry.values()) {
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`); options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
job.scheduledTask.stop(); job.scheduledTask?.stop();
} }
}, },
getJobRegistry() { getJobRegistry() {

View File

@@ -24,10 +24,12 @@
"dependencies": { "dependencies": {
"@extractus/feed-extractor": "^7.1.3", "@extractus/feed-extractor": "^7.1.3",
"@homarr/analytics": "workspace:^0.1.0", "@homarr/analytics": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0", "@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/cron-jobs-core": "workspace:^0.1.0", "@homarr/cron-jobs-core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/icons": "workspace:^0.1.0", "@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
@@ -41,7 +43,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -11,6 +11,7 @@ import { mediaServerJob } from "./jobs/integrations/media-server";
import { pingJob } from "./jobs/ping"; import { pingJob } from "./jobs/ping";
import type { RssFeed } from "./jobs/rss-feeds"; import type { RssFeed } from "./jobs/rss-feeds";
import { rssFeedsJob } from "./jobs/rss-feeds"; import { rssFeedsJob } from "./jobs/rss-feeds";
import { sessionCleanupJob } from "./jobs/session-cleanup";
import { createCronJobGroup } from "./lib"; import { createCronJobGroup } from "./lib";
export const jobGroup = createCronJobGroup({ export const jobGroup = createCronJobGroup({
@@ -26,6 +27,7 @@ export const jobGroup = createCronJobGroup({
rssFeeds: rssFeedsJob, rssFeeds: rssFeedsJob,
indexerManager: indexerManagerJob, indexerManager: indexerManagerJob,
healthMonitoring: healthMonitoringJob, healthMonitoring: healthMonitoringJob,
sessionCleanup: sessionCleanupJob,
}); });
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number]; export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];

View File

@@ -1,7 +1,6 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import SuperJSON from "superjson"; import SuperJSON from "superjson";
import type { Modify } from "@homarr/common/types";
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; 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(); const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate();
//Asserting the integration kind until all of them get implemented //Asserting the integration kind until all of them get implemented
const integrationInstance = integrationCreatorFromSecrets( const integrationInstance = integrationCreatorFromSecrets(integration);
integration as Modify<typeof integration, { kind: "sonarr" | "radarr" }>,
);
const events = await integrationInstance.getCalendarEventsAsync(start, end); const events = await integrationInstance.getCalendarEventsAsync(start, end);

View 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");
}
});

View File

@@ -35,26 +35,26 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@auth/core": "^0.37.3", "@auth/core": "^0.37.4",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^10.14.0", "@testcontainers/mysql": "^10.15.0",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-kit": "^0.28.1", "drizzle-kit": "^0.28.1",
"drizzle-orm": "^0.36.3", "drizzle-orm": "^0.36.4",
"mysql2": "3.11.4" "mysql2": "3.11.4"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.11", "@types/better-sqlite3": "7.6.12",
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.4",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"tsx": "4.19.2", "tsx": "4.19.2",
"typescript": "^5.6.3" "typescript": "^5.6.3"

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -36,8 +36,13 @@ export type IntegrationPermission = (typeof integrationPermissions)[number];
* For example "board-create" is a generated key * For example "board-create" is a generated key
*/ */
export const groupPermissions = { export const groupPermissions = {
// Order is the same in the UI, inspired from order in navigation here
board: ["create", "view-all", "modify-all", "full-all"], board: ["create", "view-all", "modify-all", "full-all"],
app: ["create", "use-all", "modify-all", "full-all"],
integration: ["create", "use-all", "interact-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, admin: true,
} as const; } as const;
@@ -49,9 +54,21 @@ export const groupPermissions = {
const groupPermissionParents = { const groupPermissionParents = {
"board-modify-all": ["board-view-all"], "board-modify-all": ["board-view-all"],
"board-full-all": ["board-modify-all", "board-create"], "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-interact-all": ["integration-use-all"],
"integration-full-all": ["integration-interact-all", "integration-create"], "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[]>>; } satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey[]>>;
export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => { export const getPermissionsWithParents = (permissions: GroupPermissionKey[]): GroupPermissionKey[] => {

View File

@@ -24,13 +24,13 @@
"dependencies": { "dependencies": {
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.14.0" "@mantine/form": "^7.14.1"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -24,8 +24,8 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@ctrl/deluge": "^6.1.0", "@ctrl/deluge": "^7.0.0",
"@ctrl/qbittorrent": "^9.0.1", "@ctrl/qbittorrent": "^9.1.0",
"@ctrl/transmission": "^7.1.0", "@ctrl/transmission": "^7.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
@@ -41,7 +41,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -12,7 +12,9 @@ import { TransmissionIntegration } from "../download-client/transmission/transmi
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration";
import { RadarrIntegration } from "../media-organizer/radarr/radarr-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 { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration"; import { OverseerrIntegration } from "../overseerr/overseerr-integration";
@@ -64,4 +66,6 @@ export const integrationCreators = {
overseerr: OverseerrIntegration, overseerr: OverseerrIntegration,
prowlarr: ProwlarrIntegration, prowlarr: ProwlarrIntegration,
openmediavault: OpenMediaVaultIntegration, openmediavault: OpenMediaVaultIntegration,
} satisfies Partial<Record<IntegrationKind, new (integration: IntegrationInput) => Integration>>; lidarr: LidarrIntegration,
readarr: ReadarrIntegration,
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;

View File

@@ -16,6 +16,8 @@ export { OverseerrIntegration } from "./overseerr/overseerr-integration";
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration"; export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
export { PlexIntegration } from "./plex/plex-integration"; export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
// Types // Types
export type { IntegrationInput } from "./base/integration"; export type { IntegrationInput } from "./base/integration";

View File

@@ -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)),
});

View File

@@ -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
];
}

View File

@@ -2,24 +2,11 @@ import type { AtLeastOneOf } from "@homarr/common/types";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import { Integration } from "../../base/integration";
import type { CalendarEvent } from "../../calendar-types"; import type { CalendarEvent } from "../../calendar-types";
import { radarrReleaseTypes } from "../../calendar-types"; import { radarrReleaseTypes } from "../../calendar-types";
import { MediaOrganizerIntegration } from "../media-organizer-integration";
export class RadarrIntegration extends Integration { export class RadarrIntegration extends MediaOrganizerIntegration {
/**
* 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
];
/** /**
* Gets the events in the Radarr calendar between two dates. * Gets the events in the Radarr calendar between two dates.
* @param start The start date * @param start The start date
@@ -76,7 +63,7 @@ export class RadarrIntegration extends Integration {
name: "IMDb", name: "IMDb",
color: "#f5c518", color: "#f5c518",
isDark: false, isDark: false,
logo: "/images/apps/imdb.png", logo: "/images/apps/imdb.svg",
}); });
} }

View File

@@ -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)),
});

View File

@@ -1,23 +1,10 @@
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import { Integration } from "../../base/integration";
import type { CalendarEvent } from "../../calendar-types"; import type { CalendarEvent } from "../../calendar-types";
import { MediaOrganizerIntegration } from "../media-organizer-integration";
export class SonarrIntegration extends Integration { export class SonarrIntegration extends MediaOrganizerIntegration {
/**
* 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
];
/** /**
* Gets the events in the Sonarr calendar between two dates. * Gets the events in the Sonarr calendar between two dates.
* @param start The start date * @param start The start date
@@ -75,7 +62,7 @@ export class SonarrIntegration extends Integration {
name: "IMDb", name: "IMDb",
color: "#f5c518", color: "#f5c518",
isDark: false, isDark: false,
logo: "/images/apps/imdb.png", logo: "/images/apps/imdb.svg",
}); });
} }

View File

@@ -34,7 +34,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -30,7 +30,7 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.14.0", "@mantine/core": "^7.14.1",
"@tabler/icons-react": "^3.22.0", "@tabler/icons-react": "^3.22.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "^14.2.18", "next": "^14.2.18",
@@ -40,7 +40,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"

View File

@@ -24,15 +24,15 @@
"dependencies": { "dependencies": {
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.14.0", "@mantine/core": "^7.14.1",
"@mantine/hooks": "^7.14.0", "@mantine/hooks": "^7.14.1",
"react": "^18.3.1" "react": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -24,14 +24,14 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.14.0", "@mantine/notifications": "^7.14.1",
"@tabler/icons-react": "^3.22.0" "@tabler/icons-react": "^3.22.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"

View File

@@ -27,7 +27,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"

View File

@@ -4,27 +4,24 @@ import { tileBaseSchema } from "./tile";
const appBehaviourSchema = z.object({ const appBehaviourSchema = z.object({
externalUrl: z.string(), externalUrl: z.string(),
isOpeningNewTab: z.boolean(), isOpeningNewTab: z.boolean().catch(true),
tooltipDescription: z.string().optional(), tooltipDescription: z.string().optional().catch(undefined),
}); });
const appNetworkSchema = z.object({ const appNetworkSchema = z.object({
enabledStatusChecker: z.boolean(), enabledStatusChecker: z.boolean().catch(true),
okStatus: z.array(z.number()).optional(), okStatus: z.array(z.number()).optional().catch([]),
statusCodes: z.array(z.string()), statusCodes: z.array(z.string()).catch([]),
}); });
const appAppearanceSchema = z.object({ const appAppearanceSchema = z.object({
iconUrl: z.string(), iconUrl: z.string(),
appNameStatus: z.union([z.literal("normal"), z.literal("hover"), z.literal("hidden")]), appNameStatus: z.union([z.literal("normal"), z.literal("hover"), z.literal("hidden")]).catch("normal"),
positionAppName: z.union([ positionAppName: z
z.literal("row"), .union([z.literal("row"), z.literal("column"), z.literal("row-reverse"), z.literal("column-reverse")])
z.literal("column"), .catch("column"),
z.literal("row-reverse"), appNameFontSize: z.number().catch(16),
z.literal("column-reverse"), lineClampAppName: z.number().catch(1),
]),
appNameFontSize: z.number(),
lineClampAppName: z.number(),
}); });
const integrationSchema = z.enum([ const integrationSchema = z.enum([

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -31,11 +31,11 @@
"@homarr/modals-collection": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.14.0", "@mantine/core": "^7.14.1",
"@mantine/hooks": "^7.14.0", "@mantine/hooks": "^7.14.1",
"@mantine/spotlight": "^7.14.0", "@mantine/spotlight": "^7.14.1",
"@tabler/icons-react": "^3.22.0", "@tabler/icons-react": "^3.22.0",
"jotai": "^2.10.2", "jotai": "^2.10.3",
"next": "^14.2.18", "next": "^14.2.18",
"react": "^18.3.1", "react": "^18.3.1",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
@@ -44,7 +44,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import type { Dispatch, SetStateAction } from "react";
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core"; import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
@@ -9,23 +10,42 @@ import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes"; import { searchModes } from "../modes";
import { selectAction, spotlightStore } from "../spotlight-store"; import { selectAction, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group"; import { SpotlightActionGroups } from "./actions/groups/action-group";
type SearchModeKey = keyof TranslationObject["search"]["mode"];
export const Spotlight = () => { export const Spotlight = () => {
const [query, setQuery] = useState(""); const searchModeState = useState<SearchModeKey>("help");
const [mode, setMode] = useState<keyof TranslationObject["search"]["mode"]>("help"); const mode = searchModeState[0];
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]); const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
if (!activeMode) { if (!activeMode) {
return null; 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 ( return (
<MantineSpotlight.Root <MantineSpotlight.Root
yOffset={8} yOffset={8}
@@ -115,7 +135,7 @@ export const Spotlight = () => {
}); });
}} }}
query={query} query={query}
groups={activeMode.groups} groups={groups}
/> />
)} )}
</MantineSpotlight.ActionsList> </MantineSpotlight.ActionsList>

View File

@@ -2,8 +2,14 @@ import type { TranslationObject } from "@homarr/translation";
import type { SearchGroup } from "./group"; import type { SearchGroup } from "./group";
export interface SearchMode { export type SearchMode = {
modeKey: keyof TranslationObject["search"]["mode"]; modeKey: keyof TranslationObject["search"]["mode"];
character: string; character: string;
groups: SearchGroup[]; } & (
} | {
groups: SearchGroup[];
}
| {
useGroups: () => SearchGroup[];
}
);

View File

@@ -1,3 +1,6 @@
import { useSession } from "@homarr/auth/client";
import type { SearchGroup } from "../../lib/group";
import type { SearchMode } from "../../lib/mode"; import type { SearchMode } from "../../lib/mode";
import { appsSearchGroup } from "./apps-search-group"; import { appsSearchGroup } from "./apps-search-group";
import { boardsSearchGroup } from "./boards-search-group"; import { boardsSearchGroup } from "./boards-search-group";
@@ -6,5 +9,14 @@ import { integrationsSearchGroup } from "./integrations-search-group";
export const appIntegrationBoardMode = { export const appIntegrationBoardMode = {
modeKey: "appIntegrationBoard", modeKey: "appIntegrationBoard",
character: "#", 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; } satisfies SearchMode;

View File

@@ -1,11 +1,11 @@
import { Group, Text, useMantineColorScheme } from "@mantine/core"; import { Group, Text, useMantineColorScheme } from "@mantine/core";
import { import {
IconBox,
IconCategoryPlus, IconCategoryPlus,
IconFileImport, IconFileImport,
IconLanguage, IconLanguage,
IconMailForward, IconMailForward,
IconMoon, IconMoon,
IconPackage,
IconPlug, IconPlug,
IconSun, IconSun,
IconUserPlus, IconUserPlus,
@@ -113,9 +113,10 @@ export const commandMode = {
}, },
{ {
commandKey: "newApp", commandKey: "newApp",
icon: IconPackage, icon: IconBox,
name: tOption("newApp.label"), name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })), useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
hidden: !session?.user.permissions.includes("app-create"),
}, },
{ {
commandKey: "newIntegration", commandKey: "newIntegration",

View File

@@ -1,6 +1,7 @@
import { Group, Kbd, Text } from "@mantine/core"; import { Group, Kbd, Text } from "@mantine/core";
import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react"; import { IconBook2, IconBrandDiscord, IconBrandGithub } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { createDocumentationLink } from "@homarr/definitions"; import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -18,58 +19,67 @@ const searchModesWithoutHelp = [userGroupMode, appIntegrationBoardMode, external
const helpMode = { const helpMode = {
modeKey: "help", modeKey: "help",
character: "?", character: "?",
groups: [ useGroups() {
createGroup({ const { data: session } = useSession();
keyPath: "character", const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode];
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}`);
return ( if (session?.user.permissions.includes("admin")) {
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between"> visibleSearchModes.unshift(userGroupMode);
<Text>{t("help")}</Text> }
<Kbd size="sm">{character}</Kbd>
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> </Group>
); ),
}, filter: () => true,
filter: () => true, useInteraction: interaction.link(({ href }) => ({ href, newTab: 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 })),
}),
],
} satisfies SearchMode; } satisfies SearchMode;
export const searchModes = [...searchModesWithoutHelp, helpMode] as const; export const searchModes = [...searchModesWithoutHelp, helpMode] as const;

View File

@@ -130,7 +130,7 @@ export const pagesSearchGroup = createGroup<{
icon: IconLogs, icon: IconLogs,
path: "/manage/tools/logs", path: "/manage/tools/logs",
name: t("manageLog.label"), name: t("manageLog.label"),
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("other-view-logs"),
}, },
{ {
icon: IconReport, icon: IconReport,

View File

@@ -40,7 +40,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -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": { "board": {
"title": "Boards", "title": "Boards",
"item": { "item": {
@@ -237,6 +258,49 @@
"description": "Allow members to manage, use and interact with any integration" "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": { "memberNotice": {
@@ -244,7 +308,7 @@
"external": "All members are from external providers and cannot be managed here" "external": "All members are from external providers and cannot be managed here"
}, },
"reservedNotice": { "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": { "action": {
"create": { "create": {
@@ -2073,6 +2137,9 @@
}, },
"dnsHole": { "dnsHole": {
"label": "DNS Hole Data" "label": "DNS Hole Data"
},
"sessionCleanup": {
"label": "Session Cleanup"
} }
} }
}, },

View File

@@ -28,9 +28,9 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.14.0", "@mantine/core": "^7.14.1",
"@mantine/dates": "^7.14.0", "@mantine/dates": "^7.14.1",
"@mantine/hooks": "^7.14.0", "@mantine/hooks": "^7.14.1",
"@tabler/icons-react": "^3.22.0", "@tabler/icons-react": "^3.22.0",
"mantine-react-table": "2.0.0-beta.7", "mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.18", "next": "^14.2.18",
@@ -41,7 +41,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/css-modules": "^1.0.5", "@types/css-modules": "^1.0.5",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
const findIconsSchema = z.object({ const findIconsSchema = z.object({
searchText: z.string().optional(), searchText: z.string().optional(),
limitPerGroup: z.number().min(1).max(500).default(12),
}); });
export const iconsSchemas = { export const iconsSchemas = {

View File

@@ -40,24 +40,24 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.14.0", "@mantine/core": "^7.14.1",
"@mantine/hooks": "^7.14.0", "@mantine/hooks": "^7.14.1",
"@tabler/icons-react": "^3.22.0", "@tabler/icons-react": "^3.22.0",
"@tiptap/extension-color": "2.9.1", "@tiptap/extension-color": "2.10.2",
"@tiptap/extension-highlight": "2.9.1", "@tiptap/extension-highlight": "2.10.2",
"@tiptap/extension-image": "2.9.1", "@tiptap/extension-image": "2.10.2",
"@tiptap/extension-link": "^2.9.1", "@tiptap/extension-link": "^2.10.2",
"@tiptap/extension-table": "2.9.1", "@tiptap/extension-table": "2.10.2",
"@tiptap/extension-table-cell": "2.9.1", "@tiptap/extension-table-cell": "2.10.2",
"@tiptap/extension-table-header": "2.9.1", "@tiptap/extension-table-header": "2.10.2",
"@tiptap/extension-table-row": "2.9.1", "@tiptap/extension-table-row": "2.10.2",
"@tiptap/extension-task-item": "2.9.1", "@tiptap/extension-task-item": "2.10.2",
"@tiptap/extension-task-list": "2.9.1", "@tiptap/extension-task-list": "2.10.2",
"@tiptap/extension-text-align": "2.9.1", "@tiptap/extension-text-align": "2.10.2",
"@tiptap/extension-text-style": "2.9.1", "@tiptap/extension-text-style": "2.10.2",
"@tiptap/extension-underline": "2.9.1", "@tiptap/extension-underline": "2.10.2",
"@tiptap/react": "^2.9.1", "@tiptap/react": "^2.10.2",
"@tiptap/starter-kit": "^2.9.1", "@tiptap/starter-kit": "^2.10.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"mantine-react-table": "2.0.0-beta.7", "mantine-react-table": "2.0.0-beta.7",
@@ -70,7 +70,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/video.js": "^7.3.58", "@types/video.js": "^7.3.58",
"eslint": "^9.14.0", "eslint": "^9.15.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

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