Compare commits

...

14 Commits

Author SHA1 Message Date
Manuel
46a57c1cf2 config: tag release (#1906) 2024-02-17 14:36:09 +01:00
Thomas Camlong
78527cb375 Merge pull request #1892 from ajnart/ajnart-patch-1 2024-02-10 15:32:27 +01:00
Thomas Camlong
508f687491 Update README.md
Fixed incorrect URLs in the integrations part of the README #1891
2024-02-10 15:31:59 +01:00
Rikpat
9a8ea9e1fe feat: add ldap and oidc support (#1497)
Co-authored-by: Thomas Camlong <49837342+ajnart@users.noreply.github.com>
Co-authored-by: Tagaishi <Tagaishi@hotmail.ch>
2024-02-09 22:57:00 +01:00
Thomas Camlong
b1ae5f700e chore(translations): new Crowdin updates (#1878) 2024-02-09 22:37:26 +01:00
Thomas Camlong
f0a67d9a29 feat: allow up to 8128 characters for links in bookmark widget (#1851) #1850 2024-02-09 22:36:34 +01:00
Yossi Hillali
5d113ea280 Indexer manager (#1807)
* indexer manager widget

Co-authored-by: Tagaishi <Tagaishi@hotmail.ch>
2024-02-09 22:35:56 +01:00
Dennis Vesterlund
d45ae5fab9 feat: add unit display to smart home entitiy card (#1844)
Unit of measurement can be chosen to display on the entity card.
Friendly name can be used instead of displayName.
2024-02-09 22:30:57 +01:00
Tagaishi
02249d20c2 fix: add check for already existing name upon creating board (#1887) 2024-02-09 22:30:03 +01:00
Tagaishi
cefa0d8fde fix: notebook link target bug fix (#1889) 2024-02-09 22:28:23 +01:00
Manuel
4933b4f60d docs: add argos ci to readme (#1883) 2024-02-05 18:18:16 +01:00
Manuel
a60053e6c4 config: chore version (#1876) 2024-02-04 09:12:48 +01:00
Thomas Camlong
6756838d5f chore: new Crowdin updates (#1873) 2024-02-04 09:10:54 +01:00
Manuel
74fe7a7a28 fix: attempt to fix hidden docker button (#1875) 2024-02-04 09:08:04 +01:00
64 changed files with 1387 additions and 426 deletions

View File

@@ -53,34 +53,46 @@ Simplify the management of your server with Homarr - a sleek, modern dashboard t
- 🦞 Comprehensive built-in icon picker with over 7000 icons
- 🐳 Easy deployment with Docker, unRAID, and Synology
- 🚀 Compatible with any major consumer hardware (x86, Raspberry Pi, old laptops, ...)
- 💵 Free and Open-Source - your data stays on your device. No telemetry data.
<br/>
<br/>
![Widgets & Integrations Section](docs/section-widgets-and-integrations.png)
Homarr has a [built-in collection of widgets and integrations](https://homarr.dev/docs/integrations/), that connect to your applications and enable you to control them directly from the dashboard.
Each widget and integration has a comprehensive documentation for your comfort.
Homarr will integrate with the following applications of yours:
Homarr has a [built-in collection of widgets and integrations](https://homarr.dev/docs/management/integrations/), that connect to your applications and enable you to control them directly from the dashboard.
Each widget and integration has a comprehensive documentation
Homarr will integrate with the following applications:
- 📥 Torrent clients
- [Deluge](https://homarr.dev/docs/integrations/#deluge)
- [Transmission](https://homarr.dev/docs/integrations/#transmission)
- [qBittorent](https://homarr.dev/docs/integrations/#qbittorrent-integration)
- 📥 Usenet clients
- [SABnzbd](https://homarr.dev/docs/integrations/#sabnzbd)
- [NZBGet](https://homarr.dev/docs/integrations/#nzbget)
- 📚 Media collection managers
- [Sonarr](https://homarr.dev/docs/integrations/#sonarr)
- [Radarr](https://homarr.dev/docs/integrations/#radarr)
- [Lidarr](https://homarr.dev/docs/integrations/#lidarr)
- [Readarr](https://homarr.dev/docs/integrations/#readarr)
- 🎞️ Media request managers
- [Overseerr](https://homarr.dev/docs/integrations/#overseerr--jellyseerr)
- [Jellyseerr](https://homarr.dev/docs/integrations/#overseerr--jellyseerr)
- 🔌 [Dash.](https://homarr.dev/docs/integrations/#dash)
- 🐳 [Docker](https://homarr.dev/docs/integrations/#docker)
📥 Torrent clients
- [Deluge](https://homarr.dev/docs/management/integrations/torrent-deluge)
- [Transmission](https://homarr.dev/docs/management/integrations/torrent-transmission)
- [qBittorent](https://homarr.dev/docs/management/integrations/torrent-qbittorrent)
📥 Usenet clients
- [SABnzbd](https://homarr.dev/docs/management/integrations/usenet-sabnzbd)
- [NZBGet](https://homarr.dev/docs/management/integrations/usenet-nzbget)
📺 Media servers
- [Plex](https://homarr.dev/docs/management/integrations/media-server-plex)
- [Jellyfin](https://homarr.dev/docs/management/integrations/media-server-jellyfin)
📚 Media collection managers
- [Sonarr](https://homarr.dev/docs/management/integrations/servarr-sonarr)
- [Radarr](https://homarr.dev/docs/management/integrations/servarr-radarr)
- [Lidarr](https://homarr.dev/docs/management/integrations/servarr-lidarr)
- [Readarr](https://homarr.dev/docs/management/integrations/servarr-readarr)
🎞️ Media request managers
- [Overseerr](https://homarr.dev/docs/management/integrations/media-requester/)
- [Jellyseerr](https://homarr.dev/docs/management/integrations/media-requester/)
🚫 DNS ad-blockers
- [Pihole](https://homarr.dev/docs/management/integrations/dns-pihole)
- [AdGuard Home](https://homarr.dev/docs/management/integrations/dns-adguard-home)
Other integrations
- [🔌 Dash.](https://homarr.dev/docs/management/integrations/hardware-dash)
- [🐳 Docker](https://homarr.dev/docs/management/integrations/containers-docker)
We're constantly adding new integrations and widgets, which will enhance your experience even further.
@@ -123,3 +135,4 @@ You can also support us by helping with [translating the entire project](https:/
All contributions, regardless of their size or scope, are welcome and highly appreciated! Thank you ❤️
![Alt](https://repobeats.axiom.co/api/embed/60a6f68f193faf831f64221bdf90782adec51c93.svg "Repobeats analytics image")
[![Covered by Argos Visual Testing](https://argos-ci.com/badge-large.svg)](https://argos-ci.com?utm_source=%5Bhomarr%5D&utm_campaign=oss)

View File

@@ -6,6 +6,11 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
});
module.exports = withBundleAnalyzer({
webpack: (config) => {
// for dynamic loading of auth providers
config.experiments = { ...config.experiments, topLevelAwait: true };
return config;
},
images: {
domains: ['cdn.jsdelivr.net'],
},

View File

@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "0.14.5",
"version": "0.15.0",
"description": "Homarr - A homepage for your server.",
"license": "MIT",
"repository": {
@@ -27,7 +27,6 @@
"db:migrate": "dotenv ts-node drizzle/migrate/migrate.ts ./drizzle"
},
"dependencies": {
"@auth/drizzle-adapter": "^0.3.2",
"@ctrl/deluge": "^4.1.0",
"@ctrl/qbittorrent": "^6.0.0",
"@ctrl/shared-torrent": "^4.1.1",
@@ -92,9 +91,8 @@
"i18next": "^22.5.1",
"immer": "^10.0.2",
"js-file-download": "^0.4.12",
"ldapjs": "^3.0.5",
"mantine-react-table": "^1.3.4",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"next": "13.4.12",
"next-auth": "^4.23.0",
"next-i18next": "^14.0.0",
@@ -123,6 +121,7 @@
"@types/better-sqlite3": "^7.6.5",
"@types/cookies": "^0.7.7",
"@types/dockerode": "^3.3.9",
"@types/ldapjs": "^3.0.2",
"@types/node": "18.17.8",
"@types/prismjs": "^1.26.0",
"@types/react": "^18.2.11",

View File

@@ -0,0 +1,19 @@
{
"descriptor": {
"name": "Indexer manager status",
"description": "Status about your indexers",
"settings": {
"title": "Indexer manager status"
}
},
"indexersStatus": {
"title": "Indexer manager",
"testAllButton": "Test all"
},
"errors": {
"general": {
"title": "Unable to find a indexer manager",
"text": "There was a problem connecting to your indexer manager. Please verify your configuration/integration(s)."
}
}
}

View File

@@ -9,12 +9,20 @@
"label": "Entity ID",
"info": "Unique entity ID in Home Assistant. Copy by clicking on entity > Click on cog icon > Click on copy button at 'Entity ID'. Some custom entities may not be supported."
},
"appendUnit": {
"label": "Append unit of measurement",
"info": "Append the unit of measurement attribute to the entity state."
},
"automationId": {
"label": "Optional automation ID",
"info": "Your unique automation ID. Always starts with automation.XXXXX. If not set, widget will not be clickable and only display state. After click, entity state will be refreshed."
},
"displayName": {
"label": "Display name"
},
"displayFriendlyName": {
"label": "Display friendly name",
"info": "Display friendly name from Home Assistant instead instead of display name"
}
}
}

View File

@@ -22,10 +22,10 @@
"label": "Ordina per data di pubblicazione (ascendente)"
},
"sortPostsWithoutPublishDateToTheTop": {
"label": ""
"label": "Metti i post senza data di pubblicazione in alto"
},
"maximumAmountOfPosts": {
"label": ""
"label": "Numero massimo di post"
}
},
"card": {

View File

@@ -13,7 +13,7 @@
"previous": "Iepriekšējais",
"confirm": "Apstipriniet",
"enabled": "Iespējots",
"duplicate": "",
"duplicate": "Dublicēt",
"disabled": "Atspējots",
"enableAll": "Iespējot visu",
"disableAll": "Atspējot visu",
@@ -54,5 +54,5 @@
"height": "Augstums"
},
"public": "Publisks",
"restricted": ""
"restricted": "Ierobežots"
}

View File

@@ -22,5 +22,5 @@
"message": "Ir izveidota kategorija \"{{name}}\""
}
},
"importFromDocker": ""
"importFromDocker": "Importēt no Docker"
}

View File

@@ -16,15 +16,15 @@
"label": "Neatgriezeniski dzēst",
"disabled": "Dzēšana atspējota, jo vecāki Homarr komponenti neļauj dzēst noklusējuma konfigurāciju. Dzēšana būs iespējama nākotnē."
},
"duplicate": "",
"duplicate": "Dublicēt",
"rename": {
"label": "",
"label": "Pārdēvēt",
"modal": {
"title": "",
"title": "Pārdēvēt dēli {{name}}",
"fields": {
"name": {
"label": "",
"placeholder": ""
"label": "Jauns nosaukums",
"placeholder": "Jauns dēļa nosaukums"
}
}
}

View File

@@ -6,10 +6,10 @@
},
"filter": {
"roles": {
"all": "",
"normal": "",
"admin": "",
"owner": ""
"all": "Viss",
"normal": "Normāls",
"admin": "Administrators",
"owner": "Īpašnieks"
}
},
"table": {

View File

@@ -1,6 +1,6 @@
{
"metaTitle": "",
"back": "",
"metaTitle": "Lietotājs {{username}}",
"back": "Atgriezties uz lietotāju pārvaldību",
"sections": {
"general": {
"title": "Vispārīgi",
@@ -14,40 +14,40 @@
}
},
"security": {
"title": "",
"title": "Drošība",
"inputs": {
"password": {
"label": ""
"label": "Jauna parole"
},
"terminateExistingSessions": {
"label": "",
"description": ""
"label": "Pārtraukt esošās sesijas",
"description": "Piespiež lietotāju no jauna pieteikties savās ierīcēs"
},
"confirm": {
"label": "Apstipriniet",
"description": ""
"description": "Parole tiks atjaunināta. Šo darbību nevar atcelt."
}
}
},
"roles": {
"title": "",
"currentRole": "",
"title": "Lomas",
"currentRole": "Pašreizējā loma: ",
"badges": {
"owner": "",
"admin": "",
"normal": ""
"owner": "Īpašnieks",
"admin": "Administrators",
"normal": "Normāls"
}
},
"deletion": {
"title": "",
"title": "Konta dzēšana",
"inputs": {
"confirmUsername": {
"label": "",
"description": ""
"label": "Apstiprināt lietotājvārdu",
"description": "Ierakstiet lietotājvārdu, lai apstiprinātu dzēšanu"
},
"confirm": {
"label": "Neatgriezeniski dzēst",
"description": ""
"description": "Es apzinos, ka šī darbība ir neatgriezeniska un visi konta dati tiks zaudēti."
}
}
}

View File

@@ -5,11 +5,11 @@
"settings": {
"title": "Datuma un Laika logrīka iestatījumi",
"timezone": {
"label": "",
"info": ""
"label": "Laika zona",
"info": "Izvēlieties savas laika zonas nosaukumu, atrodiet savējo šeit: "
},
"customTitle": {
"label": ""
"label": "Pilsētas nosaukums vai pielāgots nosaukums"
},
"display24HourFormat": {
"label": "Rādīt pilnu laiku (24 stundu)"
@@ -21,11 +21,11 @@
}
},
"titleState": {
"label": "",
"info": "",
"label": "Pulksteņa nosaukums",
"info": "Pielāgotais nosaukums un laika zonas kods var tikt parādīts jūsu logrīkā.<br/>Varat arī rādīt tikai pilsētu, nerādīt nevienu,<br/>vai pat rādīt tikai laika joslu, gadījumā ja ir atlasīti abi, bet nav norādīts nosaukums.",
"data": {
"both": "",
"city": "",
"both": "Pilsēta un Laika zona",
"city": "Tikai nosaukums",
"none": "Nekas"
}
}

View File

@@ -19,13 +19,13 @@
"label": "Teksta līniju skava"
},
"sortByPublishDateAscending": {
"label": ""
"label": "Kārtot pēc publicēšanas datuma (augošā secībā)"
},
"sortPostsWithoutPublishDateToTheTop": {
"label": ""
"label": "Ievietot ziņas bez publicēšanas datuma augšpusē"
},
"maximumAmountOfPosts": {
"label": ""
"label": "Maksimālais ierakstu skaits"
}
},
"card": {

View File

@@ -1,20 +1,20 @@
{
"entityNotFound": "",
"entityNotFound": "Vienība nav atrasta",
"descriptor": {
"name": "",
"description": "",
"name": "Home Assistant vienība",
"description": "Vienības pašreizējais stāvoklis pakalpojumā Home Assistant",
"settings": {
"title": "",
"title": "Vienības stāvoklis",
"entityId": {
"label": "",
"info": ""
"label": "Vienības ID",
"info": "Unikāls vienības ID pakalpojumā Home Assistant. Ievietojiet starpliktuvē, noklikšķinot uz vienību > Noklikšķiniet uz zobrata ikonu > Noklikšķiniet uz kopēšanas pogu pie \"Vienības ID\". Dažas pielāgotas vienības var nebūt atbalstītas."
},
"automationId": {
"label": "",
"info": ""
"label": "Izvēles automatizācijas ID",
"info": "Jūsu unikālais automatizācijas ID. Vienmēr sākas ar automatizāciju.XXXX. Ja tas nav iestatīts, logrīks nebūs noklikšķināms un tiks parādīts tikai statuss. Pēc noklikšķināšanas vienības stāvoklis tiks atsvaidzināts."
},
"displayName": {
"label": ""
"label": "Parādāmais nosaukums"
}
}
}

View File

@@ -1,15 +1,15 @@
{
"descriptor": {
"name": "",
"description": "",
"name": "Home Assistant automatizācija",
"description": "Automatizācijas izpilde",
"settings": {
"title": "",
"title": "Automatizācijas izpilde",
"automationId": {
"label": "",
"info": ""
"label": "Automatizācijas ID",
"info": "Jūsu unikālais automatizācijas ID. Vienmēr sāksies ar automation.XXXXX."
},
"displayName": {
"label": ""
"label": "Parādāmais nosaukums"
}
}
}

View File

@@ -41,22 +41,22 @@
},
"table": {
"header": {
"isCompleted": "",
"isCompleted": "Lejupielādē",
"name": "Nosaukums",
"dateAdded": "",
"dateAdded": "Pievienots",
"size": "Lielums",
"download": "Lejupielāde",
"upload": "Augšupielāde",
"estimatedTimeOfArrival": "ETA",
"progress": "Progress",
"totalUploaded": "",
"totalDownloaded": "",
"ratio": "",
"seeds": "",
"peers": "",
"label": "",
"totalUploaded": "Kopējā Augšupielāde",
"totalDownloaded": "Kopējā Lejupielāde",
"ratio": "Attiecība",
"seeds": "Devēji (savienoti)",
"peers": "Ņēmēji (savienoti)",
"label": "Birka",
"state": "Stāvoklis",
"stateMessage": ""
"stateMessage": "Statusa Ziņojums"
},
"item": {
"text": "Pārvalda {{appName}}, {{ratio}} attiecība"

View File

@@ -19,26 +19,26 @@
"label": "Fons"
},
"backgroundImageAttachment": {
"label": "",
"label": "Fona attēla pielikums",
"options": {
"fixed": "",
"scroll": ""
"fixed": "Fiksēts - fons paliek nemainīgā pozīcijā (ieteicams)",
"scroll": "Ritināšana - fons ritinās līdz ar kursora ritināšanu"
}
},
"backgroundImageSize": {
"label": "",
"label": "Fona attēla izmērs",
"options": {
"cover": "",
"contain": ""
"cover": "Pārklājums - pēc iespējas mazāks attēla mērogs, lai, apgriežot lieko vietu, pārklātu visu logu. (ieteicams)",
"contain": "Saturēt — mērogo attēlu pēc iespējas lielāku tā konteinerā, neapgriežot vai neizstiepjot attēlu."
}
},
"backgroundImageRepeat": {
"label": "",
"label": "Fona attēla pielikums",
"options": {
"repeat": "",
"no-repeat": "",
"repeat-x": "",
"repeat-y": ""
"repeat": "Atkārtot — attēls tiek atkārtots tik daudz, cik nepieciešams, lai aptvertu visu fona laukumu.",
"no-repeat": "Bez atkārtojuma - attēls neatkārtojas un var neaizpildīt visu fona laukumu (ieteicams)",
"repeat-x": "Atkārtot X - tāpat kā \"Atkārtot\", bet tikai uz horizontālās ass.",
"repeat-y": "Atkārtot Y - tāpat kā \"Atkārtot\", bet tikai uz vertikālās ass."
}
},
"customCSS": {

View File

@@ -2,7 +2,7 @@
"title": "Docker",
"alerts": {
"notConfigured": {
"text": ""
"text": "Jūsu Homarr instancē nav konfigurēts Docker vai arī nav izdevies iegūtu konteinerus. Lūdzu, pārbaudiet dokumentāciju par to, kā iestatīt integrāciju."
}
},
"modals": {

View File

@@ -13,7 +13,7 @@
"previous": "Tidligere",
"confirm": "Bekreft",
"enabled": "Aktivert",
"duplicate": "",
"duplicate": "Dupliser",
"disabled": "Deaktivert",
"enableAll": "Aktiver alle",
"disableAll": "Deaktiver alle",

View File

@@ -22,5 +22,5 @@
"message": "Kategorien \"{{name}}\" er opprettet"
}
},
"importFromDocker": ""
"importFromDocker": "Importer fra docker"
}

View File

@@ -13,7 +13,7 @@
"switchTheme": "Bytt tema",
"preferences": "Brukerinnstillinger",
"defaultBoard": "Standard dashbord",
"manage": "Endre",
"manage": "Administrer",
"logout": "Logg ut fra {{username}}",
"login": "Logg Inn"
}

View File

@@ -9,7 +9,7 @@
"users": {
"title": "Brukere",
"items": {
"manage": "Endre",
"manage": "Administrer",
"invites": "Invitasjoner"
}
},
@@ -26,7 +26,7 @@
"title": "Verktøy",
"items": {
"docker": "Docker",
"api": ""
"api": "API"
}
},
"about": {

View File

@@ -1,6 +1,6 @@
{
"description": "Homarr er et <strong>sleek</strong>, <strong>moderne</strong> dashbord som legger alle apper og tjenester ved fingertuppene dine. Med Homarr, kan du få tilgang til og kontrollere alt på et bekvemt sted. Homarr sømløst integrerer med appene du har lagt til, gir deg verdifull informasjon og gir deg full kontroll. Installasjon er en lek, og Homarr støtter en lang rekke distribusjonsmetoder.",
"addToDashboard": "Legg til på dashbord",
"addToDashboard": "Legg til tavlen",
"tip": "Mod refererer til endringstasten din, den er Ctrl og Command/Super/Windows tasten",
"key": "Hurtigtast",
"action": "Handling",
@@ -20,7 +20,7 @@
"version": "Versjon",
"nodeEnvironment": "Node miljø",
"i18n": "Lastet I18n oversettelsesnavneområder",
"locales": "Konfigurerte I18n-lokasjoner",
"locales": "Konfigurerte I18n-lokaliseringer",
"experimental_disableEditMode": "<b>EXPERIMENTAL</b>: Deaktivere redigeringsmodus"
},
"version": {

View File

@@ -16,15 +16,15 @@
"label": "Slett permanent",
"disabled": "Sletting er deaktivert fordi eldre Homarr-komponenter ikke tillater sletting av standardkonfigurasjonen. Sletting vil være mulig i fremtiden."
},
"duplicate": "",
"duplicate": "Dupliser",
"rename": {
"label": "",
"label": "Gi nytt navn",
"modal": {
"title": "",
"title": "Gi nytt navn til tavlen {{name}}",
"fields": {
"name": {
"label": "",
"placeholder": ""
"label": "Nytt navn",
"placeholder": "Nytt tavlenavn"
}
}
}

View File

@@ -6,10 +6,10 @@
},
"filter": {
"roles": {
"all": "",
"normal": "",
"admin": "",
"owner": ""
"all": "Alle",
"normal": "Normal",
"admin": "Administrator",
"owner": "Eier"
}
},
"table": {

View File

@@ -1,6 +1,6 @@
{
"metaTitle": "",
"back": "",
"metaTitle": "Bruker {{username}}",
"back": "Tilbake til brukeradministrasjon",
"sections": {
"general": {
"title": "Generelt",
@@ -14,40 +14,40 @@
}
},
"security": {
"title": "",
"title": "Sikkerhet",
"inputs": {
"password": {
"label": ""
"label": "Nytt passord"
},
"terminateExistingSessions": {
"label": "",
"description": ""
"label": "Avslutt eksisterende økter",
"description": "Tvinger brukeren til å logge på igjen på enhetene sine"
},
"confirm": {
"label": "Bekreft",
"description": ""
"description": "Passordet vil bli oppdatert. Handlingen kan ikke tilbakestilles."
}
}
},
"roles": {
"title": "",
"currentRole": "",
"title": "Roller",
"currentRole": "Nåværende rolle: ",
"badges": {
"owner": "",
"admin": "",
"normal": ""
"owner": "Eier",
"admin": "Administrator",
"normal": "Normal"
}
},
"deletion": {
"title": "",
"title": "Konto sletting",
"inputs": {
"confirmUsername": {
"label": "",
"description": ""
"label": "Bekreft brukernavn",
"description": "Skriv inn brukernavn for å bekrefte sletting"
},
"confirm": {
"label": "Slett permanent",
"description": ""
"description": "Jeg er klar over at denne handlingen er permanent og alle kontodata vil gå tapt."
}
}
}

View File

@@ -5,11 +5,11 @@
"settings": {
"title": "Innstillinger for dato og klokkeslett widget",
"timezone": {
"label": "",
"info": ""
"label": "Tidssone",
"info": "Velg navnet på tidssonen din, finn din her: "
},
"customTitle": {
"label": ""
"label": "Bynavn eller egendefinert tittel"
},
"display24HourFormat": {
"label": "Vis 24 timers formatering"
@@ -21,11 +21,11 @@
}
},
"titleState": {
"label": "",
"info": "",
"label": "Klokketittel",
"info": "Den egendefinerte tittelen og tidssonekoden kan vises på widgeten din.<br/>Du kan også vise byen alene, vise ingen,<br/>eller til og med vise tidssonen alene når begge er valgt, men ingen tittel er gitt.",
"data": {
"both": "",
"city": "",
"both": "Tittel og tidssone",
"city": "Kun tittel",
"none": "Ingen"
}
}

View File

@@ -1,6 +1,6 @@
{
"descriptor": {
"name": "Nedlastingshastighet",
"name": "Nedlastings- hastighet",
"description": "Viser nedlastingshastighet og opplastingshastighet av støttede integrasjoner."
},
"card": {

View File

@@ -17,8 +17,8 @@
"approved": "Godkjent",
"pendingApproval": "Venter på godkjenning",
"declined": "Avvist",
"available": "",
"partial": ""
"available": "Tilgjengelig",
"partial": "Delvis"
},
"tooltips": {
"approve": "Godkjenne forespørsler",

View File

@@ -19,13 +19,13 @@
"label": "Tekstlinjer klemme"
},
"sortByPublishDateAscending": {
"label": ""
"label": "Sorter etter publiseringsdato (stigende)"
},
"sortPostsWithoutPublishDateToTheTop": {
"label": ""
"label": "Sett innlegg uten publiseringsdato øverst"
},
"maximumAmountOfPosts": {
"label": ""
"label": "Maksimalt antall innlegg"
}
},
"card": {

View File

@@ -10,8 +10,8 @@
"info": "Unik enhets-ID i Home Assistant. Kopier ved å klikke på enhet > Klikk på tannhjulikonet > Klikk på kopieringsknappen ved Entitets-ID. Noen egendefinerte enheter støttes kanskje ikke."
},
"automationId": {
"label": "",
"info": ""
"label": "Valgfri automatisering ID",
"info": "Din unike automatisering ID. Starter alltid med automation.XXXXX. Hvis den ikke er angitt, vil widgeten ikke være klikkbar og kun vise status Etter klikk vil enhetsstatus bli oppdatert."
},
"displayName": {
"label": "Visningsnavn"

View File

@@ -1,12 +1,12 @@
{
"descriptor": {
"name": "",
"description": "",
"name": "Home Assistant automatisering",
"description": "Utfør en automatisering",
"settings": {
"title": "",
"title": "Utfør en automatisering",
"automationId": {
"label": "",
"info": ""
"label": "Automatisering ID",
"info": "Din unike automatisering ID. Starter alltid med automation.XXXXX."
},
"displayName": {
"label": "Visningsnavn"

View File

@@ -22,10 +22,10 @@
"label": "Zoradiť podľa dátumu vydania (vzostupne)"
},
"sortPostsWithoutPublishDateToTheTop": {
"label": ""
"label": "Umiestnite príspevky bez dátumu uverejnenia na začiatok"
},
"maximumAmountOfPosts": {
"label": ""
"label": "Maximálny počet príspevkov"
}
},
"card": {

View File

@@ -17,8 +17,8 @@
"disabled": "Pasif",
"enableAll": "Tümünü etkinleştir",
"disableAll": "Tümünü pasifleştir",
"version": "Versiyon",
"changePosition": "Pozisyon değiştir",
"version": "Sürüm",
"changePosition": "Pozisyonu değiştir",
"remove": "Kaldır",
"removeConfirm": "{{item}}'i kaldırmak istediğinizden emin misiniz?",
"createItem": "+ yeni {{item}}",

View File

@@ -163,6 +163,11 @@ export const availableIntegrations = [
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png',
label: 'Readarr',
},
{
value: 'prowlarr',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/prowlarr.png',
label: 'Prowlarr',
},
{
value: 'jellyfin',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png',
@@ -186,6 +191,6 @@ export const availableIntegrations = [
{
value: 'homeAssistant',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png',
label: 'Home Assistant'
}
label: 'Home Assistant',
},
] as const satisfies Readonly<SelectItem[]>;

View File

@@ -9,12 +9,15 @@ import { createBoardSchemaValidation } from '~/validations/boards';
export const CreateBoardModal = ({ id }: ContextModalProps<{}>) => {
const { t } = useTranslation('manage/boards');
const utils = api.useContext();
const utils = api.useUtils();
const { isLoading, mutate } = api.config.save.useMutation({
onSuccess: async () => {
await utils.boards.all.invalidate();
modals.close(id);
},
onError: async (error) => {
form.setFieldError('name', error.message);
},
});
const { i18nZodResolver } = useI18nZodResolver();
@@ -31,6 +34,7 @@ export const CreateBoardModal = ({ id }: ContextModalProps<{}>) => {
mutate({
name: form.values.name,
config: fallbackConfig,
create: true,
});
};
@@ -59,7 +63,7 @@ export const CreateBoardModal = ({ id }: ContextModalProps<{}>) => {
<Button
type="submit"
onClick={async () => {
umami.track('Create new board')
umami.track('Create new board');
}}
disabled={isLoading}
variant="light"

View File

@@ -111,7 +111,7 @@ export const ReviewInputStep = ({ values, prevStep, nextStep }: ReviewInputStepP
password: values.security.password,
email: values.account.eMail === '' ? undefined : values.account.eMail,
});
umami.track('Create user', { username: values.account.username});
umami.track('Create user', { username: values.account.username });
}}
loading={isLoading}
rightIcon={<IconCheck size="1rem" />}

View File

@@ -57,9 +57,12 @@ export const StepCreateAccount = ({
Create your administrator account
</Title>
<Text>
Your administrator account <b>must be secure</b>, that's why we have so many rules surrounding it.
<br/>Try not to make it adminadmin this time...
<br/>Note: these password requirements <b>are not forced</b>, they are just recommendations.
Your administrator account <b>must be secure</b>, that's why we have so many rules
surrounding it.
<br />
Try not to make it adminadmin this time...
<br />
Note: these password requirements <b>are not forced</b>, they are just recommendations.
</Text>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>

View File

@@ -1,6 +1,14 @@
const { z } = require('zod');
const { createEnv } = require('@t3-oss/env-nextjs');
const trueStrings = ["1", "t", "T", "TRUE", "true", "True"];
const falseStrings = ["0", "f", "F", "FALSE", "false", "False"];
const zodParsedBoolean = () => z
.enum([...trueStrings, ...falseStrings])
.default("false")
.transform((value) => trueStrings.includes(value))
const portSchema = z
.string()
.regex(/\d*/)
@@ -8,6 +16,8 @@ const portSchema = z
.optional();
const envSchema = z.enum(['development', 'test', 'production']);
const authProviders = process.env.AUTH_PROVIDER?.replaceAll(' ', '').split(',') || ['credentials'];
const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
@@ -28,6 +38,37 @@ const env = createEnv({
DOCKER_PORT: portSchema,
DEMO_MODE: z.string().optional(),
HOSTNAME: z.string().optional(),
// Authentication
AUTH_PROVIDER: z.string().default('credentials').transform(providers => providers.replaceAll(' ', '').split(',')),
// LDAP
...(authProviders.includes('ldap')
? {
AUTH_LDAP_URI: z.string().url(),
AUTH_LDAP_BIND_DN: z.string(),
AUTH_LDAP_BIND_PASSWORD: z.string(),
AUTH_LDAP_BASE: z.string(),
AUTH_LDAP_USERNAME_ATTRIBUTE: z.string().default('uid'),
AUTH_LDAP_GROUP_CLASS: z.string().default('groupOfUniqueNames'),
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: z.string().default('member'),
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: z.string().default('dn'),
AUTH_LDAP_ADMIN_GROUP: z.string().default('admin'),
AUTH_LDAP_OWNER_GROUP: z.string().default('admin'),
}
: {}),
// OIDC
...(authProviders.includes('oidc')
? {
AUTH_OIDC_CLIENT_ID: z.string(),
AUTH_OIDC_CLIENT_SECRET: z.string(),
AUTH_OIDC_URI: z.string().url(),
// Custom Display name, defaults to OIDC
AUTH_OIDC_CLIENT_NAME: z.string().default('OIDC'),
AUTH_OIDC_ADMIN_GROUP: z.string().default('admin'),
AUTH_OIDC_OWNER_GROUP: z.string().default('admin'),
AUTH_OIDC_AUTO_LOGIN: zodParsedBoolean()
}
: {}),
},
/**
@@ -64,6 +105,25 @@ const env = createEnv({
NEXT_PUBLIC_PORT: process.env.PORT,
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
HOSTNAME: process.env.HOSTNAME,
AUTH_PROVIDER: process.env.AUTH_PROVIDER,
AUTH_LDAP_URI: process.env.AUTH_LDAP_URI,
AUTH_LDAP_BIND_DN: process.env.AUTH_LDAP_BIND_DN,
AUTH_LDAP_BIND_PASSWORD: process.env.AUTH_LDAP_BIND_PASSWORD,
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
AUTH_LDAP_USERNAME_ATTRIBUTE: process.env.AUTH_LDAP_USERNAME_ATTRIBUTE,
AUTH_LDAP_GROUP_CLASS: process.env.AUTH_LDAP_GROUP_CLASS,
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE,
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE,
AUTH_LDAP_ADMIN_GROUP: process.env.AUTH_LDAP_ADMIN_GROUP,
AUTH_LDAP_OWNER_GROUP: process.env.AUTH_LDAP_OWNER_GROUP,
AUTH_OIDC_CLIENT_ID: process.env.AUTH_OIDC_CLIENT_ID,
AUTH_OIDC_CLIENT_SECRET: process.env.AUTH_OIDC_CLIENT_SECRET,
AUTH_OIDC_URI: process.env.AUTH_OIDC_URI,
AUTH_OIDC_CLIENT_NAME: process.env.AUTH_OIDC_CLIENT_NAME,
AUTH_OIDC_GROUP_CLAIM: process.env.AUTH_OIDC_GROUP_CLAIM,
AUTH_OIDC_ADMIN_GROUP: process.env.AUTH_OIDC_ADMIN_GROUP,
AUTH_OIDC_OWNER_GROUP: process.env.AUTH_OIDC_OWNER_GROUP,
AUTH_OIDC_AUTO_LOGIN: process.env.AUTH_OIDC_AUTO_LOGIN,
DEMO_MODE: process.env.DEMO_MODE,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,

View File

@@ -10,6 +10,7 @@ const skippedUrls = [
'/favicon.ico',
'/404',
'/pages/_app',
'/auth/login',
'/imgs/',
];
@@ -29,12 +30,15 @@ export async function middleware(req: NextRequest) {
}
// Do not redirect if there are users in the database
if (cachedUserCount > 0) {
return NextResponse.next();
}
if (cachedUserCount > 0 || !(await shouldRedirectToOnboard())) {
// redirect to login if not logged in
// not working, should work in next-auth 5
// @see https://github.com/nextauthjs/next-auth/pull/7443
// Do not redirect if there are users in the database
if (!(await shouldRedirectToOnboard())) {
// const session = await getServerSession();
// if (!session?.user) {
// return NextResponse.redirect(getUrl(req) + '/auth/login')
// }
return NextResponse.next();
}

View File

@@ -125,8 +125,7 @@ export default function AuthInvitePage() {
withAsterisk
{...form.getInputProps('password')}
/>
<Card
>
<Card>
<PasswordRequirements value={form.values.password} />
</Card>

View File

@@ -1,13 +1,24 @@
import { Alert, Button, Card, Flex, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core';
import {
Alert,
Button,
Card,
Divider,
Flex,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconAlertTriangle } from '@tabler/icons-react';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next';
import { signIn } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import Head from 'next/head';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle';
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
@@ -17,8 +28,13 @@ import { getServerSideTranslations } from '~/tools/server/getServerSideTranslati
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { signInSchema } from '~/validations/user';
const signInSchemaWithProvider = signInSchema.extend({ provider: z.string() });
export default function LoginPage({
redirectAfterLogin,
providers,
oidcProviderName,
oidcAutoLogin,
isDemo,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { t } = useTranslation('authentication/login');
@@ -27,16 +43,18 @@ export default function LoginPage({
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const form = useForm<z.infer<typeof signInSchema>>({
const hasCredentialsInput = providers.includes('credentials') || providers.includes('ldap');
const form = useForm<z.infer<typeof signInSchemaWithProvider>>({
validateInputOnChange: true,
validateInputOnBlur: true,
validate: i18nZodResolver(signInSchema),
validate: i18nZodResolver(signInSchemaWithProvider),
});
const handleSubmit = (values: z.infer<typeof signInSchema>) => {
const handleSubmit = (values: z.infer<typeof signInSchemaWithProvider>) => {
setIsLoading(true);
setIsError(false);
signIn('credentials', {
signIn(values.provider, {
redirect: false,
name: values.name,
password: values.password,
@@ -51,6 +69,10 @@ export default function LoginPage({
});
};
useEffect(() => {
if (oidcAutoLogin) signIn('oidc');
}, [oidcAutoLogin]);
const metaTitle = `${t('metaTitle')} • Homarr`;
return (
@@ -58,7 +80,6 @@ export default function LoginPage({
<Head>
<title>{metaTitle}</title>
</Head>
<Flex h="100dvh" display="flex" w="100%" direction="column" align="center" justify="center">
<FloatingBackground />
<ThemeSchemeToggle pos="absolute" top={20} right={20} />
@@ -83,51 +104,94 @@ export default function LoginPage({
<b>demodemo</b>
</Alert>
)}
<Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={450}>
<Title style={{ whiteSpace: 'nowrap' }} align="center" weight={900}>
{t('title')}
</Title>
{oidcAutoLogin ? (
<Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={450}>
<Text size="lg" align="center" m="md">
Signing in with OIDC provider
</Text>
</Card>
) : (
<Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={450}>
<Title style={{ whiteSpace: 'nowrap' }} align="center" weight={900}>
{t('title')}
</Title>
<Text color="dimmed" size="sm" align="center" mt={5} mb="md">
{t('text')}
</Text>
<Text color="dimmed" size="sm" align="center" mt={5} mb="md">
{t('text')}
</Text>
{isError && (
<Alert icon={<IconAlertTriangle size="1rem" />} color="red">
{t('alert')}
</Alert>
)}
{isError && (
<Alert icon={<IconAlertTriangle size="1rem" />} color="red">
{t('alert')}
</Alert>
)}
{hasCredentialsInput && (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
variant="filled"
label={t('form.fields.username.label')}
autoComplete="homarr-username"
withAsterisk
{...form.getInputProps('name')}
/>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
variant="filled"
label={t('form.fields.username.label')}
autoComplete="homarr-username"
withAsterisk
{...form.getInputProps('name')}
/>
<PasswordInput
variant="filled"
label={t('form.fields.password.label')}
autoComplete="homarr-password"
withAsterisk
{...form.getInputProps('password')}
/>
<PasswordInput
variant="filled"
label={t('form.fields.password.label')}
autoComplete="homarr-password"
withAsterisk
{...form.getInputProps('password')}
/>
{providers.includes('credentials') && (
<Button
mt="xs"
variant="light"
fullWidth
type="submit"
disabled={isLoading && form.values.provider != 'credentials'}
loading={isLoading && form.values.provider == 'credentials'}
name="credentials"
onClick={() => form.setFieldValue('provider', 'credentials')}
>
{t('form.buttons.submit')}
</Button>
)}
<Button mt="xs" variant="light" fullWidth type="submit" loading={isLoading}>
{t('form.buttons.submit')}
{providers.includes('ldap') && (
<Button
mt="xs"
variant="light"
fullWidth
type="submit"
disabled={isLoading && form.values.provider != 'ldap'}
loading={isLoading && form.values.provider == 'ldap'}
name="ldap"
onClick={() => form.setFieldValue('provider', 'ldap')}
>
{t('form.buttons.submit')} - LDAP
</Button>
)}
{redirectAfterLogin && (
<Text color="dimmed" align="center" size="xs">
{t('form.afterLoginRedirection', { url: redirectAfterLogin })}
</Text>
)}
</Stack>
</form>
)}
{hasCredentialsInput && providers.includes('oidc') && (
<Divider label="OIDC" labelPosition="center" mt="xl" mb="md" />
)}
{providers.includes('oidc') && (
<Button mt="xs" variant="light" fullWidth onClick={() => signIn('oidc')}>
{t('form.buttons.submit')} - {oidcProviderName}
</Button>
{redirectAfterLogin && (
<Text color="dimmed" align="center" size="xs">
{t('form.afterLoginRedirection', { url: redirectAfterLogin })}
</Text>
)}
</Stack>
</form>
</Card>
)}
</Card>
)}
</Stack>
</Flex>
</>
@@ -136,7 +200,12 @@ export default function LoginPage({
const regexExp = /^\/{1}[A-Za-z\/]*$/;
export const getServerSideProps: GetServerSideProps = async ({ locale, req, res, query }) => {
export const getServerSideProps = async ({
locale,
req,
res,
query,
}: GetServerSidePropsContext) => {
const session = await getServerAuthSession({ req, res });
const zodResult = await z
@@ -159,6 +228,9 @@ export const getServerSideProps: GetServerSideProps = async ({ locale, req, res,
props: {
...(await getServerSideTranslations(['authentication/login'], locale, req, res)),
redirectAfterLogin,
providers: env.AUTH_PROVIDER,
oidcProviderName: env.AUTH_OIDC_CLIENT_NAME || null,
oidcAutoLogin: env.AUTH_OIDC_AUTO_LOGIN || null,
isDemo,
},
};

View File

@@ -65,7 +65,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return result;
}
const isDockerEnabled: boolean = !!env.DOCKER_HOST || !!env.DOCKER_PORT || fs.existsSync('/var/run/docker');
const isDockerEnabled: boolean = !!env.DOCKER_HOST || !!env.DOCKER_PORT || fs.existsSync('/var/run/docker.sock');
return {
props: {

View File

@@ -46,7 +46,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return result;
}
const isDockerEnabled: boolean = !!env.DOCKER_HOST || !!env.DOCKER_PORT || fs.existsSync('/var/run/docker');
const isDockerEnabled: boolean = !!env.DOCKER_HOST || !!env.DOCKER_PORT || fs.existsSync('/var/run/docker.sock');
return {
props: {

View File

@@ -1,4 +1,5 @@
import { createTRPCRouter } from '~/server/api/trpc';
import { appRouter } from './routers/app';
import { boardRouter } from './routers/board';
import { calendarRouter } from './routers/calendar';
@@ -8,6 +9,7 @@ import { dnsHoleRouter } from './routers/dns-hole/router';
import { dockerRouter } from './routers/docker/router';
import { downloadRouter } from './routers/download';
import { iconRouter } from './routers/icon';
import { indexerManagerRouter } from './routers/indexer-manager';
import { inviteRouter } from './routers/invite/invite-router';
import { mediaRequestsRouter } from './routers/media-request';
import { mediaServerRouter } from './routers/media-server';
@@ -30,6 +32,7 @@ export const rootRouter = createTRPCRouter({
rss: rssRouter,
user: userRouter,
calendar: calendarRouter,
indexerManager: indexerManagerRouter,
config: configRouter,
dashDot: dashDotRouter,
dnsHole: dnsHoleRouter,
@@ -45,7 +48,7 @@ export const rootRouter = createTRPCRouter({
boards: boardRouter,
password: passwordRouter,
notebook: notebookRouter,
smartHomeEntityState: smartHomeEntityStateRouter
smartHomeEntityState: smartHomeEntityStateRouter,
});
// export type definition of API

View File

@@ -19,15 +19,15 @@ export const configRouter = createTRPCRouter({
.input(
z.object({
name: configNameSchema,
}),
})
)
.output(z.object({ message: z.string() }))
.mutation(async ({ input }) => {
if (input.name.toLowerCase() === 'default') {
Consola.error('Rejected config deletion because default configuration can\'t be deleted');
Consola.error("Rejected config deletion because default configuration can't be deleted");
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Default config can\'t be deleted',
message: "Default config can't be deleted",
});
}
@@ -44,7 +44,7 @@ export const configRouter = createTRPCRouter({
// If the target is not in the list of files, return an error
if (!matchedFile) {
Consola.error(
`Rejected config deletion request because config name '${input.name}' was not included in present configurations`,
`Rejected config deletion request because config name '${input.name}' was not included in present configurations`
);
throw new TRPCError({
code: 'NOT_FOUND',
@@ -64,9 +64,13 @@ export const configRouter = createTRPCRouter({
z.object({
name: configNameSchema,
config: z.custom<ConfigType>((x) => !!x && typeof x === 'object'),
}),
create: z.boolean().optional(),
})
)
.mutation(async ({ input }) => {
if (input.create && configExists(input.name))
throw new TRPCError({ message: 'Config already exists.', code: 'CONFLICT' });
Consola.info(`Saving updated configuration of '${input.name}' config.`);
const previousConfig = getConfig(input.name);
@@ -96,16 +100,16 @@ export const configRouter = createTRPCRouter({
}
const previousApp = previousConfig.apps.find(
(previousApp) => previousApp.id === app.id,
(previousApp) => previousApp.id === app.id
);
const previousProperty = previousApp?.integration?.properties.find(
(previousProperty) => previousProperty.field === property.field,
(previousProperty) => previousProperty.field === property.field
);
if (property.value !== undefined && property.value !== null) {
Consola.info(
'Detected credential change of private secret. Value will be overwritten in configuration',
'Detected credential change of private secret. Value will be overwritten in configuration'
);
return {
field: property.field,
@@ -168,13 +172,14 @@ export const configRouter = createTRPCRouter({
path: '/configs/byName',
tags: ['config'],
deprecated: true,
summary: 'Retrieve content of the JSON configuration. Deprecated because JSON will be removed in a future version and be replaced with a relational database.'
}
summary:
'Retrieve content of the JSON configuration. Deprecated because JSON will be removed in a future version and be replaced with a relational database.',
},
})
.input(
z.object({
name: configNameSchema,
}),
})
)
.output(z.custom<ConfigType>())
.query(async ({ ctx, input }) => {

View File

@@ -0,0 +1,102 @@
import axios from 'axios';
import Consola from 'consola';
import { z } from 'zod';
import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-properties';
import { getConfig } from '~/tools/config/getConfig';
import { IntegrationType } from '~/types/app';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
export const indexerManagerRouter = createTRPCRouter({
indexers: publicProcedure
.input(
z.object({
configName: z.string(),
})
)
.query(async ({ input }) => {
const config = getConfig(input.configName);
const indexerAppIntegrationTypes = ['prowlarr'] as const satisfies readonly IntegrationType[];
const app = config.apps.find((app) =>
checkIntegrationsType(app.integration, indexerAppIntegrationTypes)
)!;
const apiKey = findAppProperty(app, 'apiKey');
if (!app || !apiKey) {
Consola.error(
`Failed to process request to indexer app (${app.id}): API key not found. Please check the configuration.`
);
}
const appUrl = new URL(app.url);
const data = await axios
.get(`${appUrl.origin}/api/v1/indexer`, {
headers: {
'X-Api-Key': apiKey,
},
})
.then((res) => res.data);
return data;
}),
statuses: publicProcedure
.input(
z.object({
configName: z.string(),
})
)
.query(async ({ input }) => {
const config = getConfig(input.configName);
const indexerAppIntegrationTypes = ['prowlarr'] as const satisfies readonly IntegrationType[];
const app = config.apps.find((app) =>
checkIntegrationsType(app.integration, indexerAppIntegrationTypes)
)!;
const apiKey = findAppProperty(app, 'apiKey');
if (!app || !apiKey) {
Consola.error(
`Failed to process request to indexer app (${app.id}): API key not found. Please check the configuration.`
);
}
const appUrl = new URL(app.url);
const data = await axios
.get(`${appUrl.origin}/api/v1/indexerstatus`, {
headers: {
'X-Api-Key': apiKey,
},
})
.then((res) => res.data);
return data;
}),
testAllIndexers: protectedProcedure
.input(
z.object({
configName: z.string(),
})
)
.mutation(async ({ input }) => {
const config = getConfig(input.configName);
const indexerAppIntegrationTypes = ['prowlarr'] as const satisfies readonly IntegrationType[];
const app = config.apps.find((app) =>
checkIntegrationsType(app.integration, indexerAppIntegrationTypes)
)!;
const apiKey = findAppProperty(app, 'apiKey');
if (!app || !apiKey) {
Consola.error(
`failed to process request to app '${app?.integration}' (${app?.id}). Please check api key`
);
}
const appUrl = new URL(app.url);
const result = await axios
.post(`${appUrl.origin}/api/v1/indexer/testall`, null, {
headers: {
'X-Api-Key': apiKey,
},
})
.then((res) => res.data)
.catch((err: any) => err.response.data);
return result;
}),
});

View File

@@ -1,57 +1,17 @@
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import bcrypt from 'bcryptjs';
import Consola from 'consola';
import Cookies from 'cookies';
import { eq } from 'drizzle-orm';
import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next';
import { type DefaultSession, type NextAuthOptions, getServerSession } from 'next-auth';
import { type NextAuthOptions, getServerSession } from 'next-auth';
import { Adapter } from 'next-auth/adapters';
import { decode, encode } from 'next-auth/jwt';
import Credentials from 'next-auth/providers/credentials';
import { adapter, onCreateUser, providers } from '~/utils/auth';
import EmptyNextAuthProvider from '~/utils/empty-provider';
import { fromDate, generateSessionToken } from '~/utils/session';
import { colorSchemeParser, signInSchema } from '~/validations/user';
import { colorSchemeParser } from '~/validations/user';
import { db } from './db';
import { users } from './db/schema';
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module 'next-auth' {
interface Session extends DefaultSession {
user: DefaultSession['user'] & {
id: string;
isAdmin: boolean;
colorScheme: 'light' | 'dark' | 'environment';
autoFocusSearch: boolean;
language: string;
// ...other properties
// role: UserRole;
};
}
interface User {
isAdmin: boolean;
colorScheme: 'light' | 'dark' | 'environment';
autoFocusSearch: boolean;
language: string;
// ...other properties
// role: UserRole;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string;
isAdmin: boolean;
}
}
const adapter = DrizzleAdapter(db);
const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
/**
@@ -63,6 +23,9 @@ export const constructAuthOptions = (
req: NextApiRequest,
res: NextApiResponse
): NextAuthOptions => ({
events: {
createUser: onCreateUser,
},
callbacks: {
async session({ session, user }) {
if (session.user) {
@@ -133,58 +96,7 @@ export const constructAuthOptions = (
error: '/auth/login',
},
adapter: adapter as Adapter,
providers: [
Credentials({
name: 'credentials',
credentials: {
name: {
label: 'Username',
type: 'text',
},
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const data = await signInSchema.parseAsync(credentials);
const user = await db.query.users.findFirst({
with: {
settings: {
columns: {
colorScheme: true,
language: true,
autoFocusSearch: true,
},
},
},
where: eq(users.name, data.name),
});
if (!user || !user.password) {
return null;
}
Consola.log(`user ${user.name} is trying to log in. checking password...`);
const isValidPassword = await bcrypt.compare(data.password, user.password);
if (!isValidPassword) {
Consola.log(`password for user ${user.name} was incorrect`);
return null;
}
Consola.log(`user ${user.name} successfully authorized`);
return {
id: user.id,
name: user.name,
isAdmin: false,
colorScheme: colorSchemeParser.parse(user.settings?.colorScheme),
language: user.settings?.language ?? 'en',
autoFocusSearch: user.settings?.autoFocusSearch ?? false,
};
},
}),
EmptyNextAuthProvider(),
],
providers: [...providers, EmptyNextAuthProvider()],
jwt: {
async encode(params) {
if (!isCredentialsRequest(req)) {
@@ -207,10 +119,12 @@ export const constructAuthOptions = (
});
const isCredentialsRequest = (req: NextApiRequest): boolean => {
const nextAuthQueryParams = req.query.nextauth as ['callback', 'credentials'];
const nextAuthQueryParams = req.query.nextauth as string[];
return (
nextAuthQueryParams.includes('callback') &&
nextAuthQueryParams.includes('credentials') &&
(nextAuthQueryParams.includes('credentials') ||
nextAuthQueryParams.includes('ldap') ||
nextAuthQueryParams.includes('oidc')) &&
req.method === 'POST'
);
};

View File

@@ -2,7 +2,9 @@ import { InferSelectModel, relations } from 'drizzle-orm';
import { index, int, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { type AdapterAccount } from 'next-auth/adapters';
export const users = sqliteTable('user', {
// workaround for typescript check in adapter
// preferably add email into credential login and make email non-nullable here
export const _users = {
id: text('id').notNull().primaryKey(),
name: text('name'),
email: text('email'),
@@ -12,7 +14,9 @@ export const users = sqliteTable('user', {
salt: text('salt'),
isAdmin: int('is_admin', { mode: 'boolean' }).notNull().default(false),
isOwner: int('is_owner', { mode: 'boolean' }).notNull().default(false),
});
};
export const users = sqliteTable('user', _users);
export const accounts = sqliteTable(
'account',

View File

@@ -22,6 +22,7 @@ export const boardNamespaces = [
'modules/dashdot',
'modules/overseerr',
'modules/media-server',
'modules/indexer-manager',
'modules/common-media-cards',
'modules/video-stream',
'modules/media-requests-list',
@@ -44,7 +45,7 @@ export const manageNamespaces = [
'manage/users',
'manage/users/invites',
'manage/users/create',
'manage/users/edit'
'manage/users/edit',
];
export const loginNamespaces = ['authentication/login'];

View File

@@ -1,5 +1,4 @@
import { Icon, IconKey, IconPassword, IconUser } from '@tabler/icons-react';
import { Property } from 'csstype';
import { TileBaseType } from './tile';
@@ -46,6 +45,7 @@ export type IntegrationType =
| 'radarr'
| 'sonarr'
| 'lidarr'
| 'prowlarr'
| 'sabnzbd'
| 'jellyseerr'
| 'overseerr'
@@ -87,6 +87,7 @@ export const integrationFieldProperties: {
lidarr: ['apiKey'],
radarr: ['apiKey'],
sonarr: ['apiKey'],
prowlarr: ['apiKey'],
sabnzbd: ['apiKey'],
readarr: ['apiKey'],
overseerr: ['apiKey'],
@@ -99,7 +100,7 @@ export const integrationFieldProperties: {
plex: ['apiKey'],
pihole: ['apiKey'],
adGuardHome: ['username', 'password'],
homeAssistant: ['apiKey']
homeAssistant: ['apiKey'],
};
export type IntegrationFieldDefinitionType = {

166
src/utils/auth/adapter.ts Normal file
View File

@@ -0,0 +1,166 @@
import { randomUUID } from 'crypto';
import { and, eq } from 'drizzle-orm';
import {
BaseSQLiteDatabase,
SQLiteTableFn,
sqliteTable as defaultSqliteTableFn,
text,
} from 'drizzle-orm/sqlite-core';
import { User } from 'next-auth';
import { Adapter, AdapterAccount } from 'next-auth/adapters';
import { db } from '~/server/db';
import { _users, accounts, sessions, userSettings, verificationTokens } from '~/server/db/schema';
// Need to modify createTables with custom schema
const createTables = (sqliteTable: SQLiteTableFn) => ({
users: sqliteTable('user', {
..._users,
email: text('email').notNull(), // workaround for typescript
}),
accounts,
sessions,
verificationTokens,
});
export type DefaultSchema = ReturnType<typeof createTables>;
export const onCreateUser = async ({ user }: { user: User }) => {
await db.insert(userSettings).values({
id: randomUUID(),
userId: user.id,
});
};
// Keep this the same as original file @auth/drizzle-adapter/src/lib/sqlite.ts
// only change changed return type from Adapter to "satisfies Adapter", to tell typescript createUser exists
export function SQLiteDrizzleAdapter(
client: InstanceType<typeof BaseSQLiteDatabase>,
tableFn = defaultSqliteTableFn
) {
const { users, accounts, sessions, verificationTokens } = createTables(tableFn);
return {
createUser(data) {
return client
.insert(users)
.values({ ...data, id: crypto.randomUUID() })
.returning()
.get();
},
getUser(data) {
return client.select().from(users).where(eq(users.id, data)).get() ?? null;
},
getUserByEmail(data) {
return client.select().from(users).where(eq(users.email, data)).get() ?? null;
},
createSession(data) {
return client.insert(sessions).values(data).returning().get();
},
getSessionAndUser(data) {
return (
client
.select({
session: sessions,
user: users,
})
.from(sessions)
.where(eq(sessions.sessionToken, data))
.innerJoin(users, eq(users.id, sessions.userId))
.get() ?? null
);
},
updateUser(data) {
if (!data.id) {
throw new Error('No user id.');
}
return client.update(users).set(data).where(eq(users.id, data.id)).returning().get();
},
updateSession(data) {
return client
.update(sessions)
.set(data)
.where(eq(sessions.sessionToken, data.sessionToken))
.returning()
.get();
},
linkAccount(rawAccount) {
const updatedAccount = client.insert(accounts).values(rawAccount).returning().get();
const account: AdapterAccount = {
...updatedAccount,
type: updatedAccount.type,
access_token: updatedAccount.access_token ?? undefined,
token_type: updatedAccount.token_type ?? undefined,
id_token: updatedAccount.id_token ?? undefined,
refresh_token: updatedAccount.refresh_token ?? undefined,
scope: updatedAccount.scope ?? undefined,
expires_at: updatedAccount.expires_at ?? undefined,
session_state: updatedAccount.session_state ?? undefined,
};
return account;
},
getUserByAccount(account) {
const results = client
.select()
.from(accounts)
.leftJoin(users, eq(users.id, accounts.userId))
.where(
and(
eq(accounts.provider, account.provider),
eq(accounts.providerAccountId, account.providerAccountId)
)
)
.get();
return results?.user ?? null;
},
deleteSession(sessionToken) {
return (
client.delete(sessions).where(eq(sessions.sessionToken, sessionToken)).returning().get() ??
null
);
},
createVerificationToken(token) {
return client.insert(verificationTokens).values(token).returning().get();
},
useVerificationToken(token) {
try {
return (
client
.delete(verificationTokens)
.where(
and(
eq(verificationTokens.identifier, token.identifier),
eq(verificationTokens.token, token.token)
)
)
.returning()
.get() ?? null
);
} catch (err) {
throw new Error('No verification token found.');
}
},
deleteUser(id) {
return client.delete(users).where(eq(users.id, id)).returning().get();
},
unlinkAccount(account) {
client
.delete(accounts)
.where(
and(
eq(accounts.providerAccountId, account.providerAccountId),
eq(accounts.provider, account.provider)
)
)
.run();
return undefined;
},
} satisfies Adapter;
}
export default SQLiteDrizzleAdapter(db);

View File

@@ -0,0 +1,56 @@
import bcrypt from 'bcryptjs';
import Consola from 'consola';
import { eq } from 'drizzle-orm';
import Credentials from 'next-auth/providers/credentials';
import { colorSchemeParser, signInSchema } from '~/validations/user';
import { db } from '../../server/db';
import { users } from '../../server/db/schema';
export default Credentials({
name: 'credentials',
credentials: {
name: {
label: 'Username',
type: 'text',
},
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const data = await signInSchema.parseAsync(credentials);
const user = await db.query.users.findFirst({
with: {
settings: {
columns: {
colorScheme: true,
language: true,
autoFocusSearch: true,
},
},
},
where: eq(users.name, data.name),
});
if (!user || !user.password) {
return null;
}
Consola.log(`user ${user.name} is trying to log in. checking password...`);
const isValidPassword = await bcrypt.compare(data.password, user.password);
if (!isValidPassword) {
Consola.log(`password for user ${user.name} was incorrect`);
return null;
}
Consola.log(`user ${user.name} successfully authorized`);
return {
id: user.id,
name: user.name,
isAdmin: false,
isOwner: false,
};
},
});

46
src/utils/auth/index.ts Normal file
View File

@@ -0,0 +1,46 @@
import { DefaultSession } from 'next-auth';
import { CredentialsConfig, OAuthConfig } from 'next-auth/providers';
import { env } from '~/env';
export { default as adapter, onCreateUser } from './adapter';
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module 'next-auth' {
interface Session extends DefaultSession {
user: DefaultSession['user'] & {
id: string;
isAdmin: boolean;
colorScheme: 'light' | 'dark' | 'environment';
autoFocusSearch: boolean;
language: string;
// ...other properties
// role: UserRole;
};
}
interface User {
isAdmin: boolean;
isOwner?: boolean;
// ...other properties
// role: UserRole;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string;
isAdmin: boolean;
}
}
export const providers: (CredentialsConfig | OAuthConfig<any>)[] = [];
if (env.AUTH_PROVIDER?.includes('ldap')) providers.push((await import('./ldap')).default);
if (env.AUTH_PROVIDER?.includes('credentials'))
providers.push((await import('./credentials')).default);
if (env.AUTH_PROVIDER?.includes('oidc')) providers.push((await import('./oidc')).default);

161
src/utils/auth/ldap.ts Normal file
View File

@@ -0,0 +1,161 @@
import Consola from 'consola';
import ldap from 'ldapjs';
import Credentials from 'next-auth/providers/credentials';
import { env } from '~/env';
import { signInSchema } from '~/validations/user';
import adapter, { onCreateUser } from './adapter';
// Helper types for infering properties of returned search type
type AttributeConstraint = string | readonly string[] | undefined;
type InferrableSearchOptions<
Attributes extends AttributeConstraint,
ArrayAttributes extends Attributes,
> = Omit<ldap.SearchOptions, 'attributes'> & {
attributes?: Attributes;
arrayAttributes?: ArrayAttributes;
};
type SearchResultIndex<Attributes extends AttributeConstraint> = Attributes extends string
? Attributes
: Attributes extends readonly string[]
? Attributes[number]
: string;
type SearchResult<
Attributes extends AttributeConstraint,
ArrayAttributes extends Attributes = never,
> = { dn: string } & Record<
Exclude<SearchResultIndex<Attributes>, SearchResultIndex<ArrayAttributes>>,
string
> &
Record<SearchResultIndex<ArrayAttributes>, string[]>;
const ldapLogin = (username: string, password: string) =>
new Promise<ldap.Client>((resolve, reject) => {
const client = ldap.createClient({
url: env.AUTH_LDAP_URI,
});
client.bind(username, password, (error, res) => {
if (error) {
reject('Invalid username or password');
} else {
resolve(client);
}
});
});
const ldapSearch = async <
Attributes extends AttributeConstraint,
ArrayAttributes extends Attributes = never,
>(
client: ldap.Client,
base: string,
options: InferrableSearchOptions<Attributes, ArrayAttributes>
) =>
new Promise<SearchResult<Attributes, ArrayAttributes>[]>((resolve, reject) => {
client.search(base, options as ldap.SearchOptions, (err, res) => {
const results: SearchResult<Attributes, ArrayAttributes>[] = [];
res.on('error', (err) => {
reject('error: ' + err.message);
});
res.on('searchEntry', (entry) => {
results.push(
entry.pojo.attributes.reduce<Record<string, string | string[]>>(
(obj, attr) => {
// just take first element assuming there's only one (uid, mail), unless in arrayAttributes
obj[attr.type] = options.arrayAttributes?.includes(attr.type)
? attr.values
: attr.values[0];
return obj;
},
{ dn: entry.pojo.objectName }
) as SearchResult<Attributes, ArrayAttributes>
);
});
res.on('end', (result) => {
if (result?.status != 0) {
reject(new Error('ldap search status is not 0, search failed'));
} else {
resolve(results);
}
});
});
});
export default Credentials({
id: 'ldap',
name: 'LDAP',
credentials: {
name: { label: 'uid', type: 'text' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
try {
const data = await signInSchema.parseAsync(credentials);
Consola.log(`user ${data.name} is trying to log in using LDAP. Signing in...`);
const client = await ldapLogin(env.AUTH_LDAP_BIND_DN, env.AUTH_LDAP_BIND_PASSWORD);
const ldapUser = (
await ldapSearch(client, env.AUTH_LDAP_BASE, {
filter: `(uid=${data.name})`,
// as const for inference
attributes: ['uid', 'mail'] as const,
})
)[0];
await ldapLogin(ldapUser.dn, data.password).then((client) => client.destroy());
const userGroups = (
await ldapSearch(client, env.AUTH_LDAP_BASE, {
filter: `(&(objectclass=${env.AUTH_LDAP_GROUP_CLASS})(${
env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE
}=${ldapUser[env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE as 'dn' | 'uid']}))`,
// as const for inference
attributes: 'cn',
})
).map((group) => group.cn);
client.destroy();
Consola.log(`user ${data.name} successfully authorized`);
let user = await adapter.getUserByEmail!(ldapUser.mail);
const isAdmin = userGroups.includes(env.AUTH_LDAP_ADMIN_GROUP);
const isOwner = userGroups.includes(env.AUTH_LDAP_OWNER_GROUP);
if (!user) {
// CreateUser will create settings in event
user = await adapter.createUser({
name: ldapUser.uid,
email: ldapUser.mail,
emailVerified: new Date(), // assume ldap email is verified
isAdmin: isAdmin,
isOwner: isOwner,
});
// For some reason adapter.createUser doesn't call createUser event, needs to be called manually to create usersettings
await onCreateUser({ user });
} else if (user.isAdmin != isAdmin || user.isOwner != isOwner) {
// Update roles if changed in LDAP
Consola.log(`updating roles of user ${user.name}`);
adapter.updateUser({
...user,
isAdmin,
isOwner,
});
}
return {
id: user?.id || ldapUser.dn,
name: user?.name || ldapUser.uid,
isAdmin: isAdmin,
isOwner: isOwner,
};
} catch (error) {
Consola.error(error);
return null;
}
},
});

51
src/utils/auth/oidc.ts Normal file
View File

@@ -0,0 +1,51 @@
import Consola from 'consola';
import { OAuthConfig } from 'next-auth/providers/oauth';
import { env } from '~/env';
import adapter from './adapter';
type Profile = {
sub: string;
name: string;
email: string;
groups: string[];
preferred_username: string;
email_verified: boolean;
};
const provider: OAuthConfig<Profile> = {
id: 'oidc',
name: env.AUTH_OIDC_CLIENT_NAME,
type: 'oauth',
clientId: env.AUTH_OIDC_CLIENT_ID,
clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
wellKnown: `${env.AUTH_OIDC_URI}/.well-known/openid-configuration`,
authorization: { params: { scope: 'openid email profile groups' } },
idToken: true,
async profile(profile) {
const user = await adapter.getUserByEmail!(profile.email);
const isAdmin = profile.groups.includes(env.AUTH_OIDC_ADMIN_GROUP);
const isOwner = profile.groups.includes(env.AUTH_OIDC_OWNER_GROUP);
// check for role update
if (user && (user.isAdmin != isAdmin || user.isOwner != isOwner)) {
Consola.log(`updating roles of user ${user.name}`);
adapter.updateUser({
...user,
isAdmin,
isOwner,
});
}
return {
id: profile.sub,
name: profile.preferred_username,
email: profile.email,
isAdmin,
isOwner,
};
},
};
export default provider;

View File

@@ -87,7 +87,7 @@ const definition = defineWidget({
return t('item.validation.length', { shortest: '1', longest: '100' });
},
href: (value) => {
if (!z.string().min(1).max(200).safeParse(value).success) {
if (!z.string().min(1).max(8192).safeParse(value).success) {
return t('item.validation.length', { shortest: '1', longest: '200' });
}

View File

@@ -6,6 +6,7 @@ import dnsHoleControls from './dnshole/DnsHoleControls';
import dnsHoleSummary from './dnshole/DnsHoleSummary';
import torrentNetworkTraffic from './download-speed/TorrentNetworkTrafficTile';
import iframe from './iframe/IFrameTile';
import indexerManager from './indexer-manager/IndexerManagerTile';
import mediaRequestsList from './media-requests/MediaRequestListTile';
import mediaRequestsStats from './media-requests/MediaRequestStatsTile';
import mediaServer from './media-server/MediaServerTile';
@@ -20,6 +21,7 @@ import weather from './weather/WeatherTile';
export default {
calendar,
'indexer-manager': indexerManager,
dashdot,
usenet,
weather,

View File

@@ -0,0 +1,98 @@
import { Button, Card, Flex, Group, ScrollArea, Text } from '@mantine/core';
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from '@tabler/icons-react';
import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
import { defineWidget } from '../helper';
import { WidgetLoading } from '../loading';
import { IWidget } from '../widgets';
const definition = defineWidget({
id: 'indexer-manager',
icon: IconReportSearch,
options: {},
gridstack: {
minWidth: 1,
minHeight: 1,
maxWidth: 3,
maxHeight: 3,
},
component: IndexerManagerWidgetTile,
});
export type IIndexerManagerWidget = IWidget<(typeof definition)['id'], typeof definition>;
interface IndexerManagerWidgetProps {
widget: IIndexerManagerWidget;
}
function IndexerManagerWidgetTile({ widget }: IndexerManagerWidgetProps) {
const { t } = useTranslation('modules/indexer-manager');
const { data: sessionData } = useSession();
const { name: configName } = useConfigContext();
const utils = api.useUtils();
const { isLoading: testAllLoading, mutateAsync: testAllAsync } =
api.indexerManager.testAllIndexers.useMutation({
onSuccess: async () => {
await utils.indexerManager.invalidate();
},
});
const { isInitialLoading: indexersLoading, data: indexersData } =
api.indexerManager.indexers.useQuery({
configName: configName!,
});
const { isInitialLoading: statusesLoading, data: statusesData } =
api.indexerManager.statuses.useQuery(
{
configName: configName!,
},
{
staleTime: 1000 * 60 * 2,
}
);
if (indexersLoading || !indexersData || statusesLoading) {
return <WidgetLoading />;
}
return (
<Flex h="100%" gap={0} direction="column">
<Text mt={2}>{t('indexersStatus.title')}</Text>
<Card py={5} px={10} radius="md" withBorder style={{ flex: '1' }}>
<ScrollArea h="100%">
{indexersData.map((indexer: any) => (
<Group key={indexer.id} position="apart">
<Text color="dimmed" align="center" size="xs">
{indexer.name}
</Text>
{!statusesData.find((status: any) => indexer.id === status.indexerId) &&
indexer.enable ? (
<IconCircleCheck color="#2ecc71" />
) : (
<IconCircleX color="#d9534f" />
)}
</Group>
))}
</ScrollArea>
</Card>
{sessionData && (
<Button
mt={5}
radius="md"
variant="light"
onClick={() => {
testAllAsync({ configName: configName! });
}}
loading={testAllLoading}
loaderPosition="right"
rightIcon={<IconTestPipe size={20} />}
>
{t('indexersStatus.testAllButton')}
</Button>
)}
</Flex>
);
}
export default definition;

View File

@@ -95,6 +95,13 @@ export function Editor({ widget }: { widget: INotebookWidget }) {
validate(url) {
return /^https?:\/\//.test(url);
},
}).extend({
addAttributes() {
return {
...this.parent?.(),
target: { default: null }
}
}
}),
StarterKit,
Table.configure({

View File

@@ -16,6 +16,11 @@ const definition = defineWidget({
defaultValue: 'sun.sun',
info: true,
},
appendUnit: {
type: 'switch',
defaultValue: false,
info: true,
},
automationId: {
type: 'text',
info: true,
@@ -25,6 +30,11 @@ const definition = defineWidget({
type: 'text',
defaultValue: 'Sun',
},
displayFriendlyName: {
type: 'switch',
defaultValue: false,
info: true,
},
},
gridstack: {
minWidth: 1,
@@ -58,6 +68,14 @@ function EntityStateTile({ widget }: SmartHomeEntityStateWidgetProps) {
},
);
const attribute = (widget.properties.appendUnit && data?.attributes.unit_of_measurement ?
" " + data?.attributes.unit_of_measurement : ""
)
const displayName = (widget.properties.displayFriendlyName && data?.attributes.friendly_name ?
data?.attributes.friendly_name : widget.properties.displayName
)
const { mutateAsync: mutateTriggerAutomationAsync } = api.smartHomeEntityState.triggerAutomation.useMutation({
onSuccess: () => {
void utils.smartHomeEntityState.invalidate();
@@ -101,6 +119,7 @@ function EntityStateTile({ widget }: SmartHomeEntityStateWidgetProps) {
dataComponent = (
<Text align="center">
{data?.state}
{attribute}
{isLoading && <Loader ml="xs" size={10} />}
</Text>
);
@@ -118,7 +137,7 @@ function EntityStateTile({ widget }: SmartHomeEntityStateWidgetProps) {
w="100%">
<Stack align="center" spacing={3}>
<Text align="center" weight="bold" size="lg">
{widget.properties.displayName}
{displayName}
</Text>
{dataComponent}
</Stack>

View File

@@ -38,6 +38,9 @@ describe('login page', () => {
redirectAfterLogin: null,
isDemo: false,
_i18Next: 'hello',
oidcAutoLogin: null,
oidcProviderName: null,
providers: undefined
},
});
@@ -75,6 +78,9 @@ describe('login page', () => {
redirectAfterLogin: '/manage/users/create',
isDemo: false,
_i18Next: 'hello',
oidcAutoLogin: null,
oidcProviderName: null,
providers: undefined
},
});
@@ -112,6 +118,9 @@ describe('login page', () => {
redirectAfterLogin: null,
isDemo: false,
_i18Next: 'hello',
oidcAutoLogin: null,
oidcProviderName: null,
providers: undefined
},
});

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es6",
"target": "es2017",
"lib": [
"dom",
"dom.iterable",

299
yarn.lock
View File

@@ -22,34 +22,6 @@ __metadata:
languageName: node
linkType: hard
"@auth/core@npm:0.18.1":
version: 0.18.1
resolution: "@auth/core@npm:0.18.1"
dependencies:
"@panva/hkdf": ^1.1.1
cookie: 0.5.0
jose: ^5.1.0
oauth4webapi: ^2.3.0
preact: 10.11.3
preact-render-to-string: 5.2.3
peerDependencies:
nodemailer: ^6.8.0
peerDependenciesMeta:
nodemailer:
optional: true
checksum: 46ae80e621e03d9206cc9a5e37941df92207e58298f423ec71ae2b8d3492d86f14d5e024ba30c5a905675c451688d212d389b580748f3a176ec0ddcd3872291a
languageName: node
linkType: hard
"@auth/drizzle-adapter@npm:^0.3.2":
version: 0.3.6
resolution: "@auth/drizzle-adapter@npm:0.3.6"
dependencies:
"@auth/core": 0.18.1
checksum: c80abc825ab15645f39ad4fd630ca81caf18880aca32f8df030a072dfb7f5222d1fe4396713041bf24e7252c8478a09be81ac4f921652497319acf30e138f4ec
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.22.13":
version: 7.22.13
resolution: "@babel/code-frame@npm:7.22.13"
@@ -1033,6 +1005,95 @@ __metadata:
languageName: node
linkType: hard
"@ldapjs/asn1@npm:2.0.0, @ldapjs/asn1@npm:^2.0.0":
version: 2.0.0
resolution: "@ldapjs/asn1@npm:2.0.0"
checksum: b9957b47b14ef0a24fa5275849b624f8a1a7708c2f37b0b7ff278062527a7c93a885c9a73462c3bba4a9e182bd0766422f08597361cdbf3ecafd7dfb478ab490
languageName: node
linkType: hard
"@ldapjs/asn1@npm:^1.2.0":
version: 1.2.0
resolution: "@ldapjs/asn1@npm:1.2.0"
checksum: 720b65fd825b414f672264c19edf2b67f643bd655ac9dae761394f40e332c68fbe7f442046daf88a00a656ca2cbbfe91c0435fc59c9b7c301770ea0d2606b89a
languageName: node
linkType: hard
"@ldapjs/attribute@npm:1.0.0, @ldapjs/attribute@npm:^1.0.0":
version: 1.0.0
resolution: "@ldapjs/attribute@npm:1.0.0"
dependencies:
"@ldapjs/asn1": 2.0.0
"@ldapjs/protocol": ^1.2.1
process-warning: ^2.1.0
checksum: 887665a3067deebbfea7760befc535f94205f87cece0f164f9ddc2f3f5b0daa136a0ede4520fa37aa9d30af025cb23023a155473bbc61916aa39da2ad697c7f0
languageName: node
linkType: hard
"@ldapjs/change@npm:^1.0.0":
version: 1.0.0
resolution: "@ldapjs/change@npm:1.0.0"
dependencies:
"@ldapjs/asn1": 2.0.0
"@ldapjs/attribute": 1.0.0
checksum: 5f28d8e904fe47cbaff225d9696d35ee78f1f648e2aedab9aebe67c0b19df4a9b0224bf2ac9a8ab2d1dab00e69eaff9f17be5532af2f32862e27a973228d83eb
languageName: node
linkType: hard
"@ldapjs/controls@npm:^2.1.0":
version: 2.1.0
resolution: "@ldapjs/controls@npm:2.1.0"
dependencies:
"@ldapjs/asn1": ^1.2.0
"@ldapjs/protocol": ^1.2.1
checksum: b61a69ddf0634ea6bbc1a32691fa19ee92aa2efe17aeae77ea261b5b16cf6102c36ed71ef0ce038ec74fe7751917c0946862fdabe328086b7561b6e6453ef794
languageName: node
linkType: hard
"@ldapjs/dn@npm:^1.1.0":
version: 1.1.0
resolution: "@ldapjs/dn@npm:1.1.0"
dependencies:
"@ldapjs/asn1": 2.0.0
process-warning: ^2.1.0
checksum: 716e408c9f8ea1d1f14c512a1ecbc3271d7873da1aee788bfa6548a47290fecefd9ea2039f1f9f9238cba8072ae798c4e4b4da5e457ee24b68e94572665f711f
languageName: node
linkType: hard
"@ldapjs/filter@npm:^2.1.1":
version: 2.1.1
resolution: "@ldapjs/filter@npm:2.1.1"
dependencies:
"@ldapjs/asn1": 2.0.0
"@ldapjs/protocol": ^1.2.1
process-warning: ^2.1.0
checksum: e87c698fe7921969a751479b435a58f8202ebbe48420a3705dd47180b33ae39fcbe1451640c58fb94f80b4a96efa91d99ec91e6dc6d7be96b7bc3cc469506ba8
languageName: node
linkType: hard
"@ldapjs/messages@npm:^1.3.0":
version: 1.3.0
resolution: "@ldapjs/messages@npm:1.3.0"
dependencies:
"@ldapjs/asn1": ^2.0.0
"@ldapjs/attribute": ^1.0.0
"@ldapjs/change": ^1.0.0
"@ldapjs/controls": ^2.1.0
"@ldapjs/dn": ^1.1.0
"@ldapjs/filter": ^2.1.1
"@ldapjs/protocol": ^1.2.1
process-warning: ^2.2.0
checksum: e7f1994db976456546769d72b2efba18c93e9201c81050b52479575bf72bac42312c6b817e886ac315caf592b00d2f0d3407fcc4eea58ff65c8bd18211e5b458
languageName: node
linkType: hard
"@ldapjs/protocol@npm:^1.2.1":
version: 1.2.1
resolution: "@ldapjs/protocol@npm:1.2.1"
checksum: 3e26f3fc642897ae1448a5a172839ab368fe72e05b9eaf36e16fe6dd4c3c93ce298ab4e3907b3eda9c3911e018d617777b79071dfbb3b813add56b046aea48dc
languageName: node
linkType: hard
"@mantine/core@npm:^6.0.0":
version: 6.0.21
resolution: "@mantine/core@npm:6.0.21"
@@ -1504,7 +1565,7 @@ __metadata:
languageName: node
linkType: hard
"@panva/hkdf@npm:^1.0.2, @panva/hkdf@npm:^1.1.1":
"@panva/hkdf@npm:^1.0.2":
version: 1.1.1
resolution: "@panva/hkdf@npm:1.1.1"
checksum: f0dd12903751d8792420353f809ed3c7de860cf506399759fff5f59f7acfef8a77e2b64012898cee7e5b047708fa0bd91dff5ef55a502bf8ea11aad9842160da
@@ -3286,6 +3347,15 @@ __metadata:
languageName: node
linkType: hard
"@types/ldapjs@npm:^3.0.2":
version: 3.0.2
resolution: "@types/ldapjs@npm:3.0.2"
dependencies:
"@types/node": "*"
checksum: 0839acb3c46aa231577266c46700b44cfeb5cc77cfb854be6dac25bf1346cd0b5c83e3671fd6a78769c7702a1eb610d5f08b68e2583bb5c13d214eb0558c3d36
languageName: node
linkType: hard
"@types/mime@npm:*":
version: 3.0.4
resolution: "@types/mime@npm:3.0.4"
@@ -3919,6 +3989,13 @@ __metadata:
languageName: node
linkType: hard
"abstract-logging@npm:^2.0.1":
version: 2.0.1
resolution: "abstract-logging@npm:2.0.1"
checksum: 6967d15e5abbafd17f56eaf30ba8278c99333586fa4f7935fd80e93cfdc006c37fcc819c5d63ee373a12e6cb2d0417f7c3c6b9e42b957a25af9937d26749415e
languageName: node
linkType: hard
"accepts@npm:^1.3.7":
version: 1.3.8
resolution: "accepts@npm:1.3.8"
@@ -4207,6 +4284,13 @@ __metadata:
languageName: node
linkType: hard
"assert-plus@npm:^1.0.0":
version: 1.0.0
resolution: "assert-plus@npm:1.0.0"
checksum: 19b4340cb8f0e6a981c07225eacac0e9d52c2644c080198765d63398f0075f83bbc0c8e95474d54224e297555ad0d631c1dcd058adb1ddc2437b41a6b424ac64
languageName: node
linkType: hard
"assertion-error@npm:^1.1.0":
version: 1.1.0
resolution: "assertion-error@npm:1.1.0"
@@ -4309,6 +4393,15 @@ __metadata:
languageName: node
linkType: hard
"backoff@npm:^2.5.0":
version: 2.5.0
resolution: "backoff@npm:2.5.0"
dependencies:
precond: 0.2
checksum: ccdcf2a26acd9379d0d4f09e3fb3b7ee34dee94f07ab74d1e38b38f89a3675d9f3cbebb142d9c61c655f4c9eb63f1d6ec28cebeb3dc9215efd8fe7cef92725b9
languageName: node
linkType: hard
"balanced-match@npm:^1.0.0":
version: 1.0.2
resolution: "balanced-match@npm:1.0.2"
@@ -4904,13 +4997,6 @@ __metadata:
languageName: node
linkType: hard
"cookie@npm:0.5.0, cookie@npm:^0.5.0":
version: 0.5.0
resolution: "cookie@npm:0.5.0"
checksum: 1f4bd2ca5765f8c9689a7e8954183f5332139eb72b6ff783d8947032ec1fdf43109852c178e21a953a30c0dd42257828185be01b49d1eb1a67fd054ca588a180
languageName: node
linkType: hard
"cookie@npm:^0.4.0":
version: 0.4.2
resolution: "cookie@npm:0.4.2"
@@ -4918,6 +5004,13 @@ __metadata:
languageName: node
linkType: hard
"cookie@npm:^0.5.0":
version: 0.5.0
resolution: "cookie@npm:0.5.0"
checksum: 1f4bd2ca5765f8c9689a7e8954183f5332139eb72b6ff783d8947032ec1fdf43109852c178e21a953a30c0dd42257828185be01b49d1eb1a67fd054ca588a180
languageName: node
linkType: hard
"cookie@npm:~0.6.0":
version: 0.6.0
resolution: "cookie@npm:0.6.0"
@@ -4978,6 +5071,13 @@ __metadata:
languageName: node
linkType: hard
"core-util-is@npm:1.0.2":
version: 1.0.2
resolution: "core-util-is@npm:1.0.2"
checksum: 7a4c925b497a2c91421e25bf76d6d8190f0b2359a9200dbeed136e63b2931d6294d3b1893eda378883ed363cd950f44a12a401384c609839ea616befb7927dab
languageName: node
linkType: hard
"core-util-is@npm:~1.0.0":
version: 1.0.3
resolution: "core-util-is@npm:1.0.3"
@@ -6481,6 +6581,13 @@ __metadata:
languageName: node
linkType: hard
"extsprintf@npm:^1.2.0":
version: 1.4.1
resolution: "extsprintf@npm:1.4.1"
checksum: a2f29b241914a8d2bad64363de684821b6b1609d06ae68d5b539e4de6b28659715b5bea94a7265201603713b7027d35399d10b0548f09071c5513e65e8323d33
languageName: node
linkType: hard
"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3":
version: 3.1.3
resolution: "fast-deep-equal@npm:3.1.3"
@@ -7276,7 +7383,6 @@ __metadata:
version: 0.0.0-use.local
resolution: "homarr@workspace:."
dependencies:
"@auth/drizzle-adapter": ^0.3.2
"@ctrl/deluge": ^4.1.0
"@ctrl/qbittorrent": ^6.0.0
"@ctrl/shared-torrent": ^4.1.1
@@ -7327,6 +7433,7 @@ __metadata:
"@types/better-sqlite3": ^7.6.5
"@types/cookies": ^0.7.7
"@types/dockerode": ^3.3.9
"@types/ldapjs": ^3.0.2
"@types/node": 18.17.8
"@types/prismjs": ^1.26.0
"@types/react": ^18.2.11
@@ -7370,9 +7477,8 @@ __metadata:
i18next: ^22.5.1
immer: ^10.0.2
js-file-download: ^0.4.12
ldapjs: ^3.0.5
mantine-react-table: ^1.3.4
moment: ^2.29.4
moment-timezone: ^0.5.43
next: 13.4.12
next-auth: ^4.23.0
next-i18next: ^14.0.0
@@ -8188,13 +8294,6 @@ __metadata:
languageName: node
linkType: hard
"jose@npm:^5.1.0":
version: 5.1.1
resolution: "jose@npm:5.1.1"
checksum: 3a18d85dd1ed0e7746c67cba65a95ee972f20b363ceb99a9d75b870beb34942089cfca6249c4a50a79bc854c5a052f1be39e814c42b0f00f9358e902ce706e8d
languageName: node
linkType: hard
"js-file-download@npm:^0.4.12":
version: 0.4.12
resolution: "js-file-download@npm:0.4.12"
@@ -8398,6 +8497,28 @@ __metadata:
languageName: node
linkType: hard
"ldapjs@npm:^3.0.5":
version: 3.0.7
resolution: "ldapjs@npm:3.0.7"
dependencies:
"@ldapjs/asn1": ^2.0.0
"@ldapjs/attribute": ^1.0.0
"@ldapjs/change": ^1.0.0
"@ldapjs/controls": ^2.1.0
"@ldapjs/dn": ^1.1.0
"@ldapjs/filter": ^2.1.1
"@ldapjs/messages": ^1.3.0
"@ldapjs/protocol": ^1.2.1
abstract-logging: ^2.0.1
assert-plus: ^1.0.0
backoff: ^2.5.0
once: ^1.4.0
vasync: ^2.2.1
verror: ^1.10.1
checksum: 4c0c4aeb5a0e22d0b1cba3779663472d8ebe6bc0fed5e56d6e29ac15b7f9e567e673c8764d0e51ca52eab48eef2024561a3553d6c804b11a260a893c18bd8df7
languageName: node
linkType: hard
"levn@npm:^0.4.1":
version: 0.4.1
resolution: "levn@npm:0.4.1"
@@ -8960,22 +9081,6 @@ __metadata:
languageName: node
linkType: hard
"moment-timezone@npm:^0.5.43":
version: 0.5.43
resolution: "moment-timezone@npm:0.5.43"
dependencies:
moment: ^2.29.4
checksum: 8075c897ed8a044f992ef26fe8cdbcad80caf974251db424cae157473cca03be2830de8c74d99341b76edae59f148c9d9d19c1c1d9363259085688ec1cf508d0
languageName: node
linkType: hard
"moment@npm:^2.29.4":
version: 2.29.4
resolution: "moment@npm:2.29.4"
checksum: 0ec3f9c2bcba38dc2451b1daed5daded747f17610b92427bebe1d08d48d8b7bdd8d9197500b072d14e326dd0ccf3e326b9e3d07c5895d3d49e39b6803b76e80e
languageName: node
linkType: hard
"mpd-parser@npm:^1.0.1, mpd-parser@npm:^1.2.2":
version: 1.2.2
resolution: "mpd-parser@npm:1.2.2"
@@ -9341,13 +9446,6 @@ __metadata:
languageName: node
linkType: hard
"oauth4webapi@npm:^2.3.0":
version: 2.4.0
resolution: "oauth4webapi@npm:2.4.0"
checksum: 9e6d5be3966013aa9dd61781032a6bd07a63166a9819f2fc0d622d33b23221ea39ae25334a4bde9eba4623e576972d367b196e3b5d3facff75002125c510b672
languageName: node
linkType: hard
"oauth@npm:^0.9.15":
version: 0.9.15
resolution: "oauth@npm:0.9.15"
@@ -9801,17 +9899,6 @@ __metadata:
languageName: node
linkType: hard
"preact-render-to-string@npm:5.2.3":
version: 5.2.3
resolution: "preact-render-to-string@npm:5.2.3"
dependencies:
pretty-format: ^3.8.0
peerDependencies:
preact: ">=10"
checksum: 6e46288d8956adde35b9fe3a21aecd9dea29751b40f0f155dea62f3896f27cb8614d457b32f48d33909d2da81135afcca6c55077520feacd7d15164d1371fb44
languageName: node
linkType: hard
"preact-render-to-string@npm:^5.1.19":
version: 5.2.6
resolution: "preact-render-to-string@npm:5.2.6"
@@ -9823,13 +9910,6 @@ __metadata:
languageName: node
linkType: hard
"preact@npm:10.11.3":
version: 10.11.3
resolution: "preact@npm:10.11.3"
checksum: 9387115aa0581e8226309e6456e9856f17dfc0e3d3e63f774de80f3d462a882ba7c60914c05942cb51d51e23e120dcfe904b8d392d46f29ad15802941fe7a367
languageName: node
linkType: hard
"preact@npm:^10.6.3":
version: 10.19.2
resolution: "preact@npm:10.19.2"
@@ -9859,6 +9939,13 @@ __metadata:
languageName: node
linkType: hard
"precond@npm:0.2":
version: 0.2.3
resolution: "precond@npm:0.2.3"
checksum: c613e7d68af3e0b43a294a994bf067cc2bc44b03fd17bc4fb133e30617a4f5b49414b08e9b392d52d7c6822d8a71f66a7fe93a8a1e7d02240177202cff3f63ef
languageName: node
linkType: hard
"prelude-ls@npm:^1.2.1":
version: 1.2.1
resolution: "prelude-ls@npm:1.2.1"
@@ -9941,6 +10028,13 @@ __metadata:
languageName: node
linkType: hard
"process-warning@npm:^2.1.0, process-warning@npm:^2.2.0":
version: 2.3.2
resolution: "process-warning@npm:2.3.2"
checksum: cbeddc85d3963eccd6578b1eea5ba981383d1ec688d6e4ba5bf0ca6662d094c024b44dfcb1c530662c7694b68fe09fd95fa0269a1309090d793008f4553e7784
languageName: node
linkType: hard
"process@npm:^0.11.10":
version: 0.11.10
resolution: "process@npm:0.11.10"
@@ -12520,6 +12614,37 @@ __metadata:
languageName: node
linkType: hard
"vasync@npm:^2.2.1":
version: 2.2.1
resolution: "vasync@npm:2.2.1"
dependencies:
verror: 1.10.0
checksum: dca14090436f1b30d4887737af47bc8333795a6d45e520e583ca2c4476d841bf68606cbc79071cfd980e3e42e630736d66a598b9100a505663442ae2e7c2f92f
languageName: node
linkType: hard
"verror@npm:1.10.0":
version: 1.10.0
resolution: "verror@npm:1.10.0"
dependencies:
assert-plus: ^1.0.0
core-util-is: 1.0.2
extsprintf: ^1.2.0
checksum: c431df0bedf2088b227a4e051e0ff4ca54df2c114096b0c01e1cbaadb021c30a04d7dd5b41ab277bcd51246ca135bf931d4c4c796ecae7a4fef6d744ecef36ea
languageName: node
linkType: hard
"verror@npm:^1.10.1":
version: 1.10.1
resolution: "verror@npm:1.10.1"
dependencies:
assert-plus: ^1.0.0
core-util-is: 1.0.2
extsprintf: ^1.2.0
checksum: 690a8d6ad5a4001672290e9719e3107c86269bc45fe19f844758eecf502e59f8aa9631b19b839f6d3dea562334884d22d1eb95ae7c863032075a9212c889e116
languageName: node
linkType: hard
"video.js@npm:^7 || ^8, video.js@npm:^8.0.3":
version: 8.6.1
resolution: "video.js@npm:8.6.1"