Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e63c0b0e65 | ||
|
|
8a43bf47aa | ||
|
|
d4bb014a9b | ||
|
|
e01d74f4f8 | ||
|
|
ec3bda34e0 | ||
|
|
5c16fb8d6c | ||
|
|
986e013ca3 | ||
|
|
1fd3fd8dfb | ||
|
|
a03a01b964 | ||
|
|
4d94fce523 | ||
|
|
75c791801e | ||
|
|
3e67b411b6 | ||
|
|
ff1a003037 | ||
|
|
99c69ba65d | ||
|
|
f5cf92c49c | ||
|
|
05dc51b38e | ||
|
|
a53e0f7cee | ||
|
|
f3a91ca37f | ||
|
|
1e406a8a58 | ||
|
|
2ea763c231 | ||
|
|
579bb2a33e | ||
|
|
feecdd36d4 | ||
|
|
56755d1ab4 | ||
|
|
6b36ec3103 | ||
|
|
fc6dbc92df | ||
|
|
467d5e21ae | ||
|
|
b399cb6979 | ||
|
|
4d69b81676 | ||
|
|
a1fc9902a5 | ||
|
|
0649220080 | ||
|
|
e502fbc533 | ||
|
|
f8c3dab7f9 | ||
|
|
039800d94d | ||
|
|
9a7e421f68 | ||
|
|
e695fb64d3 | ||
|
|
62da953356 | ||
|
|
39171ac76a | ||
|
|
546c824888 | ||
|
|
a12dd10269 | ||
|
|
80c02ef9f2 | ||
|
|
7d1b1d8342 | ||
|
|
dc3d27e1a1 | ||
|
|
ec8ad483ca | ||
|
|
8edf922e30 | ||
|
|
86a925939c | ||
|
|
b034dcbb11 | ||
|
|
93d7d99e70 | ||
|
|
bfe5e9a8e1 | ||
|
|
e056f620da | ||
|
|
3860af31b6 | ||
|
|
a69c64d88d | ||
|
|
34a6c65ead | ||
|
|
e33e0a4d2b | ||
|
|
ffdc8d3496 | ||
|
|
64cf3d02c4 | ||
|
|
f4f02ca539 | ||
|
|
6327ca073c | ||
|
|
7807b28d24 | ||
|
|
02386ffcd8 | ||
|
|
690cc303bf | ||
|
|
9da50e518b | ||
|
|
eb424d757b | ||
|
|
65befa22ba | ||
|
|
6a68ccfee4 | ||
|
|
e46b1c49f9 | ||
|
|
64cc078b09 | ||
|
|
be4e75321a | ||
|
|
e0eab425e6 | ||
|
|
2dec74e5ff | ||
|
|
39ea2baff3 | ||
|
|
a160f16772 | ||
|
|
ccb19e06c1 | ||
|
|
bf12944768 | ||
|
|
1e9098d052 | ||
|
|
1562eae4d7 | ||
|
|
c349bf8371 | ||
|
|
1f0116d725 | ||
|
|
49d10f7ad0 | ||
|
|
d98552540a | ||
|
|
92f4f9421e | ||
|
|
53f07f87b8 | ||
|
|
812469db82 | ||
|
|
e2b211cf34 | ||
|
|
2358f6f5eb | ||
|
|
024ec9235d | ||
|
|
58fdea0245 | ||
|
|
4ead238462 | ||
|
|
6305c74f90 | ||
|
|
0780080681 | ||
|
|
aa15a705d7 | ||
|
|
cc70175125 | ||
|
|
bc5e25e9cd | ||
|
|
d43557e165 | ||
|
|
b6c96f73a5 | ||
|
|
ee8831affe | ||
|
|
6e21863d04 | ||
|
|
ca39ca66f0 | ||
|
|
cc7d53e84e | ||
|
|
99c409cbb7 | ||
|
|
1fcf9d0146 | ||
|
|
a234fc844e |
No files matched your search
+2
-1
@@ -6,4 +6,5 @@ README.md
|
||||
.next
|
||||
.git
|
||||
dev
|
||||
.build
|
||||
.build
|
||||
e2e
|
||||
@@ -91,6 +91,8 @@ jobs:
|
||||
network: host
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- name: Install playwright browsers
|
||||
run: pnpm exec playwright install chromium
|
||||
- name: Run E2E Tests
|
||||
shell: bash
|
||||
run: pnpm test:e2e
|
||||
|
||||
@@ -94,6 +94,8 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
git config user.name "Releases Homarr"
|
||||
git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
|
||||
git fetch origin dev
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
|
||||
@@ -54,10 +54,14 @@ yarn-error.log*
|
||||
*.log
|
||||
|
||||
apps/tasks/tasks.cjs
|
||||
apps/tasks/tasks.css
|
||||
apps/websocket/wssServer.cjs
|
||||
apps/websocket/wssServer.css
|
||||
apps/nextjs/.million/
|
||||
packages/cli/cli.cjs
|
||||
|
||||
# e2e mounts
|
||||
e2e/shared/tmp
|
||||
|
||||
#personal backgrounds
|
||||
apps/nextjs/public/images/background.png
|
||||
@@ -1,3 +1,80 @@
|
||||
## [1.0.0-beta.13](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.12...v1.0.0-beta.13) (2025-01-14)
|
||||
|
||||
### Features
|
||||
|
||||
* **board:** add mobile home board ([#1910](https://github.com/homarr-labs/homarr/issues/1910)) ([e01d74f](https://github.com/homarr-labs/homarr/commit/e01d74f4f89aee9a122b660e6303c356871c6ebb))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **app:** missing noreferer attribute for anchor tag ([#1915](https://github.com/homarr-labs/homarr/issues/1915)) ([986e013](https://github.com/homarr-labs/homarr/commit/986e013ca3eaf402d9014c5586536e05d93e9adc))
|
||||
* **category:** remove not always working and ignoring dynamic sections ([#1939](https://github.com/homarr-labs/homarr/issues/1939)) ([d4bb014](https://github.com/homarr-labs/homarr/commit/d4bb014a9ba83770fd7880b48a4a5ab5362f6f75))
|
||||
* **deps:** update dependency @ianvs/prettier-plugin-sort-imports to ^4.4.1 ([#1936](https://github.com/homarr-labs/homarr/issues/1936)) ([1e406a8](https://github.com/homarr-labs/homarr/commit/1e406a8a58ebdf51f59b5aa0e079baf46c6d02cf))
|
||||
* **deps:** update dependency eslint-config-prettier to v10 ([#1937](https://github.com/homarr-labs/homarr/issues/1937)) ([75c7918](https://github.com/homarr-labs/homarr/commit/75c791801e593e88deb3abcacf2b91aade0871d8))
|
||||
* **deps:** update dependency eslint-plugin-react to ^7.37.4 ([#1934](https://github.com/homarr-labs/homarr/issues/1934)) ([2ea763c](https://github.com/homarr-labs/homarr/commit/2ea763c23161753619a204f9bb522ab04c37a2ab))
|
||||
* **deps:** update dependency sass to ^1.83.3 ([#1945](https://github.com/homarr-labs/homarr/issues/1945)) ([99c69ba](https://github.com/homarr-labs/homarr/commit/99c69ba65ddb5f8cf6bdd61518649fc21405ac87))
|
||||
* **deps:** update dependency sass to ^1.83.4 ([#1946](https://github.com/homarr-labs/homarr/issues/1946)) ([ff1a003](https://github.com/homarr-labs/homarr/commit/ff1a00303728b15b8e709ce57d77a78967abab78))
|
||||
* **deps:** update dependency typescript-eslint to ^8.20.0 ([#1938](https://github.com/homarr-labs/homarr/issues/1938)) ([feecdd3](https://github.com/homarr-labs/homarr/commit/feecdd36d49890022e8f479efe6cefa3f77d265a))
|
||||
* **deps:** update mantine monorepo to ^7.16.0 ([#1948](https://github.com/homarr-labs/homarr/issues/1948)) ([a03a01b](https://github.com/homarr-labs/homarr/commit/a03a01b964e3ab30af6078326b91fb10817b3b98))
|
||||
* **deps:** update tanstack-query monorepo to ^5.64.0 ([#1922](https://github.com/homarr-labs/homarr/issues/1922)) ([b399cb6](https://github.com/homarr-labs/homarr/commit/b399cb697945cd1bb3da2ad5222aceb9b0e71d2b))
|
||||
* **deps:** update tanstack-query monorepo to ^5.64.1 ([#1935](https://github.com/homarr-labs/homarr/issues/1935)) ([a53e0f7](https://github.com/homarr-labs/homarr/commit/a53e0f7ceed9cf771609be7b04090df9744ec4e6))
|
||||
* **media-request:** media and media-request status can have missing values ([#1911](https://github.com/homarr-labs/homarr/issues/1911)) ([ec3bda3](https://github.com/homarr-labs/homarr/commit/ec3bda34e0c1adacf0cfc5d7af272f9f2617fbe5))
|
||||
* trpc removed export anything from client in react-query ([#1941](https://github.com/homarr-labs/homarr/issues/1941)) ([56755d1](https://github.com/homarr-labs/homarr/commit/56755d1ab4194de04faeb569020f6d19623bd29a))
|
||||
|
||||
## [1.0.0-beta.12](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2025-01-10)
|
||||
|
||||
### Features
|
||||
|
||||
* **app:** add search and pagination ([#1860](https://github.com/homarr-labs/homarr/issues/1860)) ([ccb19e0](https://github.com/homarr-labs/homarr/commit/ccb19e06c192029a81c851900982d52b045185a6))
|
||||
* **auth:** add env variable for oidc-name-attribute-overwrite ([#1850](https://github.com/homarr-labs/homarr/issues/1850)) ([c349bf8](https://github.com/homarr-labs/homarr/commit/c349bf83714739ca1bc6057f2d43c9e3964fa205))
|
||||
* **board:** add board duplication ([#1856](https://github.com/homarr-labs/homarr/issues/1856)) ([49d10f7](https://github.com/homarr-labs/homarr/commit/49d10f7ad0f58b577ed72c2a46a062af263ff225))
|
||||
* docker add to homarr ([#1825](https://github.com/homarr-labs/homarr/issues/1825)) ([64cc078](https://github.com/homarr-labs/homarr/commit/64cc078b095e64edca17e07a09a4f5c340665f84))
|
||||
* **icons:** add upload button to icon picker ([#1859](https://github.com/homarr-labs/homarr/issues/1859)) ([1e9098d](https://github.com/homarr-labs/homarr/commit/1e9098d052801fe7d53ce15e9f22ed89de04d644))
|
||||
* **items:** add search to selection ([#1887](https://github.com/homarr-labs/homarr/issues/1887)) ([62da953](https://github.com/homarr-labs/homarr/commit/62da953356ff76694ddc50980a2b879a01bcbdd6))
|
||||
* **spotlight:** add default search engine ([#1807](https://github.com/homarr-labs/homarr/issues/1807)) ([65befa2](https://github.com/homarr-labs/homarr/commit/65befa22ba91c36a3734ac6dc027175dc9fca892))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency @tabler/icons-react to ^3.27.1 ([#1875](https://github.com/homarr-labs/homarr/issues/1875)) ([e46b1c4](https://github.com/homarr-labs/homarr/commit/e46b1c49f9bb1320f82b2de0585f7e2c7bc72c54))
|
||||
* **deps:** update dependency @tabler/icons-react to ^3.28.1 ([#1891](https://github.com/homarr-labs/homarr/issues/1891)) ([ffdc8d3](https://github.com/homarr-labs/homarr/commit/ffdc8d34960dea0172c50a1d93a4cd14997b13ac))
|
||||
* **deps:** update dependency better-sqlite3 to ^11.7.2 ([#1858](https://github.com/homarr-labs/homarr/issues/1858)) ([1f0116d](https://github.com/homarr-labs/homarr/commit/1f0116d725d76845df4b9cce74ef87134edc627a))
|
||||
* **deps:** update dependency dockerode to ^4.0.3 ([#1854](https://github.com/homarr-labs/homarr/issues/1854)) ([92f4f94](https://github.com/homarr-labs/homarr/commit/92f4f9421e8f693e6546716f5f04922161e29cdc))
|
||||
* **deps:** update dependency glob to ^11.0.1 ([#1908](https://github.com/homarr-labs/homarr/issues/1908)) ([7d1b1d8](https://github.com/homarr-labs/homarr/commit/7d1b1d83429acaa9846479a7a5c48092d4614b75))
|
||||
* **deps:** update dependency ldapts to v7.3.1 ([#1896](https://github.com/homarr-labs/homarr/issues/1896)) ([a69c64d](https://github.com/homarr-labs/homarr/commit/a69c64d88d57293eba8adbdf88ce694194268492))
|
||||
* **deps:** update dependency mantine-react-table to v2.0.0-beta.8 ([#1902](https://github.com/homarr-labs/homarr/issues/1902)) ([86a9259](https://github.com/homarr-labs/homarr/commit/86a925939cb8ef92fe0bca5a762e688887916b04))
|
||||
* **deps:** update dependency octokit to ^4.1.0 ([#1898](https://github.com/homarr-labs/homarr/issues/1898)) ([3860af3](https://github.com/homarr-labs/homarr/commit/3860af31b64e8a74e7e1c91470a9de5450f9e5e7))
|
||||
* **deps:** update dependency sass to ^1.83.1 ([#1852](https://github.com/homarr-labs/homarr/issues/1852)) ([812469d](https://github.com/homarr-labs/homarr/commit/812469db827badee63c73a8c819922c24fdefd13))
|
||||
* **deps:** update dependency trpc-to-openapi to ^2.1.2 ([#1900](https://github.com/homarr-labs/homarr/issues/1900)) ([8edf922](https://github.com/homarr-labs/homarr/commit/8edf922e3050ba9d8c951180ae68f628067c190e))
|
||||
* **deps:** update dependency typescript-eslint to ^8.19.1 ([#1874](https://github.com/homarr-labs/homarr/issues/1874)) ([be4e753](https://github.com/homarr-labs/homarr/commit/be4e75321a0a6a05c6ac6c4ba97ffb5068aed4aa))
|
||||
* **deps:** update dependency undici to v7.2.1 ([#1904](https://github.com/homarr-labs/homarr/issues/1904)) ([b034dcb](https://github.com/homarr-labs/homarr/commit/b034dcbb11017a016e30db0e2bf8c1a0765174ca))
|
||||
* **deps:** update mantine monorepo to ^7.15.3 ([#1880](https://github.com/homarr-labs/homarr/issues/1880)) ([7807b28](https://github.com/homarr-labs/homarr/commit/7807b28d24327db89514da533cb06f4c296b579d))
|
||||
* **deps:** update nextjs monorepo to ^15.1.4 ([#1885](https://github.com/homarr-labs/homarr/issues/1885)) ([f4f02ca](https://github.com/homarr-labs/homarr/commit/f4f02ca539609705b7192c4327272f88415e1594))
|
||||
* **deps:** update nextjs monorepo to v15 (major) ([#1844](https://github.com/homarr-labs/homarr/issues/1844)) ([d985525](https://github.com/homarr-labs/homarr/commit/d98552540a9eb3c24ba92a490f921c2b1911b1f6))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.14 ([#1846](https://github.com/homarr-labs/homarr/issues/1846)) ([2358f6f](https://github.com/homarr-labs/homarr/commit/2358f6f5ebf4bbb9eb4ccd91990f967d9cc44e3b))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.15 ([#1853](https://github.com/homarr-labs/homarr/issues/1853)) ([53f07f8](https://github.com/homarr-labs/homarr/commit/53f07f87b8d600ce57fa94d2ffb4d6f0105102d0))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.16 ([#1877](https://github.com/homarr-labs/homarr/issues/1877)) ([eb424d7](https://github.com/homarr-labs/homarr/commit/eb424d757b7934d5a6bce9de943528f17e678b80))
|
||||
* **deps:** update tanstack-query monorepo to ^5.63.0 ([#1893](https://github.com/homarr-labs/homarr/issues/1893)) ([e33e0a4](https://github.com/homarr-labs/homarr/commit/e33e0a4d2bd91d610c6d29522a98cae8b9953478))
|
||||
* **deps:** update tiptap monorepo to v2.11.1 ([#1909](https://github.com/homarr-labs/homarr/issues/1909)) ([dc3d27e](https://github.com/homarr-labs/homarr/commit/dc3d27e1a1b1611c1997235bf51daf00e3d1f239))
|
||||
* **deps:** update tiptap monorepo to v2.11.2 ([#1913](https://github.com/homarr-labs/homarr/issues/1913)) ([9a7e421](https://github.com/homarr-labs/homarr/commit/9a7e421f6827a5a0ac3627318e51537840295e72))
|
||||
* **dns-hole-summary:** calculation for domains and percentage for multiple integrations ([#1894](https://github.com/homarr-labs/homarr/issues/1894)) ([546c824](https://github.com/homarr-labs/homarr/commit/546c824888a2a6909b831b69ce1a4e283099bda5))
|
||||
* **iframe:** remove second scrollbar from item card ([#1828](https://github.com/homarr-labs/homarr/issues/1828)) ([bf12944](https://github.com/homarr-labs/homarr/commit/bf1294476867fabc896f94c9326a1acfe6bffa50))
|
||||
* **security:** restrict link protocols to http and https ([#1888](https://github.com/homarr-labs/homarr/issues/1888)) ([a12dd10](https://github.com/homarr-labs/homarr/commit/a12dd102692ee237e9695bd852dc894a2856b30e))
|
||||
* **translation:** query parameter in search with escaped ([#1895](https://github.com/homarr-labs/homarr/issues/1895)) ([80c02ef](https://github.com/homarr-labs/homarr/commit/80c02ef9f24cec74025a0fcc983c6db37f7fd457))
|
||||
* **widget:** set-options from widget components should be partial ([#1882](https://github.com/homarr-labs/homarr/issues/1882)) ([e695fb6](https://github.com/homarr-labs/homarr/commit/e695fb64d317ecb47b6844dc2a185502244f7db1))
|
||||
|
||||
## [1.0.0-beta.11](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2025-01-03)
|
||||
|
||||
### Features
|
||||
|
||||
* remove import beta badge ([#1829](https://github.com/homarr-labs/homarr/issues/1829)) ([6305c74](https://github.com/homarr-labs/homarr/commit/6305c74f90d943722c915ffbf54b5fd7e68a7dca))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency zod-form-data to ^2.0.5 ([#1822](https://github.com/homarr-labs/homarr/issues/1822)) ([a234fc8](https://github.com/homarr-labs/homarr/commit/a234fc844e5edcef9935efb0725b6ccceadfe69d))
|
||||
* **deps:** update tanstack-query monorepo to ^5.62.12 ([#1843](https://github.com/homarr-labs/homarr/issues/1843)) ([bc5e25e](https://github.com/homarr-labs/homarr/commit/bc5e25e9cd3269a3d8cc8ef521ae75aff50088be))
|
||||
* **icons:** picker does not allow multiple words ([#1827](https://github.com/homarr-labs/homarr/issues/1827)) ([6e21863](https://github.com/homarr-labs/homarr/commit/6e21863d04f7d0598e7e7ca3fb707f9beb09d7ef))
|
||||
* login page in light mode is always black ([#1821](https://github.com/homarr-labs/homarr/issues/1821)) ([cc7d53e](https://github.com/homarr-labs/homarr/commit/cc7d53e84ec8c2dd3698a178ca6f35099194b5d7))
|
||||
* prowlarr integration indexers status changed from id to indexerId ([#1837](https://github.com/homarr-labs/homarr/issues/1837)) ([ee8831a](https://github.com/homarr-labs/homarr/commit/ee8831affe34a33b80a0993aa538c2b16c035f11))
|
||||
|
||||
## [1.0.0-beta.10](https://github.com/homarr-labs/homarr/compare/v1.0.0-beta.9...v1.0.0-beta.10) (2024-12-31)
|
||||
|
||||
### Features
|
||||
|
||||
+2
-5
@@ -1,4 +1,4 @@
|
||||
FROM node:22.12.0-alpine AS base
|
||||
FROM node:22.13.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
@@ -12,9 +12,6 @@ COPY . .
|
||||
|
||||
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
||||
|
||||
# Install sharp for image optimization
|
||||
RUN corepack enable pnpm && pnpm install sharp -w
|
||||
|
||||
# Copy static data as it is not part of the build
|
||||
COPY static-data ./static-data
|
||||
ARG SKIP_ENV_VALIDATION='true'
|
||||
@@ -42,7 +39,7 @@ RUN mkdir -p /var/cache/nginx && \
|
||||
touch /run/nginx/nginx.pid && \
|
||||
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs
|
||||
|
||||
COPY --from=builder /app/apps/nextjs/next.config.mjs .
|
||||
COPY --from=builder /app/apps/nextjs/next.config.ts .
|
||||
COPY --from=builder /app/apps/nextjs/package.json .
|
||||
|
||||
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
// Importing env files here to validate on build
|
||||
import "@homarr/auth/env.mjs";
|
||||
import "@homarr/db/env.mjs";
|
||||
import "@homarr/common/env.mjs";
|
||||
import "@homarr/auth/env";
|
||||
import "@homarr/db/env";
|
||||
import "@homarr/common/env";
|
||||
|
||||
import type { NextConfig } from "next";
|
||||
import MillionLint from "@million/lint";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
import "./src/env.mjs";
|
||||
import "./src/env.ts";
|
||||
|
||||
// Package path does not work... so we need to use relative path
|
||||
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
interface WebpackConfig {
|
||||
module: {
|
||||
rules: {
|
||||
test: RegExp;
|
||||
loader: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
/** We already do linting and typechecking as separate tasks in CI */
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
webpack: (config, { isServer }) => {
|
||||
webpack: (config: WebpackConfig, { isServer }) => {
|
||||
if (isServer) {
|
||||
config.module.rules.push({
|
||||
test: /\.node$/,
|
||||
@@ -38,6 +47,7 @@ const nextConfig = {
|
||||
};
|
||||
|
||||
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
+24
-24
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"dev": "pnpm with-env next dev --turbopack",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"start": "pnpm with-env next start",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/gridstack": "^1.11.2",
|
||||
"@homarr/gridstack": "^1.11.3",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
@@ -38,18 +38,18 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "^7.15.2",
|
||||
"@mantine/core": "^7.15.2",
|
||||
"@mantine/dropzone": "^7.15.2",
|
||||
"@mantine/hooks": "^7.15.2",
|
||||
"@mantine/modals": "^7.15.2",
|
||||
"@mantine/tiptap": "^7.15.2",
|
||||
"@mantine/colors-generator": "^7.16.0",
|
||||
"@mantine/core": "^7.16.0",
|
||||
"@mantine/dropzone": "^7.16.0",
|
||||
"@mantine/hooks": "^7.16.0",
|
||||
"@mantine/modals": "^7.16.0",
|
||||
"@mantine/tiptap": "^7.16.0",
|
||||
"@million/lint": "1.0.14",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tabler/icons-react": "^3.26.0",
|
||||
"@tanstack/react-query": "^5.62.11",
|
||||
"@tanstack/react-query-devtools": "^5.62.11",
|
||||
"@tanstack/react-query-next-experimental": "5.62.11",
|
||||
"@tabler/icons-react": "^3.28.1",
|
||||
"@tanstack/react-query": "^5.64.1",
|
||||
"@tanstack/react-query-devtools": "^5.64.1",
|
||||
"@tanstack/react-query-next-experimental": "5.64.1",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
@@ -62,17 +62,17 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"flag-icons": "^7.2.3",
|
||||
"glob": "^11.0.0",
|
||||
"glob": "^11.0.1",
|
||||
"jotai": "^2.11.0",
|
||||
"mantine-react-table": "2.0.0-beta.7",
|
||||
"next": "^14.2.22",
|
||||
"mantine-react-table": "2.0.0-beta.8",
|
||||
"next": "15.1.4",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.83.0",
|
||||
"sass": "^1.83.4",
|
||||
"superjson": "2.2.2",
|
||||
"swagger-ui-react": "^5.18.2",
|
||||
"use-deep-compare-effect": "^1.8.1"
|
||||
@@ -81,16 +81,16 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "2.4.5",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/chroma-js": "3.1.0",
|
||||
"@types/node": "^22.10.6",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/react": "19.0.7",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint": "^9.18.0",
|
||||
"node-loader": "^2.1.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,10 @@ import superjson from "superjson";
|
||||
import type { SuperJSONResult } from "superjson";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
|
||||
import { clientApi, getTrpcUrl } from "@homarr/api/client";
|
||||
import { createHeadersCallbackForSource } from "@homarr/api/shared";
|
||||
|
||||
import { env } from "~/env.mjs";
|
||||
import { env } from "~/env";
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
// window is not defined on server side
|
||||
|
||||
@@ -11,15 +11,17 @@ import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { RegistrationForm } from "./_registration-form";
|
||||
|
||||
interface InviteUsagePageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
};
|
||||
searchParams: {
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
token: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
|
||||
export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
if (!isProviderEnabled("credentials")) notFound();
|
||||
|
||||
const session = await auth();
|
||||
@@ -57,7 +59,7 @@ export default async function InviteUsagePage({ params, searchParams }: InviteUs
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||
<Card withBorder w={64 * 6} maw="90vw">
|
||||
<RegistrationForm invite={invite} />
|
||||
</Card>
|
||||
<Text size="xs" c="gray.5" ta="center">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { env } from "@homarr/auth/env.mjs";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
@@ -9,12 +9,13 @@ import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { LoginForm } from "./_login-form";
|
||||
|
||||
interface LoginProps {
|
||||
searchParams: {
|
||||
searchParams: Promise<{
|
||||
callbackUrl?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function Login({ searchParams }: LoginProps) {
|
||||
export default async function Login(props: LoginProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
|
||||
if (session) {
|
||||
@@ -35,7 +36,7 @@ export default async function Login({ searchParams }: LoginProps) {
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||
<Card withBorder w={64 * 6} maw="90vw">
|
||||
<LoginForm
|
||||
providers={env.AUTH_PROVIDERS}
|
||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { env } from "~/env.mjs";
|
||||
import { env } from "~/env";
|
||||
import { useEditMode, useRequiredBoard } from "./_context";
|
||||
|
||||
export const BoardContentHeaderActions = () => {
|
||||
|
||||
@@ -31,15 +31,15 @@ import { GeneralSettingsContent } from "./_general";
|
||||
import { LayoutSettingsContent } from "./_layout";
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
params: Promise<{
|
||||
name: string;
|
||||
};
|
||||
searchParams: {
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
tab?: keyof TranslationObject["board"]["setting"]["section"];
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
||||
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
|
||||
try {
|
||||
const board = await api.board.getBoardByName({ name: params.name });
|
||||
const { hasFullAccess } = await getBoardPermissionsAsync(board);
|
||||
@@ -63,7 +63,9 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
||||
}
|
||||
};
|
||||
|
||||
export default async function BoardSettingsPage({ params, searchParams }: Props) {
|
||||
export default async function BoardSettingsPage(props: Props) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);
|
||||
@@ -99,7 +101,11 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||
<DangerZoneSettingsContent hideVisibility={boardSettings.homeBoardId === board.id} />
|
||||
<DangerZoneSettingsContent
|
||||
hideVisibility={
|
||||
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
|
||||
}
|
||||
/>
|
||||
</AccordionItemFor>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -28,9 +28,9 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
params,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
params: TParams;
|
||||
params: Promise<TParams>;
|
||||
}>) => {
|
||||
const initialBoard = await getInitialBoard(params).catch((error) => {
|
||||
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
logger.warn(error);
|
||||
notFound();
|
||||
|
||||
@@ -9,11 +9,12 @@ import "~/styles/scroll-area.scss";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { env } from "@homarr/auth/env.mjs";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
import { Notifications } from "@homarr/notifications";
|
||||
import { SpotlightProvider } from "@homarr/spotlight";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
import { getI18nMessages } from "@homarr/translation/server";
|
||||
|
||||
@@ -63,14 +64,17 @@ export const viewport: Viewport = {
|
||||
],
|
||||
};
|
||||
|
||||
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
||||
if (!isLocaleSupported(props.params.locale)) {
|
||||
export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: SupportedLanguage }>;
|
||||
}) {
|
||||
if (!isLocaleSupported((await props.params).locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
const direction = isLocaleRTL(props.params.locale) ? "rtl" : "ltr";
|
||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
||||
const i18nMessages = await getI18nMessages();
|
||||
|
||||
const StackedProvider = composeWrappers([
|
||||
@@ -89,7 +93,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
||||
return (
|
||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||
<html
|
||||
lang={props.params.locale}
|
||||
lang={(await props.params).locale}
|
||||
dir={direction}
|
||||
data-mantine-color-scheme={colorScheme}
|
||||
style={{
|
||||
|
||||
@@ -5,29 +5,29 @@ import { splitToNChunks } from "@homarr/common";
|
||||
import classes from "./hero-banner.module.css";
|
||||
|
||||
const icons = [
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/homarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/deluge.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sabnzbd.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/deluge.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/lidarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/dashdot.png",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/overseerr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/dashdot.png",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/readarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/transmission.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/qbittorrent.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nzbget.png",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/openmediavault.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/readarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/transmission.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nzbget.png",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openmediavault.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyseerr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyseerr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/prowlarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tdarr.png",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
|
||||
];
|
||||
|
||||
const countIconGroups = 3;
|
||||
|
||||
@@ -37,16 +37,16 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
const getHost = () => {
|
||||
const getHostAsync = async () => {
|
||||
if (process.env.HOSTNAME) {
|
||||
return `${process.env.HOSTNAME}:3000`;
|
||||
}
|
||||
|
||||
return headers().get("host");
|
||||
return (await headers()).get("host");
|
||||
};
|
||||
|
||||
export default async function AboutPage() {
|
||||
const baseServerUrl = `http://${getHost()}`;
|
||||
const baseServerUrl = `http://${await getHostAsync()}`;
|
||||
const t = await getScopedI18n("management.page.about");
|
||||
const attributes = await getPackageAttributesAsync();
|
||||
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
|
||||
|
||||
@@ -9,10 +9,11 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AppEditForm } from "./_app-edit-form";
|
||||
|
||||
interface AppEditPageProps {
|
||||
params: { id: string };
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function AppEditPage({ params }: AppEditPageProps) {
|
||||
export default async function AppEditPage(props: AppEditPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("app-modify-all")) {
|
||||
|
||||
@@ -6,7 +6,10 @@ import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
@@ -14,22 +17,35 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
export default async function AppsPage() {
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
interface AppsPageProps {
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function AppsPage(props: AppsPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const apps = await api.app.all();
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
|
||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||
const t = await getScopedI18n("app");
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} />
|
||||
{session.user.permissions.includes("app-create") && (
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
@@ -44,6 +60,10 @@ export default async function AppsPage() {
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="end">
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
);
|
||||
|
||||
+36
-2
@@ -3,12 +3,14 @@
|
||||
import { useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
@@ -30,8 +32,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
|
||||
|
||||
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
@@ -39,6 +43,12 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
await revalidatePathActionAsync("/");
|
||||
},
|
||||
});
|
||||
const setMobileHomeBoardMutation = clientApi.board.setMobileHomeBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
// Revalidate all as it's part of the user settings, /boards page and board manage page
|
||||
await revalidatePathActionAsync("/");
|
||||
},
|
||||
});
|
||||
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
@@ -64,11 +74,35 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
||||
}, [board.id, setHomeBoardMutation]);
|
||||
|
||||
const handleSetMobileHomeBoard = useCallback(async () => {
|
||||
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
|
||||
}, [board.id, setMobileHomeBoardMutation]);
|
||||
|
||||
const handleDuplicateBoard = useCallback(() => {
|
||||
openDuplicateModal({
|
||||
board: {
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
},
|
||||
});
|
||||
}, [board.id, board.name, openDuplicateModal]);
|
||||
|
||||
return (
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
||||
{t("setHomeBoard.label")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
|
||||
{t("setMobileHomeBoard.label")}
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("board-create") && (
|
||||
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||
{t("duplicate.label")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{hasChangeAccess && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Affix, Button, Group, Menu } from "@mantine/core";
|
||||
import { Affix, Button, Menu } from "@mantine/core";
|
||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BetaBadge } from "@homarr/ui";
|
||||
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n();
|
||||
@@ -26,10 +25,7 @@ export const CreateBoardButton = () => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||
<Group>
|
||||
{t("board.action.oldImport.label")}
|
||||
<BetaBadge size="xs" />
|
||||
</Group>
|
||||
{t("board.action.oldImport.label")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
||||
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -88,6 +88,14 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.isMobileHome && (
|
||||
<Tooltip label={t("action.setMobileHomeBoard.badge.tooltip")}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
|
||||
{t("action.setMobileHomeBoard.badge.label")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.creator && (
|
||||
<Group gap="xs">
|
||||
<UserAvatar user={board.creator} size="sm" />
|
||||
|
||||
@@ -11,10 +11,11 @@ import { IntegrationAccessSettings } from "../../_components/integration-access-
|
||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||
|
||||
interface EditIntegrationPageProps {
|
||||
params: { id: string };
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
|
||||
export default async function EditIntegrationPage(props: EditIntegrationPageProps) {
|
||||
const params = await props.params;
|
||||
const editT = await getScopedI18n("integration.page.edit");
|
||||
const t = await getI18n();
|
||||
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
@@ -13,12 +13,15 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NewIntegrationForm } from "./_integration-new-form";
|
||||
|
||||
interface NewIntegrationPageProps {
|
||||
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
||||
kind: IntegrationKind;
|
||||
};
|
||||
searchParams: Promise<
|
||||
Partial<z.infer<typeof validation.integration.create>> & {
|
||||
kind: IntegrationKind;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
||||
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("integration-create")) {
|
||||
notFound();
|
||||
|
||||
@@ -46,12 +46,13 @@ import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
searchParams: {
|
||||
searchParams: Promise<{
|
||||
tab?: IntegrationKind;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
||||
export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { JSX } from "react";
|
||||
import { Button, FileButton } from "@mantine/core";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { supportedMediaUploadFormats } from "@homarr/validation";
|
||||
|
||||
export const UploadMedia = () => {
|
||||
export const UploadMediaButton = () => {
|
||||
const t = useI18n();
|
||||
const onSettledAsync = async () => {
|
||||
await revalidatePathActionAsync("/manage/medias");
|
||||
};
|
||||
|
||||
return (
|
||||
<UploadMedia onSettled={onSettledAsync}>
|
||||
{({ onClick, loading }) => (
|
||||
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
||||
{t("media.action.upload.label")}
|
||||
</Button>
|
||||
)}
|
||||
</UploadMedia>
|
||||
);
|
||||
};
|
||||
|
||||
interface UploadMediaProps {
|
||||
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
|
||||
onSettled?: () => MaybePromise<void>;
|
||||
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
||||
|
||||
@@ -18,10 +43,14 @@ export const UploadMedia = () => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
await mutateAsync(formData, {
|
||||
onSuccess() {
|
||||
async onSuccess(mediaId) {
|
||||
showSuccessNotification({
|
||||
message: t("media.action.upload.notification.success.message"),
|
||||
});
|
||||
await onSuccess?.({
|
||||
id: mediaId,
|
||||
url: `/api/user-medias/${mediaId}`,
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
@@ -29,18 +58,14 @@ export const UploadMedia = () => {
|
||||
});
|
||||
},
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync("/manage/medias");
|
||||
await onSettled?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
||||
{({ onClick }) => (
|
||||
<Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
||||
{t("media.action.upload.label")}
|
||||
</Button>
|
||||
)}
|
||||
{({ onClick }) => children({ onClick, loading: isPending })}
|
||||
</FileButton>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
@@ -16,7 +17,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { CopyMedia } from "./_actions/copy-media";
|
||||
import { DeleteMedia } from "./_actions/delete-media";
|
||||
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
||||
import { UploadMedia } from "./_actions/upload-media";
|
||||
import { UploadMediaButton } from "./_actions/upload-media";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
@@ -29,12 +30,8 @@ const searchParamsSchema = z.object({
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
||||
}>;
|
||||
|
||||
interface MediaListPageProps {
|
||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
@@ -45,7 +42,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||
|
||||
return (
|
||||
@@ -61,7 +58,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{session.user.permissions.includes("media-upload") && <UploadMedia />}
|
||||
{session.user.permissions.includes("media-upload") && <UploadMediaButton />}
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
|
||||
@@ -10,10 +10,11 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { SearchEngineEditForm } from "./_search-engine-edit-form";
|
||||
|
||||
interface SearchEngineEditPageProps {
|
||||
params: { id: string };
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
|
||||
export default async function SearchEngineEditPage(props: SearchEngineEditPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("search-engine-modify-all")) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IconPencil, IconSearch } from "@tabler/icons-react";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
@@ -22,12 +23,8 @@ const searchParamsSchema = z.object({
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
||||
}>;
|
||||
|
||||
interface SearchEnginesPageProps {
|
||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
@@ -37,7 +34,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
||||
|
||||
const tEngine = await getScopedI18n("search.engine");
|
||||
|
||||
@@ -37,6 +37,25 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
||||
)}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<SelectWithCustomItems
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
data={selectableBoards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
image: board.logoImageUrl,
|
||||
}))}
|
||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||
<Group>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { Select } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
|
||||
const tSearch = useScopedI18n("management.page.settings.section.search");
|
||||
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<Select
|
||||
label={tSearch("defaultSearchEngine.label")}
|
||||
description={tSearch("defaultSearchEngine.description")}
|
||||
data={selectableSearchEngines}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||
import { SearchSettingsForm } from "./_components/search-settings-form";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management");
|
||||
@@ -41,6 +42,10 @@ export default async function SettingsPage() {
|
||||
<Title order={2}>{tSettings("section.board.title")}</Title>
|
||||
<BoardSettingsForm defaultValues={serverSettings.board} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.search.title")}</Title>
|
||||
<SearchSettingsForm defaultValues={serverSettings.search} />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
||||
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
|
||||
|
||||
@@ -30,7 +30,7 @@ export default async function ApiPage() {
|
||||
if (!session?.user || !session.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
const document = openApiDocument(extractBaseUrlFromHeaders(headers()));
|
||||
const document = openApiDocument(extractBaseUrlFromHeaders(await headers()));
|
||||
const apiKeys = await api.apiKeys.getAll();
|
||||
const t = await getScopedI18n("management.page.tool.api.tab");
|
||||
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
||||
import { IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IconCategoryPlus,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconRefresh,
|
||||
IconRotateClockwise,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
@@ -10,6 +17,8 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import type { DockerContainerState } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -125,6 +134,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
);
|
||||
},
|
||||
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||
return (
|
||||
<Group gap={"sm"}>
|
||||
{groupedAlert}
|
||||
@@ -134,7 +144,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
totalCount: table.getRowCount(),
|
||||
})}
|
||||
</Text>
|
||||
<ContainerActionBar selectedIds={table.getSelectedRowModel().rows.map((row) => row.original.id)} />
|
||||
<ContainerActionBar selectedContainers={dockerContainers} />
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -151,16 +161,29 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
}
|
||||
|
||||
interface ContainerActionBarProps {
|
||||
selectedIds: string[];
|
||||
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||
}
|
||||
|
||||
const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => {
|
||||
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const handleClick = () => {
|
||||
openModal({
|
||||
selectedContainers,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedIds = selectedContainers.map((container) => container.id);
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light" radius="md">
|
||||
{t("addToHomarr.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -174,9 +197,10 @@ interface ContainerActionBarButtonProps {
|
||||
|
||||
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
await mutateAsync(
|
||||
{ ids: props.selectedIds },
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Select, Stack } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
interface ChangeDefaultSearchEngineFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
searchEnginesData: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: ChangeDefaultSearchEngineFormProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changeDefaultSearchEngine.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
defaultSearchEngineId: variables.defaultSearchEngineId,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeDefaultSearchEngine.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("user.action.changeDefaultSearchEngine.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(validation.user.changeDefaultSearchEngine, {
|
||||
initialValues: {
|
||||
defaultSearchEngineId: user.defaultSearchEngineId ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate({
|
||||
userId: user.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select w="100%" data={searchEnginesData} {...form.getInputProps("defaultSearchEngineId")} />
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof validation.user.changeDefaultSearchEngine>;
|
||||
+17
-4
@@ -18,13 +18,14 @@ interface ChangeHomeBoardFormProps {
|
||||
|
||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changeHomeBoardId.useMutation({
|
||||
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
homeBoardId: variables.homeBoardId,
|
||||
mobileHomeBoardId: variables.mobileHomeBoardId,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeHomeBoard.notification.success.message"),
|
||||
@@ -36,9 +37,10 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(validation.user.changeHomeBoard, {
|
||||
const form = useZodForm(validation.user.changeHomeBoards, {
|
||||
initialValues: {
|
||||
homeBoardId: user.homeBoardId ?? "",
|
||||
mobileHomeBoardId: user.mobileHomeBoardId ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,7 +54,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select w="100%" data={boardsData} {...form.getInputProps("homeBoardId")} />
|
||||
<Select
|
||||
label={t("management.page.user.setting.general.item.board.type.general")}
|
||||
w="100%"
|
||||
data={boardsData}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<Select
|
||||
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
||||
w="100%"
|
||||
data={boardsData}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
@@ -64,4 +77,4 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof validation.user.changeHomeBoard>;
|
||||
type FormType = z.infer<typeof validation.user.changeHomeBoards>;
|
||||
@@ -11,6 +11,7 @@ import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { ChangeDefaultSearchEngineForm } from "./_components/_change-default-search-engine";
|
||||
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
||||
@@ -19,12 +20,13 @@ import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||
import { UserProfileForm } from "./_components/_profile-form";
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
params: Promise<{
|
||||
userId: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props) {
|
||||
export async function generateMetadata(props: Props) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
const user = await api.user
|
||||
.getById({
|
||||
@@ -43,7 +45,8 @@ export async function generateMetadata({ params }: Props) {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EditUserPage({ params }: Props) {
|
||||
export default async function EditUserPage(props: Props) {
|
||||
const params = await props.params;
|
||||
const t = await getI18n();
|
||||
const tGeneral = await getScopedI18n("management.page.user.setting.general");
|
||||
const session = await auth();
|
||||
@@ -58,6 +61,7 @@ export default async function EditUserPage({ params }: Props) {
|
||||
}
|
||||
|
||||
const boards = await api.board.getAllBoards();
|
||||
const searchEngines = await api.searchEngine.getSelectable();
|
||||
|
||||
const isCredentialsUser = user.provider === "credentials";
|
||||
|
||||
@@ -85,7 +89,7 @@ export default async function EditUserPage({ params }: Props) {
|
||||
</Stack>
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.board")}</Title>
|
||||
<Title order={2}>{tGeneral("item.board.title")}</Title>
|
||||
<ChangeHomeBoardForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
@@ -95,6 +99,11 @@ export default async function EditUserPage({ params }: Props) {
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.defaultSearchEngine")}</Title>
|
||||
<ChangeDefaultSearchEngineForm user={user} searchEnginesData={searchEngines} />
|
||||
</Stack>
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||
<FirstDayOfWeek user={user} />
|
||||
|
||||
@@ -15,10 +15,14 @@ import { NavigationLink } from "../groups/[id]/_navigation";
|
||||
import { canAccessUserEditPage } from "./access";
|
||||
|
||||
interface LayoutProps {
|
||||
params: { userId: string };
|
||||
params: Promise<{ userId: string }>;
|
||||
}
|
||||
|
||||
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
|
||||
export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const session = await auth();
|
||||
const t = await getI18n();
|
||||
const tUser = await getScopedI18n("management.page.user");
|
||||
|
||||
@@ -10,12 +10,13 @@ import { canAccessUserEditPage } from "../access";
|
||||
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
params: Promise<{
|
||||
userId: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function UserSecurityPage({ params }: Props) {
|
||||
export default async function UserSecurityPage(props: Props) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
const tSecurity = await getScopedI18n("management.page.user.setting.security");
|
||||
const user = await api.user
|
||||
|
||||
@@ -10,10 +10,14 @@ import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { NavigationLink } from "./_navigation";
|
||||
|
||||
interface LayoutProps {
|
||||
params: { id: string };
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
|
||||
export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const t = await getI18n();
|
||||
const tGroup = await getScopedI18n("management.page.group");
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { env } from "@homarr/auth/env.mjs";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
@@ -17,15 +17,17 @@ import { AddGroupMember } from "./_add-group-member";
|
||||
import { RemoveGroupMember } from "./_remove-group-member";
|
||||
|
||||
interface GroupsDetailPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
};
|
||||
searchParams: {
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
search: string | undefined;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function GroupsDetailPage({ params, searchParams }: GroupsDetailPageProps) {
|
||||
export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
|
||||
@@ -14,12 +14,13 @@ import { ReservedGroupAlert } from "./_reserved-group-alert";
|
||||
import { TransferGroupOwnership } from "./_transfer-group-ownership";
|
||||
|
||||
interface GroupsDetailPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function GroupsDetailPage({ params }: GroupsDetailPageProps) {
|
||||
export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
|
||||
@@ -12,12 +12,13 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
|
||||
|
||||
interface GroupPermissionsPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function GroupPermissionsPage({ params }: GroupPermissionsPageProps) {
|
||||
export default async function GroupPermissionsPage(props: GroupPermissionsPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead,
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
@@ -19,12 +20,8 @@ const searchParamsSchema = z.object({
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
||||
}>;
|
||||
|
||||
interface GroupsListPageProps {
|
||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
@@ -35,7 +32,7 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,15 +5,15 @@ import { db } from "@homarr/db";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
import { env } from "~/env.mjs";
|
||||
import { env } from "~/env";
|
||||
import { WidgetPreviewPageContent } from "./_content";
|
||||
|
||||
interface Props {
|
||||
params: { kind: string };
|
||||
params: Promise<{ kind: string }>;
|
||||
}
|
||||
|
||||
export default async function WidgetPreview(props: Props) {
|
||||
if (!(props.params.kind in widgetImports || env.NODE_ENV !== "development")) {
|
||||
if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function WidgetPreview(props: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
const sort = props.params.kind as WidgetKind;
|
||||
const sort = (await props.params).kind as WidgetKind;
|
||||
|
||||
return (
|
||||
<Center h="100vh">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { headers } from "next/headers";
|
||||
import { userAgent } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { userAgent } from "next/server";
|
||||
import { createOpenApiFetchHandler } from "trpc-to-openapi";
|
||||
|
||||
import { appRouter, createTRPCContext } from "@homarr/api";
|
||||
import { hashPasswordAsync } from "@homarr/auth";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { hashPasswordAsync } from "@homarr/auth";
|
||||
import { createSessionAsync } from "@homarr/auth/server";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { apiKeys } from "@homarr/db/schema";
|
||||
@@ -13,7 +12,7 @@ import { logger } from "@homarr/log";
|
||||
|
||||
const handlerAsync = async (req: NextRequest) => {
|
||||
const apiKeyHeaderValue = req.headers.get("ApiKey");
|
||||
const ipAddress = req.ip ?? headers().get("x-forwarded-for");
|
||||
const ipAddress = req.headers.get("x-forwarded-for");
|
||||
const { ua } = userAgent(req);
|
||||
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua);
|
||||
|
||||
@@ -88,9 +87,9 @@ const getSessionOrDefaultFromHeadersAsync = async (
|
||||
};
|
||||
|
||||
export {
|
||||
handlerAsync as DELETE,
|
||||
handlerAsync as GET,
|
||||
handlerAsync as PATCH,
|
||||
handlerAsync as POST,
|
||||
handlerAsync as PUT,
|
||||
handlerAsync as DELETE,
|
||||
handlerAsync as PATCH,
|
||||
};
|
||||
@@ -1,16 +1,17 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { createHandlers } from "@homarr/auth";
|
||||
import { createHandlersAsync } from "@homarr/auth";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
export const GET = async (req: NextRequest) => {
|
||||
return await createHandlers(extractProvider(req), isSecureCookieEnabled(req)).handlers.GET(reqWithTrustedOrigin(req));
|
||||
const { handlers } = await createHandlersAsync(extractProvider(req), isSecureCookieEnabled(req));
|
||||
|
||||
return await handlers.GET(reqWithTrustedOrigin(req));
|
||||
};
|
||||
export const POST = async (req: NextRequest) => {
|
||||
return await createHandlers(extractProvider(req), isSecureCookieEnabled(req)).handlers.POST(
|
||||
reqWithTrustedOrigin(req),
|
||||
);
|
||||
const { handlers } = await createHandlersAsync(extractProvider(req), isSecureCookieEnabled(req));
|
||||
return await handlers.POST(reqWithTrustedOrigin(req));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { NextRequest } from "next/server";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { medias } from "@homarr/db/schema";
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function GET(_req: NextRequest, props: { params: Promise<{ id: string }> }) {
|
||||
const params = await props.params;
|
||||
const image = await db.query.medias.findFirst({
|
||||
where: eq(medias.id, params.id),
|
||||
columns: {
|
||||
|
||||
@@ -36,6 +36,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: item.kind === "iframe" ? "hidden" : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
@@ -87,7 +88,14 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
setOptions={updateOptions}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...partialNewOptions.newOptions,
|
||||
...options,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,21 +1,67 @@
|
||||
import { Button, Card, Center, Grid, Stack, Text } from "@mantine/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import type { WidgetDefinition } from "@homarr/widgets";
|
||||
|
||||
import { useItemActions } from "./item-actions";
|
||||
|
||||
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
const { createItem } = useItemActions();
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
objectEntries(widgetImports)
|
||||
.map(([kind, value]) => ({
|
||||
kind,
|
||||
icon: value.definition.icon,
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
}))
|
||||
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
|
||||
[t],
|
||||
);
|
||||
|
||||
const filteredItems = useMemo(
|
||||
() => items.filter((item) => item.name.toLowerCase().includes(search.toLowerCase())),
|
||||
[items, search],
|
||||
);
|
||||
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
createItem({ kind });
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
{objectEntries(widgetImports).map(([key, value]) => {
|
||||
return <WidgetItem key={key} kind={key} definition={value.definition} closeModal={actions.closeModal} />;
|
||||
})}
|
||||
</Grid>
|
||||
<Stack>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch />}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
// Add item if there is only one item in the list and user presses Enter
|
||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
handleAdd(filteredItems[0]!.kind);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} />
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
@@ -23,20 +69,18 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
});
|
||||
|
||||
const WidgetItem = ({
|
||||
kind,
|
||||
definition,
|
||||
closeModal,
|
||||
item,
|
||||
onSelect,
|
||||
}: {
|
||||
kind: WidgetKind;
|
||||
definition: WidgetDefinition;
|
||||
closeModal: () => void;
|
||||
item: {
|
||||
kind: WidgetKind;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const { createItem } = useItemActions();
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
createItem({ kind });
|
||||
closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
@@ -44,25 +88,16 @@ const WidgetItem = ({
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack gap="xs">
|
||||
<Center>
|
||||
<definition.icon />
|
||||
<item.icon />
|
||||
</Center>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||
{t(`widget.${kind}.name`)}
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||
{t(`widget.${kind}.description`)}
|
||||
{item.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleAdd(kind);
|
||||
}}
|
||||
variant="light"
|
||||
size="xs"
|
||||
mt="auto"
|
||||
radius="md"
|
||||
fullWidth
|
||||
>
|
||||
<Button onClick={onSelect} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Board, CategorySection, DynamicSection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface RemoveCategoryInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeCategoryCallback =
|
||||
(input: RemoveCategoryInput) =>
|
||||
(previous: Board): Board => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
|
||||
);
|
||||
if (!currentCategory) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const emptySectionsAbove = previous.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && section.yOffset < currentCategory.yOffset,
|
||||
);
|
||||
const aboveSection = emptySectionsAbove.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset).at(0);
|
||||
|
||||
const emptySectionsBelow = previous.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && section.yOffset > currentCategory.yOffset,
|
||||
);
|
||||
const removedSection = emptySectionsBelow.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset).at(0);
|
||||
|
||||
if (!aboveSection || !removedSection) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
|
||||
const aboveYOffset = Math.max(
|
||||
calculateYHeightWithOffsetForItems(aboveSection),
|
||||
calculateYHeightWithOffsetForDynamicSections(previous.sections, aboveSection.id),
|
||||
);
|
||||
const categoryYOffset = Math.max(
|
||||
calculateYHeightWithOffsetForItems(currentCategory),
|
||||
calculateYHeightWithOffsetForDynamicSections(previous.sections, currentCategory.id),
|
||||
);
|
||||
|
||||
const previousCategoryItems = currentCategory.items.map((item) => ({
|
||||
...item,
|
||||
yOffset: item.yOffset + aboveYOffset,
|
||||
}));
|
||||
const previousBelowWrapperItems = removedSection.items.map((item) => ({
|
||||
...item,
|
||||
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
|
||||
}));
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: [
|
||||
...previous.sections.filter((section) => section.yOffset < aboveSection.yOffset && section.kind !== "dynamic"),
|
||||
{
|
||||
...aboveSection,
|
||||
items: [...aboveSection.items, ...previousCategoryItems, ...previousBelowWrapperItems],
|
||||
},
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
section.yOffset > removedSection.yOffset && section.kind !== "dynamic",
|
||||
)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
position: section.yOffset - 2,
|
||||
})),
|
||||
...previous.sections
|
||||
.filter((section): section is DynamicSection => section.kind === "dynamic")
|
||||
.map((dynamicSection) => {
|
||||
// Move dynamic sections from removed section to above section with required yOffset
|
||||
if (dynamicSection.parentSectionId === removedSection.id) {
|
||||
return {
|
||||
...dynamicSection,
|
||||
yOffset: dynamicSection.yOffset + aboveYOffset + categoryYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
// Move dynamic sections from category to above section with required yOffset
|
||||
if (dynamicSection.parentSectionId === currentCategory.id) {
|
||||
return {
|
||||
...dynamicSection,
|
||||
yOffset: dynamicSection.yOffset + aboveYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
return dynamicSection;
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffsetForDynamicSections = (sections: Section[], sectionId: string) => {
|
||||
return sections.reduce((acc, section) => {
|
||||
if (section.kind !== "dynamic" || section.parentSectionId !== sectionId) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const yHeightWithOffset = section.yOffset + section.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffsetForItems = (section: Section) =>
|
||||
section.items.reduce((acc, item) => {
|
||||
const yHeightWithOffset = item.yOffset + item.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { removeCategoryCallback } from "../remove-category";
|
||||
|
||||
describe("Remove Category", () => {
|
||||
test.each([
|
||||
[3, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 5, 6], [3, 4], 2],
|
||||
[5, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 4], [5, 6], 4],
|
||||
[1, [0, 1, 2, 3, 4, 5, 6], [0, 3, 4, 5, 6], [1, 2], 0],
|
||||
[3, [0, 3, 6, 7, 8], [0, 7, 8], [3, 6], 0],
|
||||
])(
|
||||
"should remove category",
|
||||
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
|
||||
const sections = createSections(initialYOffsets);
|
||||
|
||||
const input = removeId.toString();
|
||||
|
||||
const result = removeCategoryCallback({ id: input })({ sections } as never);
|
||||
|
||||
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual(expectedYOffsets);
|
||||
expectedRemovals.forEach((expectedRemoval) => {
|
||||
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
|
||||
});
|
||||
const aboveSection = result.sections.find((section) => section.id === expectedLocationOfItems.toString());
|
||||
expect(aboveSection?.items.map((item) => parseInt(item.id, 10))).toEqual(
|
||||
expect.arrayContaining(expectedRemovals),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("should correctly move items to above empty section", () => {
|
||||
const initialYOffsets = [0, 1, 2, 3, 4, 5, 6];
|
||||
const sections: Section[] = createSections(initialYOffsets);
|
||||
const aboveSection = sections.find((section) => section.yOffset === 2)!;
|
||||
aboveSection.items = [
|
||||
createItem({ id: "above-1" }),
|
||||
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
||||
];
|
||||
const removedCategory = sections.find((section) => section.yOffset === 3)!;
|
||||
removedCategory.items = [
|
||||
createItem({ id: "category-1" }),
|
||||
createItem({ id: "category-2", yOffset: 2, xOffset: 4, width: 4 }),
|
||||
];
|
||||
const removedEmptySection = sections.find((section) => section.yOffset === 4)!;
|
||||
removedEmptySection.items = [
|
||||
createItem({ id: "below-1", xOffset: 5 }),
|
||||
createItem({ id: "below-2", yOffset: 1, xOffset: 1, height: 2 }),
|
||||
];
|
||||
sections.push(
|
||||
createDynamicSection({
|
||||
id: "7",
|
||||
parentSectionId: "3",
|
||||
yOffset: 7,
|
||||
height: 3,
|
||||
items: [createItem({ id: "dynamic-1" })],
|
||||
}),
|
||||
);
|
||||
|
||||
const input = "3";
|
||||
|
||||
const result = removeCategoryCallback({ id: input })({ sections } as never);
|
||||
|
||||
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 5, 6, 7]);
|
||||
const aboveSectionResult = result.sections.find((section) => section.id === "2")!;
|
||||
expect(aboveSectionResult.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
createItem({ id: "above-1" }),
|
||||
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
||||
createItem({ id: "category-1", yOffset: 5 }),
|
||||
createItem({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
|
||||
createItem({ id: "below-1", yOffset: 15, xOffset: 5 }),
|
||||
createItem({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
|
||||
]),
|
||||
);
|
||||
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
|
||||
expect(dynamicSection.yOffset).toBe(12);
|
||||
expect(dynamicSection.parentSectionId).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
const createItem = (item: Partial<{ id: string; width: number; height: number; yOffset: number; xOffset: number }>) => {
|
||||
return {
|
||||
id: item.id ?? "0",
|
||||
kind: "app",
|
||||
options: {},
|
||||
advancedOptions: {
|
||||
customCssClasses: [],
|
||||
},
|
||||
height: item.height ?? 1,
|
||||
width: item.width ?? 1,
|
||||
yOffset: item.yOffset ?? 0,
|
||||
xOffset: item.xOffset ?? 0,
|
||||
integrationIds: [],
|
||||
} satisfies Item;
|
||||
};
|
||||
|
||||
const createDynamicSection = (
|
||||
section: Partial<
|
||||
Pick<DynamicSection, "id" | "height" | "width" | "yOffset" | "xOffset" | "parentSectionId" | "items">
|
||||
>,
|
||||
) => {
|
||||
return {
|
||||
id: section.id ?? "0",
|
||||
kind: "dynamic",
|
||||
height: section.height ?? 1,
|
||||
width: section.width ?? 1,
|
||||
yOffset: section.yOffset ?? 0,
|
||||
xOffset: section.xOffset ?? 0,
|
||||
parentSectionId: section.parentSectionId ?? "0",
|
||||
items: section.items ?? [],
|
||||
} satisfies DynamicSection;
|
||||
};
|
||||
|
||||
const createSections = (initialYOffsets: number[]) => {
|
||||
return initialYOffsets.map((yOffset, index) => ({
|
||||
id: yOffset.toString(),
|
||||
kind: index % 2 === 0 ? "empty" : "category",
|
||||
name: "Category",
|
||||
yOffset,
|
||||
xOffset: 0,
|
||||
items: [createItem({ id: yOffset.toString() })],
|
||||
})) satisfies Section[];
|
||||
};
|
||||
@@ -2,10 +2,12 @@ import { useCallback } from "react";
|
||||
|
||||
import { createId } from "@homarr/db/client";
|
||||
|
||||
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
import type { MoveCategoryInput } from "./actions/move-category";
|
||||
import { moveCategoryCallback } from "./actions/move-category";
|
||||
import type { RemoveCategoryInput } from "./actions/remove-category";
|
||||
import { removeCategoryCallback } from "./actions/remove-category";
|
||||
|
||||
interface AddCategory {
|
||||
name: string;
|
||||
@@ -17,10 +19,6 @@ interface RenameCategory {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RemoveCategory {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const useCategoryActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
@@ -132,57 +130,8 @@ export const useCategoryActions = () => {
|
||||
);
|
||||
|
||||
const removeCategory = useCallback(
|
||||
({ id: categoryId }: RemoveCategory) => {
|
||||
updateBoard((previous) => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === categoryId,
|
||||
);
|
||||
if (!currentCategory) return previous;
|
||||
|
||||
const aboveWrapper = previous.sections.find(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && section.yOffset === currentCategory.yOffset - 1,
|
||||
);
|
||||
|
||||
const removedWrapper = previous.sections.find(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && section.yOffset === currentCategory.yOffset + 1,
|
||||
);
|
||||
|
||||
if (!aboveWrapper || !removedWrapper) return previous;
|
||||
|
||||
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
|
||||
const aboveYOffset = calculateYHeightWithOffset(aboveWrapper);
|
||||
const categoryYOffset = calculateYHeightWithOffset(currentCategory);
|
||||
|
||||
const previousCategoryItems = currentCategory.items.map((item) => ({
|
||||
...item,
|
||||
yOffset: item.yOffset + aboveYOffset,
|
||||
}));
|
||||
const previousBelowWrapperItems = removedWrapper.items.map((item) => ({
|
||||
...item,
|
||||
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
|
||||
}));
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: [
|
||||
...previous.sections.filter((section) => section.yOffset < currentCategory.yOffset - 1),
|
||||
{
|
||||
...aboveWrapper,
|
||||
items: [...aboveWrapper.items, ...previousCategoryItems, ...previousBelowWrapperItems],
|
||||
},
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection => section.yOffset >= currentCategory.yOffset + 2,
|
||||
)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
position: section.yOffset - 2,
|
||||
})),
|
||||
],
|
||||
};
|
||||
});
|
||||
(input: RemoveCategoryInput) => {
|
||||
updateBoard(removeCategoryCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
@@ -195,10 +144,3 @@ export const useCategoryActions = () => {
|
||||
removeCategory,
|
||||
};
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffset = (section: Section) =>
|
||||
section.items.reduce((acc, item) => {
|
||||
const yHeightWithOffset = item.yOffset + item.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { startTransition, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Combobox,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Indicator,
|
||||
InputBase,
|
||||
@@ -16,10 +18,13 @@ import {
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UploadMedia } from "~/app/[locale]/manage/medias/_actions/upload-media";
|
||||
import classes from "./icon-picker.module.css";
|
||||
|
||||
interface IconPickerProps {
|
||||
@@ -34,6 +39,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
const [value, setValue] = useState<string>(initialValue ?? "");
|
||||
const [search, setSearch] = useState(initialValue ?? "");
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
|
||||
const { data: session } = useSession();
|
||||
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
@@ -105,40 +111,61 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
return (
|
||||
<Combobox store={combobox} withinPortal>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
rightSection={<Combobox.Chevron />}
|
||||
leftSection={
|
||||
previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
|
||||
) : null
|
||||
}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
setValue(event.currentTarget.value);
|
||||
setPreviewUrl(null);
|
||||
onChange(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
combobox.openDropdown();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setPreviewUrl(value);
|
||||
setSearch(value || "");
|
||||
}}
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk
|
||||
error={error}
|
||||
label={tCommon("iconPicker.label")}
|
||||
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
|
||||
/>
|
||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||
<InputBase
|
||||
flex={1}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
leftSection={
|
||||
previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
|
||||
) : null
|
||||
}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
setValue(event.currentTarget.value);
|
||||
setPreviewUrl(null);
|
||||
onChange(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
combobox.openDropdown();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setPreviewUrl(value);
|
||||
setSearch(value || "");
|
||||
}}
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk
|
||||
error={error}
|
||||
label={tCommon("iconPicker.label")}
|
||||
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={({ url }) => {
|
||||
startTransition(() => {
|
||||
setValue(url);
|
||||
setPreviewUrl(url);
|
||||
setSearch(url);
|
||||
onChange(url);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{({ onClick, loading }) => (
|
||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||
<IconUpload size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</UploadMedia>
|
||||
)}
|
||||
</Group>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuPro
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false}>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
@@ -113,7 +113,7 @@ export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuPro
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item onClick={() => router.push("/auth/login")} leftSection={<IconLogin size="1rem" />}>
|
||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||
{t("login")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { shouldSkipEnvValidation } from "@homarr/common/env-validation";
|
||||
|
||||
export const env = createEnv({
|
||||
shared: {
|
||||
PORT: z.coerce.number().default(3000),
|
||||
@@ -13,7 +15,7 @@ export const env = createEnv({
|
||||
server: {
|
||||
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...)
|
||||
DOCKER_HOSTNAMES: z.string().optional(),
|
||||
DOCKER_PORTS: z.number().optional(),
|
||||
DOCKER_PORTS: z.string().optional(),
|
||||
},
|
||||
/**
|
||||
* Specify your client-side environment variables schema here.
|
||||
@@ -32,6 +34,5 @@ export const env = createEnv({
|
||||
DOCKER_PORTS: process.env.DOCKER_PORTS,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
skipValidation:
|
||||
Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION) || process.env.npm_lifecycle_event === "lint",
|
||||
skipValidation: shouldSkipEnvValidation(),
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { createTRPCClient, httpLink } from "@trpc/client";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { createHeadersCallbackForSource } from "@homarr/api/client";
|
||||
import { createHeadersCallbackForSource } from "@homarr/api/shared";
|
||||
import { createI18nMiddleware } from "@homarr/translation/middleware";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||
|
||||
export const getCurrentColorSchemeAsync = cache(async () => {
|
||||
const cookieValue = cookies().get(colorSchemeCookieKey)?.value;
|
||||
const cookieValue = (await cookies()).get(colorSchemeCookieKey)?.value;
|
||||
|
||||
if (cookieValue) {
|
||||
return cookieValue as ColorScheme;
|
||||
|
||||
@@ -38,17 +38,17 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"superjson": "2.2.2",
|
||||
"undici": "7.2.0"
|
||||
"undici": "7.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/node": "^22.10.6",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint": "^9.18.0",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,8 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint": "^9.18.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { chromium } from "playwright";
|
||||
import { GenericContainer } from "testcontainers";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { OnboardingActions } from "./shared/actions/onboarding-actions";
|
||||
import { createHomarrContainer, withLogs } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
|
||||
const defaultCredentials = {
|
||||
username: "admin",
|
||||
password: "password",
|
||||
email: "admin@homarr.dev",
|
||||
group: "lldap_admin",
|
||||
};
|
||||
|
||||
const ldapBase = "dc=example,dc=com";
|
||||
|
||||
describe("LLDAP authorization", () => {
|
||||
test("Authorize with LLDAP successfully", async () => {
|
||||
// Arrange
|
||||
const lldapContainer = await createLldapContainer().start();
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "ldap",
|
||||
AUTH_LDAP_URI: `ldap://host.docker.internal:${lldapContainer.getMappedPort(3890)}`,
|
||||
AUTH_LDAP_BASE: ldapBase,
|
||||
AUTH_LDAP_BIND_DN: `uid=${defaultCredentials.username},ou=People,${ldapBase}`,
|
||||
AUTH_LDAP_BIND_PASSWORD: defaultCredentials.password,
|
||||
},
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const onboardingActions = new OnboardingActions(page, db);
|
||||
await onboardingActions.skipOnboardingAsync({
|
||||
group: defaultCredentials.group,
|
||||
});
|
||||
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
|
||||
await page.getByLabel("Username").fill(defaultCredentials.username);
|
||||
await page.getByLabel("Password").fill(defaultCredentials.password);
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
|
||||
// Assert
|
||||
await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
const users = await db.query.users.findMany({
|
||||
with: {
|
||||
groups: {
|
||||
with: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(users).toHaveLength(1);
|
||||
const user = users[0]!;
|
||||
expect(user).toEqual(
|
||||
expect.objectContaining({
|
||||
name: defaultCredentials.username,
|
||||
email: defaultCredentials.email,
|
||||
provider: "ldap",
|
||||
}),
|
||||
);
|
||||
|
||||
const groups = user.groups.map((g) => g.group.name);
|
||||
expect(groups).toContain(defaultCredentials.group);
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
await lldapContainer.stop();
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
const createLldapContainer = () => {
|
||||
return withLogs(
|
||||
new GenericContainer("lldap/lldap:stable").withExposedPorts(3890).withEnvironment({
|
||||
LLDAP_JWT_SECRET: "REPLACE_WITH_RANDOM",
|
||||
LLDAP_KEY_SEED: "REPLACE_WITH_RANDOM",
|
||||
LLDAP_LDAP_BASE_DN: ldapBase,
|
||||
LLDAP_LDAP_USER_PASS: defaultCredentials.password,
|
||||
LLDAP_LDAP_USER_EMAIL: defaultCredentials.email,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { chromium } from "playwright";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { OnboardingActions } from "./shared/actions/onboarding-actions";
|
||||
import { OnboardingAssertions } from "./shared/assertions/onboarding-assertions";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
|
||||
describe("Onboarding", () => {
|
||||
test("Credentials onboarding should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const actions = new OnboardingActions(page, db);
|
||||
const assertions = new OnboardingAssertions(page, db);
|
||||
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processUserStepAsync({
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
confirmPassword: "Comp(exP4sswOrd",
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
}, 60_000);
|
||||
|
||||
test("External provider onboarding setup should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "ldap",
|
||||
AUTH_LDAP_URI: "ldap://host.docker.internal:3890",
|
||||
AUTH_LDAP_BASE: "",
|
||||
AUTH_LDAP_BIND_DN: "",
|
||||
AUTH_LDAP_BIND_PASSWORD: "",
|
||||
},
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
const externalGroupName = "oidc-admins";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const actions = new OnboardingActions(page, db);
|
||||
const assertions = new OnboardingAssertions(page, db);
|
||||
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processExternalGroupStepAsync({
|
||||
name: externalGroupName,
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type { Page } from "playwright";
|
||||
|
||||
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
||||
import type { SqliteDatabase } from "../e2e-db";
|
||||
|
||||
export class OnboardingActions {
|
||||
private readonly page: Page;
|
||||
private readonly db: SqliteDatabase;
|
||||
|
||||
constructor(page: Page, db: SqliteDatabase) {
|
||||
this.page = page;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public async skipOnboardingAsync(input?: { group?: string }) {
|
||||
await this.db.update(sqliteSchema.onboarding).set({
|
||||
step: "finish",
|
||||
});
|
||||
|
||||
if (input?.group) {
|
||||
await this.db.insert(sqliteSchema.groups).values({
|
||||
id: createId(),
|
||||
name: input.group,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async startOnboardingAsync(type: "scratch" | "before 1.0") {
|
||||
await this.page.locator("button", { hasText: type }).click();
|
||||
}
|
||||
|
||||
public async processUserStepAsync(input: { username: string; password: string; confirmPassword: string }) {
|
||||
await this.page.waitForSelector("text=administrator user");
|
||||
|
||||
await this.page.getByLabel("Username").fill(input.username);
|
||||
await this.page.getByLabel("Password", { exact: true }).fill(input.password);
|
||||
await this.page.getByLabel("Confirm password").fill(input.confirmPassword);
|
||||
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
|
||||
public async processExternalGroupStepAsync(input: { name: string }) {
|
||||
await this.page.waitForSelector("text=external provider");
|
||||
await this.page.locator("input").fill(input.name);
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
|
||||
public async processSettingsStepAsync() {
|
||||
await this.page.waitForSelector("text=Analytics");
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Page } from "playwright";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
||||
import { OnboardingStep } from "../../../packages/definitions/src";
|
||||
import { credentialsAdminGroup } from "../../../packages/definitions/src/group";
|
||||
import type { SqliteDatabase } from "../e2e-db";
|
||||
|
||||
export class OnboardingAssertions {
|
||||
private readonly page: Page;
|
||||
private readonly db: SqliteDatabase;
|
||||
|
||||
constructor(page: Page, db: SqliteDatabase) {
|
||||
this.page = page;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public async assertDbOnboardingStepAsync(expectedStep: OnboardingStep) {
|
||||
const onboarding = await this.db.query.onboarding.findFirst();
|
||||
expect(onboarding?.step).toEqual(expectedStep);
|
||||
}
|
||||
|
||||
public async assertUserAndAdminGroupInsertedAsync(expectedUsername: string) {
|
||||
const users = await this.db.query.users.findMany({
|
||||
with: {
|
||||
groups: {
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
permissions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = users.find((u) => u.name === expectedUsername);
|
||||
expect(user).toBeDefined();
|
||||
|
||||
const adminGroup = user!.groups.find((g) => g.group.name === credentialsAdminGroup);
|
||||
expect(adminGroup).toBeDefined();
|
||||
expect(adminGroup!.group.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
|
||||
}
|
||||
|
||||
public async assertExternalGroupInsertedAsync(expectedGroupName: string) {
|
||||
const group = await this.db.query.groups.findFirst({
|
||||
where: eq(sqliteSchema.groups.name, expectedGroupName),
|
||||
with: {
|
||||
permissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group!.permissions).toEqual([expect.objectContaining({ permission: "admin" })]);
|
||||
}
|
||||
|
||||
public async assertFinishStepVisibleAsync() {
|
||||
await this.page.waitForSelector("text=completed the setup", { timeout: 5000 });
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,43 @@
|
||||
import { GenericContainer, Wait } from "testcontainers";
|
||||
import { Environment } from "testcontainers/build/types";
|
||||
|
||||
export const createHomarrContainer = () => {
|
||||
export const createHomarrContainer = (
|
||||
options: {
|
||||
environment?: Environment;
|
||||
mounts?: {
|
||||
"/appdata"?: string;
|
||||
"/var/run/docker.sock"?: string;
|
||||
};
|
||||
} = {},
|
||||
) => {
|
||||
if (!process.env.CI) {
|
||||
throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
|
||||
}
|
||||
|
||||
return withLogs(
|
||||
new GenericContainer("homarr-e2e")
|
||||
.withExposedPorts(7575)
|
||||
.withEnvironment({
|
||||
SECRET_ENCRYPTION_KEY: "0".repeat(64),
|
||||
})
|
||||
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575)),
|
||||
);
|
||||
const container = new GenericContainer("homarr-e2e")
|
||||
.withExposedPorts(7575)
|
||||
.withEnvironment({
|
||||
...options.environment,
|
||||
SECRET_ENCRYPTION_KEY: "0".repeat(64),
|
||||
})
|
||||
.withBindMounts(
|
||||
Object.entries(options.mounts ?? {})
|
||||
.filter((item) => item?.[0] !== undefined)
|
||||
.map(([container, local]) => ({
|
||||
source: local,
|
||||
target: container,
|
||||
})),
|
||||
)
|
||||
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575))
|
||||
.withExtraHosts([
|
||||
{
|
||||
// This enabled the usage of host.docker.internal as hostname in the container
|
||||
host: "host.docker.internal",
|
||||
ipAddress: "host-gateway",
|
||||
},
|
||||
]);
|
||||
|
||||
return withLogs(container);
|
||||
};
|
||||
|
||||
export const withLogs = (container: GenericContainer) => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import Database from "better-sqlite3";
|
||||
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
import * as sqliteSchema from "../../packages/db/schema/sqlite";
|
||||
|
||||
export const createSqliteDbFileAsync = async () => {
|
||||
const localMountPath = path.join(__dirname, "tmp", createId());
|
||||
await mkdir(path.join(localMountPath, "db"), { recursive: true });
|
||||
|
||||
const localDbUrl = path.join(localMountPath, "db", "db.sqlite");
|
||||
|
||||
const connection = new Database(localDbUrl);
|
||||
const db = drizzle(connection, {
|
||||
schema: sqliteSchema,
|
||||
casing: "snake_case",
|
||||
});
|
||||
|
||||
await migrate(db, {
|
||||
migrationsFolder: path.join(__dirname, "..", "..", "packages", "db", "migrations", "sqlite"),
|
||||
});
|
||||
|
||||
return {
|
||||
db,
|
||||
localMountPath,
|
||||
};
|
||||
};
|
||||
|
||||
export type SqliteDatabase = BetterSQLite3Database<typeof sqliteSchema>;
|
||||
+12
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.0.0-beta.10",
|
||||
"version": "1.0.0-beta.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -22,9 +22,10 @@
|
||||
"lint:ws": "pnpm dlx sherif@latest",
|
||||
"package:new": "turbo gen init",
|
||||
"release": "semantic-release",
|
||||
"test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development vitest e2e",
|
||||
"test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled",
|
||||
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
},
|
||||
@@ -32,29 +33,29 @@
|
||||
"devDependencies": {
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^13.0.0",
|
||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^11.0.1",
|
||||
"@semantic-release/npm": "^12.0.1",
|
||||
"@semantic-release/release-notes-generator": "^14.0.2",
|
||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||
"@turbo/gen": "^2.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"@vitest/ui": "^2.1.8",
|
||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"jsdom": "^25.0.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"semantic-release": "^24.2.0",
|
||||
"semantic-release": "^24.2.1",
|
||||
"testcontainers": "^10.16.0",
|
||||
"turbo": "^2.3.3",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.2",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"allowNonAppliedPatches": true,
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.17.0",
|
||||
"typescript": "^5.7.2"
|
||||
"eslint": "^9.18.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./websocket": "./src/websocket.ts"
|
||||
"./websocket": "./src/websocket.ts",
|
||||
"./shared": "./src/shared.ts"
|
||||
},
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
@@ -40,20 +41,21 @@
|
||||
"@trpc/client": "next",
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
"dockerode": "^4.0.2",
|
||||
"dockerode": "^4.0.3",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"next": "^14.2.22",
|
||||
"react": "^19.0.0",
|
||||
"next": "15.1.4",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"superjson": "2.2.2",
|
||||
"trpc-to-openapi": "^2.1.1"
|
||||
"trpc-to-openapi": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/dockerode": "^3.3.32",
|
||||
"eslint": "^9.17.0",
|
||||
"@types/dockerode": "^3.3.34",
|
||||
"eslint": "^9.18.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { createTRPCClient, createTRPCReact, httpLink } from "@trpc/react-query";
|
||||
"use client";
|
||||
|
||||
import { createTRPCClient, httpLink } from "@trpc/client";
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { AppRouter } from ".";
|
||||
import { createHeadersCallbackForSource } from "./shared";
|
||||
|
||||
export const clientApi = createTRPCReact<AppRouter>();
|
||||
export const fetchApi = createTRPCClient<AppRouter>({
|
||||
@@ -26,42 +30,3 @@ function getBaseUrl() {
|
||||
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;
|
||||
}
|
||||
@@ -3,13 +3,36 @@ import { TRPCError } from "@trpc/server";
|
||||
import { asc, createId, eq, inArray, like } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
import { selectAppSchema } from "@homarr/db/validationSchemas";
|
||||
import { getIconForName } from "@homarr/icons";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { canUserSeeAppAsync } from "./app/app-access-control";
|
||||
|
||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure
|
||||
.input(validation.common.paginated)
|
||||
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
|
||||
.meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true } })
|
||||
.query(async ({ input, ctx }) => {
|
||||
const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
|
||||
const totalCount = await ctx.db.$count(apps, whereQuery);
|
||||
|
||||
const dbApps = await ctx.db.query.apps.findMany({
|
||||
limit: input.pageSize,
|
||||
offset: (input.page - 1) * input.pageSize,
|
||||
where: whereQuery,
|
||||
orderBy: asc(apps.name),
|
||||
});
|
||||
|
||||
return {
|
||||
items: dbApps,
|
||||
totalCount,
|
||||
};
|
||||
}),
|
||||
all: protectedProcedure
|
||||
.input(z.void())
|
||||
.output(z.array(selectAppSchema))
|
||||
@@ -98,6 +121,21 @@ export const appRouter = createTRPCRouter({
|
||||
href: input.href,
|
||||
});
|
||||
}),
|
||||
createMany: permissionRequiredProcedure
|
||||
.requiresPermission("app-create")
|
||||
.input(validation.app.createMany)
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.insert(apps).values(
|
||||
input.map((app) => ({
|
||||
id: createId(),
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
iconUrl: app.iconUrl ?? getIconForName(ctx.db, app.name).sync()?.url ?? defaultIcon,
|
||||
href: app.href,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
update: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
.input(convertIntersectionToZodObject(validation.app.edit))
|
||||
|
||||
@@ -2,7 +2,8 @@ import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
||||
import { and, createId, eq, inArray, like, or } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
@@ -11,7 +12,9 @@ import {
|
||||
boardUserPermissions,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
integrationGroupPermissions,
|
||||
integrationItems,
|
||||
integrationUserPermissions,
|
||||
items,
|
||||
sections,
|
||||
users,
|
||||
@@ -119,6 +122,7 @@ export const boardRouter = createTRPCRouter({
|
||||
return dbBoards.map((board) => ({
|
||||
...board,
|
||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
search: publicProcedure
|
||||
@@ -192,6 +196,7 @@ export const boardRouter = createTRPCRouter({
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
permissions: constructBoardPermissions(board, ctx.session),
|
||||
isHome: currentUserWhenPresent?.homeBoardId === board.id,
|
||||
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
|
||||
}));
|
||||
}),
|
||||
createBoard: permissionRequiredProcedure
|
||||
@@ -216,6 +221,111 @@ export const boardRouter = createTRPCRouter({
|
||||
});
|
||||
});
|
||||
}),
|
||||
duplicateBoard: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.input(validation.board.duplicate)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.name);
|
||||
|
||||
const board = await ctx.db.query.boards.findFirst({
|
||||
where: eq(boards.id, input.id),
|
||||
with: {
|
||||
sections: {
|
||||
with: {
|
||||
items: {
|
||||
with: {
|
||||
integrations: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!board) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Board not found",
|
||||
});
|
||||
}
|
||||
|
||||
const { sections: boardSections, ...boardProps } = board;
|
||||
|
||||
const newBoardId = createId();
|
||||
const sectionMap = new Map<string, string>(boardSections.map((section) => [section.id, createId()]));
|
||||
const sectionsToInsert: InferInsertModel<typeof sections>[] = boardSections.map(({ items: _, ...section }) => ({
|
||||
...section,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: sectionMap.get(section.id)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
parentSectionId: section.parentSectionId ? sectionMap.get(section.parentSectionId)! : null,
|
||||
boardId: newBoardId,
|
||||
}));
|
||||
const flatItems = boardSections.flatMap((section) => section.items);
|
||||
const itemMap = new Map<string, string>(flatItems.map((item) => [item.id, createId()]));
|
||||
const itemsToInsert: InferInsertModel<typeof items>[] = flatItems.map(({ integrations: _, ...item }) => ({
|
||||
...item,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: itemMap.get(item.id)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(item.sectionId)!,
|
||||
}));
|
||||
|
||||
// Creates a list with all integration ids the user has access to
|
||||
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
|
||||
const integrationIdsWithAccess = hasAccessForAll
|
||||
? []
|
||||
: await ctx.db
|
||||
.selectDistinct({
|
||||
id: integrationGroupPermissions.integrationId,
|
||||
})
|
||||
.from(integrationGroupPermissions)
|
||||
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
|
||||
.where(eq(groupMembers.userId, ctx.session.user.id))
|
||||
.union(
|
||||
ctx.db
|
||||
.selectDistinct({ id: integrationUserPermissions.integrationId })
|
||||
.from(integrationUserPermissions)
|
||||
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
|
||||
)
|
||||
.then((result) => result.map((row) => row.id));
|
||||
|
||||
const itemIntegrationsToInsert = flatItems.flatMap((item) =>
|
||||
item.integrations
|
||||
// Restrict integrations to only those the user has access to
|
||||
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
|
||||
.map((integration) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(item.id)!,
|
||||
integrationId: integration.integrationId,
|
||||
})),
|
||||
);
|
||||
|
||||
ctx.db.transaction((transaction) => {
|
||||
transaction
|
||||
.insert(boards)
|
||||
.values({
|
||||
...boardProps,
|
||||
id: newBoardId,
|
||||
name: input.name,
|
||||
creatorId: ctx.session.user.id,
|
||||
})
|
||||
.run();
|
||||
|
||||
if (sectionsToInsert.length > 0) {
|
||||
transaction.insert(sections).values(sectionsToInsert).run();
|
||||
}
|
||||
|
||||
if (itemsToInsert.length > 0) {
|
||||
transaction.insert(items).values(itemsToInsert).run();
|
||||
}
|
||||
|
||||
if (itemIntegrationsToInsert.length > 0) {
|
||||
transaction.insert(integrationItems).values(itemIntegrationsToInsert).run();
|
||||
}
|
||||
});
|
||||
}),
|
||||
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||
|
||||
@@ -229,7 +339,10 @@ export const boardRouter = createTRPCRouter({
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
|
||||
if (input.visibility !== "public" && boardSettings.homeBoardId === input.id) {
|
||||
if (
|
||||
input.visibility !== "public" &&
|
||||
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot make home board private",
|
||||
@@ -251,30 +364,30 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
setMobileHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||
|
||||
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const user = userId
|
||||
? await ctx.db.query.users.findFirst({
|
||||
? ((await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
})
|
||||
})) ?? null)
|
||||
: null;
|
||||
|
||||
// 1. user home board, 2. home board, 3. not found
|
||||
let boardWhere: SQL<unknown> | null = null;
|
||||
if (user?.homeBoardId) {
|
||||
boardWhere = eq(boards.id, user.homeBoardId);
|
||||
} else {
|
||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
boardWhere = boardSettings.homeBoardId ? eq(boards.id, boardSettings.homeBoardId) : null;
|
||||
}
|
||||
const homeBoardId = await getHomeIdBoardAsync(ctx.db, user, ctx.deviceType);
|
||||
|
||||
if (!boardWhere) {
|
||||
if (!homeBoardId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "No home board found",
|
||||
});
|
||||
}
|
||||
|
||||
const boardWhere = eq(boards.id, homeBoardId);
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
||||
|
||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||
@@ -585,6 +698,29 @@ export const boardRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the home board id of the user with the given device type
|
||||
* For an example of a user with deviceType = 'mobile' it would go through the following order:
|
||||
* 1. user.mobileHomeBoardId
|
||||
* 2. user.homeBoardId
|
||||
* 3. serverSettings.mobileHomeBoardId
|
||||
* 4. serverSettings.homeBoardId
|
||||
* 5. show NOT_FOUND error
|
||||
*/
|
||||
const getHomeIdBoardAsync = async (
|
||||
db: Database,
|
||||
user: InferSelectModel<typeof users> | null,
|
||||
deviceType: DeviceType,
|
||||
) => {
|
||||
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
|
||||
if (user?.[settingKey] || user?.homeBoardId) {
|
||||
return user[settingKey] ?? user.homeBoardId;
|
||||
} else {
|
||||
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||
return boardSettings[settingKey] ?? boardSettings.homeBoardId;
|
||||
}
|
||||
};
|
||||
|
||||
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => {
|
||||
const boards = await db.query.boards.findMany({
|
||||
columns: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { count, like } from "@homarr/db";
|
||||
import { and, count, like } from "@homarr/db";
|
||||
import { icons } from "@homarr/db/schema";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
@@ -15,7 +15,11 @@ export const iconsRouter = createTRPCRouter({
|
||||
name: true,
|
||||
url: true,
|
||||
},
|
||||
where: (input.searchText?.length ?? 0) > 0 ? like(icons.name, `%${input.searchText}%`) : undefined,
|
||||
where:
|
||||
(input.searchText?.length ?? 0) > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
|
||||
: undefined,
|
||||
limit: input.limitPerGroup,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { env } from "@homarr/auth/env.mjs";
|
||||
import { env } from "@homarr/auth/env";
|
||||
|
||||
export const throwIfCredentialsDisabled = () => {
|
||||
if (!env.AUTH_PROVIDERS.includes("credentials")) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { createId, eq, like, sql } from "@homarr/db";
|
||||
import { searchEngines } from "@homarr/db/schema";
|
||||
import { asc, createId, eq, like, sql } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { searchEngines, users } from "@homarr/db/schema";
|
||||
import { integrationCreator } from "@homarr/integrations";
|
||||
import { validation } from "@homarr/validation";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
export const searchEngineRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
|
||||
@@ -29,6 +30,21 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
totalCount: searchEngineCount[0]?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
getSelectable: protectedProcedure
|
||||
.input(z.object({ withIntegrations: z.boolean() }).default({ withIntegrations: true }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.searchEngines
|
||||
.findMany({
|
||||
orderBy: asc(searchEngines.name),
|
||||
where: input.withIntegrations ? undefined : eq(searchEngines.type, "generic"),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
})
|
||||
.then((engines) => engines.map((engine) => ({ value: engine.id, label: engine.name })));
|
||||
}),
|
||||
|
||||
byId: protectedProcedure.input(validation.common.byId).query(async ({ ctx, input }) => {
|
||||
const searchEngine = await ctx.db.query.searchEngines.findFirst({
|
||||
where: eq(searchEngines.id, input.id),
|
||||
@@ -55,6 +71,54 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
urlTemplate: searchEngine.urlTemplate!,
|
||||
};
|
||||
}),
|
||||
getDefaultSearchEngine: publicProcedure.query(async ({ ctx }) => {
|
||||
const userDefaultId = ctx.session?.user.id
|
||||
? ((await ctx.db.query.users
|
||||
.findFirst({
|
||||
where: eq(users.id, ctx.session.user.id),
|
||||
columns: {
|
||||
defaultSearchEngineId: true,
|
||||
},
|
||||
})
|
||||
.then((user) => user?.defaultSearchEngineId)) ?? null)
|
||||
: null;
|
||||
|
||||
if (userDefaultId) {
|
||||
return await ctx.db.query.searchEngines.findFirst({
|
||||
where: eq(searchEngines.id, userDefaultId),
|
||||
with: {
|
||||
integration: {
|
||||
columns: {
|
||||
kind: true,
|
||||
url: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const serverDefaultId = await getServerSettingByKeyAsync(ctx.db, "search").then(
|
||||
(setting) => setting.defaultSearchEngineId,
|
||||
);
|
||||
|
||||
if (serverDefaultId) {
|
||||
return await ctx.db.query.searchEngines.findFirst({
|
||||
where: eq(searchEngines.id, serverDefaultId),
|
||||
with: {
|
||||
integration: {
|
||||
columns: {
|
||||
kind: true,
|
||||
url: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.searchEngines.findMany({
|
||||
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
|
||||
|
||||
@@ -24,6 +24,7 @@ describe("all should return all apps", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
@@ -55,6 +56,7 @@ describe("all should return all apps", () => {
|
||||
// Arrange
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
@@ -72,6 +74,7 @@ describe("byId should return an app by id", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
vi.spyOn(appAccessControl, "canUserSeeAppAsync").mockReturnValue(Promise.resolve(true));
|
||||
@@ -103,6 +106,7 @@ describe("byId should return an app by id", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
await db.insert(apps).values([
|
||||
@@ -128,6 +132,7 @@ describe("byId should return an app by id", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
@@ -145,6 +150,7 @@ describe("create should create a new app with all arguments", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["app-create"]),
|
||||
});
|
||||
const input = {
|
||||
@@ -171,6 +177,7 @@ describe("create should create a new app with all arguments", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["app-create"]),
|
||||
});
|
||||
const input = {
|
||||
@@ -199,6 +206,7 @@ describe("update should update an app", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
});
|
||||
|
||||
@@ -237,6 +245,7 @@ describe("update should update an app", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
});
|
||||
|
||||
@@ -261,6 +270,7 @@ describe("delete should delete an app", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["app-full-all"]),
|
||||
});
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
||||
test("without session it should return only public boards", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
const user1 = await createRandomUserAsync(db);
|
||||
const user2 = await createRandomUserAsync(db);
|
||||
@@ -85,6 +85,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: {
|
||||
user: {
|
||||
id: defaultCreatorId,
|
||||
@@ -124,7 +125,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
||||
test("with session user beeing creator it should return all private boards of them", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const user1 = await createRandomUserAsync(db);
|
||||
const user2 = await createRandomUserAsync(db);
|
||||
@@ -168,6 +169,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
@@ -232,6 +234,7 @@ describe("getAllBoards should return all boards accessable to the current user",
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
@@ -290,7 +293,7 @@ describe("createBoard should create a new board", () => {
|
||||
permissions: ["board-create"] satisfies GroupPermissionKey[],
|
||||
},
|
||||
};
|
||||
const caller = boardRouter.createCaller({ db, session });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
|
||||
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
@@ -316,7 +319,7 @@ describe("createBoard should create a new board", () => {
|
||||
test("should throw error when user has no board-create permission", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.createBoard({ name: "newBoard", columnCount: 12, isPublic: true });
|
||||
@@ -330,7 +333,7 @@ describe("rename board should rename board", () => {
|
||||
test("should rename board", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
|
||||
await db.insert(users).values({
|
||||
@@ -358,7 +361,7 @@ describe("rename board should rename board", () => {
|
||||
test("should throw error when similar board name exists", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
@@ -385,7 +388,7 @@ describe("rename board should rename board", () => {
|
||||
test("should throw error when board not found", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.renameBoard({ id: "nonExistentBoardId", name: "newName" });
|
||||
@@ -401,7 +404,7 @@ describe("changeBoardVisibility should change board visibility", () => {
|
||||
async (visibility) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
|
||||
await db.insert(users).values({
|
||||
@@ -436,7 +439,7 @@ describe("deleteBoard should delete board", () => {
|
||||
test("should delete board", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
|
||||
await db.insert(users).values({
|
||||
@@ -463,7 +466,7 @@ describe("deleteBoard should delete board", () => {
|
||||
test("should throw error when board not found", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.deleteBoard({ id: "nonExistentBoardId" });
|
||||
@@ -478,7 +481,7 @@ describe("getHomeBoard should return home board", () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const fullBoardProps = await createFullBoardAsync(db, "home");
|
||||
await db
|
||||
@@ -502,7 +505,7 @@ describe("getHomeBoard should return home board", () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const fullBoardProps = await createFullBoardAsync(db, "home");
|
||||
await db.insert(serverSettings).values({
|
||||
@@ -523,7 +526,7 @@ describe("getHomeBoard should return home board", () => {
|
||||
test("should throw error when home board not configured in serverSettings", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
await createFullBoardAsync(db, "home");
|
||||
|
||||
// Act
|
||||
@@ -539,7 +542,7 @@ describe("getBoardByName should return board by name", () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const fullBoardProps = await createFullBoardAsync(db, name);
|
||||
|
||||
@@ -557,7 +560,7 @@ describe("getBoardByName should return board by name", () => {
|
||||
it("should throw error when not present", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
await createFullBoardAsync(db, "default");
|
||||
|
||||
// Act
|
||||
@@ -573,7 +576,7 @@ describe("savePartialBoardSettings should save general settings", () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const newPageTitle = "newPageTitle";
|
||||
const newMetaTitle = "newMetaTitle";
|
||||
@@ -633,7 +636,7 @@ describe("savePartialBoardSettings should save general settings", () => {
|
||||
|
||||
it("should throw error when board not found", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const actAsync = async () =>
|
||||
await caller.savePartialBoardSettings({
|
||||
@@ -652,7 +655,7 @@ describe("saveBoard should save full board", () => {
|
||||
it("should remove section when not present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
@@ -689,7 +692,7 @@ describe("saveBoard should save full board", () => {
|
||||
it("should remove item when not present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
@@ -744,7 +747,7 @@ describe("saveBoard should save full board", () => {
|
||||
it("should remove integration reference when not present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const anotherIntegration = {
|
||||
id: createId(),
|
||||
kind: "adGuardHome",
|
||||
@@ -814,7 +817,7 @@ describe("saveBoard should save full board", () => {
|
||||
async (partialSection) => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
@@ -867,7 +870,7 @@ describe("saveBoard should save full board", () => {
|
||||
it("should add item when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
@@ -931,7 +934,7 @@ describe("saveBoard should save full board", () => {
|
||||
it("should add integration reference when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const integration = {
|
||||
id: createId(),
|
||||
kind: "plex",
|
||||
@@ -998,7 +1001,7 @@ describe("saveBoard should save full board", () => {
|
||||
});
|
||||
it("should update section when present in input", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
const newSectionId = createId();
|
||||
@@ -1056,7 +1059,7 @@ describe("saveBoard should save full board", () => {
|
||||
it("should update item when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
@@ -1112,7 +1115,7 @@ describe("saveBoard should save full board", () => {
|
||||
});
|
||||
it("should fail when board not found", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const actAsync = async () =>
|
||||
await caller.saveBoard({
|
||||
@@ -1128,7 +1131,7 @@ describe("getBoardPermissions should return board permissions", () => {
|
||||
test("should return board permissions", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
|
||||
const user1 = await createRandomUserAsync(db);
|
||||
@@ -1202,7 +1205,7 @@ describe("saveUserBoardPermissions should save user board permissions", () => {
|
||||
async (permission) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
|
||||
const user1 = await createRandomUserAsync(db);
|
||||
@@ -1244,7 +1247,7 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
|
||||
async (permission) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
|
||||
await db.insert(users).values({
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("All procedures should only be accessible for users with admin permissi
|
||||
// Arrange
|
||||
const caller = dockerRouter.createCaller({
|
||||
db: null as unknown as Database,
|
||||
deviceType: undefined,
|
||||
session: createSessionWithPermissions("admin"),
|
||||
});
|
||||
|
||||
@@ -68,6 +69,7 @@ describe("All procedures should only be accessible for users with admin permissi
|
||||
);
|
||||
const caller = dockerRouter.createCaller({
|
||||
db: null as unknown as Database,
|
||||
deviceType: undefined,
|
||||
session: createSessionWithPermissions(...groupPermissionsWithoutAdmin),
|
||||
});
|
||||
|
||||
@@ -81,6 +83,7 @@ describe("All procedures should only be accessible for users with admin permissi
|
||||
// Arrange
|
||||
const caller = dockerRouter.createCaller({
|
||||
db: null as unknown as Database,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import * as env from "@homarr/auth/env.mjs";
|
||||
import * as env from "@homarr/auth/env";
|
||||
import { createId, eq } from "@homarr/db";
|
||||
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
@@ -37,7 +37,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
async (page, expectedCount) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await db.insert(groups).values(
|
||||
[1, 2, 3, 4, 5].map((number) => ({
|
||||
@@ -60,7 +60,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
test("with 5 groups in database and pagesize set to 3 it should return total count 5", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await db.insert(groups).values(
|
||||
[1, 2, 3, 4, 5].map((number) => ({
|
||||
@@ -81,7 +81,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
test("groups should contain id, name, email and image of members", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const user = createDummyUser();
|
||||
await db.insert(users).values(user);
|
||||
@@ -117,7 +117,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
async (query, expectedCount, firstKey) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await db.insert(groups).values(
|
||||
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
|
||||
@@ -140,7 +140,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.getPaginated({});
|
||||
@@ -154,7 +154,7 @@ describe("byId should return group by id including members and permissions", ()
|
||||
test('should return group with id "1" with members and permissions', async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const user = createDummyUser();
|
||||
const groupId = "1";
|
||||
@@ -197,7 +197,7 @@ describe("byId should return group by id including members and permissions", ()
|
||||
test("with group id 1 and group 2 in database it should throw NOT_FOUND error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: "2",
|
||||
@@ -214,7 +214,7 @@ describe("byId should return group by id including members and permissions", ()
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.getById({ id: "1" });
|
||||
@@ -228,7 +228,7 @@ describe("create should create group in database", () => {
|
||||
test("with valid input (64 character name) and non existing name it should be successful", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const name = "a".repeat(64);
|
||||
await db.insert(users).values(defaultSession.user);
|
||||
@@ -252,7 +252,7 @@ describe("create should create group in database", () => {
|
||||
test("with more than 64 characters name it should fail while validation", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
const longName = "a".repeat(65);
|
||||
|
||||
// Act
|
||||
@@ -273,7 +273,7 @@ describe("create should create group in database", () => {
|
||||
])("with similar name %s it should fail to create %s", async (similarName, nameToCreate) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
@@ -290,7 +290,7 @@ describe("create should create group in database", () => {
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.createGroup({ name: "test" });
|
||||
@@ -307,7 +307,7 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
])("update should update name from %s to %s normalized", async (initialValue, updateValue, expectedValue) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values([
|
||||
@@ -340,7 +340,7 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
])("with similar name %s it should fail to update %s", async (updateValue, initialDuplicate) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values([
|
||||
@@ -368,7 +368,7 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
test("with non existing id it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
@@ -389,7 +389,7 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
@@ -407,7 +407,7 @@ describe("savePermissions should save permissions for group", () => {
|
||||
test("with existing group and permissions it should save permissions", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
@@ -437,7 +437,7 @@ describe("savePermissions should save permissions for group", () => {
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
@@ -458,7 +458,7 @@ describe("savePermissions should save permissions for group", () => {
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
@@ -476,7 +476,7 @@ describe("transferOwnership should transfer ownership of group", () => {
|
||||
test("with existing group and user it should transfer ownership", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
const newUserId = createId();
|
||||
@@ -513,7 +513,7 @@ describe("transferOwnership should transfer ownership of group", () => {
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
@@ -534,7 +534,7 @@ describe("transferOwnership should transfer ownership of group", () => {
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
@@ -552,7 +552,7 @@ describe("deleteGroup should delete group", () => {
|
||||
test("with existing group it should delete group", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values([
|
||||
@@ -581,7 +581,7 @@ describe("deleteGroup should delete group", () => {
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
@@ -601,7 +601,7 @@ describe("deleteGroup should delete group", () => {
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
@@ -620,7 +620,7 @@ describe("addMember should add member to group", () => {
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(env, "env", "get");
|
||||
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
const userId = createId();
|
||||
@@ -658,7 +658,7 @@ describe("addMember should add member to group", () => {
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await db.insert(users).values({
|
||||
id: createId(),
|
||||
@@ -679,7 +679,7 @@ describe("addMember should add member to group", () => {
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
@@ -697,7 +697,7 @@ describe("addMember should add member to group", () => {
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(env, "env", "get");
|
||||
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
const userId = createId();
|
||||
@@ -735,7 +735,7 @@ describe("removeMember should remove member from group", () => {
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(env, "env", "get");
|
||||
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
const userId = createId();
|
||||
@@ -776,7 +776,7 @@ describe("removeMember should remove member from group", () => {
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await db.insert(users).values({
|
||||
id: createId(),
|
||||
@@ -797,7 +797,7 @@ describe("removeMember should remove member from group", () => {
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
@@ -815,7 +815,7 @@ describe("removeMember should remove member from group", () => {
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(env, "env", "get");
|
||||
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
const userId = createId();
|
||||
|
||||
@@ -33,6 +33,7 @@ describe("all should return all integrations", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
@@ -63,6 +64,7 @@ describe("byId should return an integration by id", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||
});
|
||||
|
||||
@@ -89,6 +91,7 @@ describe("byId should return an integration by id", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||
});
|
||||
|
||||
@@ -100,6 +103,7 @@ describe("byId should return an integration by id", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||
});
|
||||
|
||||
@@ -147,6 +151,7 @@ describe("byId should return an integration by id", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-interact-all"]),
|
||||
});
|
||||
|
||||
@@ -172,6 +177,7 @@ describe("create should create a new integration", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
const input = {
|
||||
@@ -206,6 +212,7 @@ describe("create should create a new integration", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
const input = {
|
||||
@@ -240,7 +247,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSearchEngine!.short).toBe("j");
|
||||
expect(dbSearchEngine!.name).toBe(input.name);
|
||||
expect(dbSearchEngine!.iconUrl).toBe(
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
|
||||
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/jellyseerr.png",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -249,6 +256,7 @@ describe("create should create a new integration", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-interact-all"]),
|
||||
});
|
||||
const input = {
|
||||
@@ -272,6 +280,7 @@ describe("update should update an integration", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||
});
|
||||
|
||||
@@ -346,6 +355,7 @@ describe("update should update an integration", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||
});
|
||||
|
||||
@@ -364,6 +374,7 @@ describe("update should update an integration", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-interact-all"]),
|
||||
});
|
||||
|
||||
@@ -386,6 +397,7 @@ describe("delete should delete an integration", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||
});
|
||||
|
||||
@@ -419,6 +431,7 @@ describe("delete should delete an integration", () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-interact-all"]),
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock("@homarr/auth", async () => {
|
||||
});
|
||||
|
||||
// Mock the env module to return the credentials provider
|
||||
vi.mock("@homarr/auth/env.mjs", () => {
|
||||
vi.mock("@homarr/auth/env", () => {
|
||||
return {
|
||||
env: {
|
||||
AUTH_PROVIDERS: ["credentials"],
|
||||
@@ -38,6 +38,7 @@ describe("all should return all existing invites without sensitive informations"
|
||||
const db = createDb();
|
||||
const caller = inviteRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
@@ -72,6 +73,7 @@ describe("all should return all existing invites without sensitive informations"
|
||||
const db = createDb();
|
||||
const caller = inviteRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
@@ -111,6 +113,7 @@ describe("create should create a new invite expiring on the specified date with
|
||||
const db = createDb();
|
||||
const caller = inviteRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
await db.insert(users).values({
|
||||
@@ -142,6 +145,7 @@ describe("delete should remove invite by id", () => {
|
||||
const db = createDb();
|
||||
const caller = inviteRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
@@ -179,6 +183,7 @@ describe("delete should remove invite by id", () => {
|
||||
const db = createDb();
|
||||
const caller = inviteRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("getAll server settings", () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
@@ -44,6 +45,7 @@ describe("getAll server settings", () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
@@ -58,6 +60,7 @@ describe("saveSettings", () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock("@homarr/auth", async () => {
|
||||
});
|
||||
|
||||
// Mock the env module to return the credentials provider
|
||||
vi.mock("@homarr/auth/env.mjs", () => {
|
||||
vi.mock("@homarr/auth/env", () => {
|
||||
return {
|
||||
env: {
|
||||
AUTH_PROVIDERS: ["credentials"],
|
||||
@@ -42,6 +42,7 @@ describe("initUser should initialize the first user", () => {
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
@@ -65,6 +66,7 @@ describe("initUser should initialize the first user", () => {
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
@@ -89,6 +91,7 @@ describe("initUser should initialize the first user", () => {
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
@@ -109,6 +112,7 @@ describe("register should create a user with valid invitation", () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
@@ -164,6 +168,7 @@ describe("register should create a user with valid invitation", () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
@@ -206,6 +211,7 @@ describe("editProfile shoud update user", () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
@@ -242,6 +248,7 @@ describe("editProfile shoud update user", () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
@@ -277,6 +284,7 @@ describe("delete should delete user", () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("ping should call sendPingRequestAsync with url and return result", ()
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
spy.mockImplementation(() => Promise.resolve({ error: "error" }));
|
||||
@@ -37,6 +38,7 @@ describe("ping should call sendPingRequestAsync with url and return result", ()
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
spy.mockImplementation(() => Promise.resolve({ statusCode: 200 }));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, createId, eq, like } from "@homarr/db";
|
||||
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
||||
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
@@ -209,8 +210,10 @@ export const userRouter = createTRPCRouter({
|
||||
image: true,
|
||||
provider: true,
|
||||
homeBoardId: true,
|
||||
mobileHomeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
defaultSearchEngineId: true,
|
||||
}),
|
||||
)
|
||||
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
|
||||
@@ -231,8 +234,10 @@ export const userRouter = createTRPCRouter({
|
||||
image: true,
|
||||
provider: true,
|
||||
homeBoardId: true,
|
||||
mobileHomeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
defaultSearchEngineId: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
@@ -371,8 +376,8 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
changeHomeBoardId: protectedProcedure
|
||||
.input(convertIntersectionToZodObject(validation.user.changeHomeBoard.and(z.object({ userId: z.string() }))))
|
||||
changeHomeBoards: protectedProcedure
|
||||
.input(convertIntersectionToZodObject(validation.user.changeHomeBoards.and(z.object({ userId: z.string() }))))
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } })
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@@ -399,10 +404,50 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.userId), "view");
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
homeBoardId: input.homeBoardId,
|
||||
mobileHomeBoardId: input.mobileHomeBoardId,
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
changeDefaultSearchEngine: protectedProcedure
|
||||
.input(
|
||||
convertIntersectionToZodObject(validation.user.changeDefaultSearchEngine.and(z.object({ userId: z.string() }))),
|
||||
)
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "PATCH", path: "/api/users/changeSearchEngine", tags: ["users"], protect: true } })
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const user = ctx.session.user;
|
||||
// Only admins can change other users passwords
|
||||
if (!user.permissions.includes("admin") && user.id !== input.userId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
|
||||
if (!dbUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
homeBoardId: input.homeBoardId,
|
||||
defaultSearchEngineId: input.defaultSearchEngineId,
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { auth } from "@homarr/auth/next";
|
||||
* handling a tRPC call from a React Server Component.
|
||||
*/
|
||||
const createContext = cache(async () => {
|
||||
const heads = new Headers(headers());
|
||||
const heads = new Headers(await headers());
|
||||
heads.set("x-trpc-source", "rsc");
|
||||
|
||||
return createTRPCContext({
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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 null;
|
||||
}
|
||||
|
||||
const { cookies } = await import("next/headers");
|
||||
|
||||
return (await cookies())
|
||||
.getAll()
|
||||
.map(({ name, value }) => `${name}=${value}`)
|
||||
.join(";");
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type { OpenApiMeta } from "trpc-to-openapi";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { FlattenError } from "@homarr/common";
|
||||
import { userAgent } from "@homarr/common/server";
|
||||
import { db } from "@homarr/db";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
@@ -39,6 +40,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
|
||||
|
||||
return {
|
||||
session,
|
||||
deviceType: userAgent(opts.headers).device.type,
|
||||
db,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
|
||||
import { createAdapter } from "./adapter";
|
||||
import { createSessionCallback } from "./callbacks";
|
||||
import { env } from "./env.mjs";
|
||||
import { env } from "./env";
|
||||
import { createSignInEventHandler } from "./events";
|
||||
import { createCredentialsConfiguration, createLdapConfiguration } from "./providers/credentials/credentials-provider";
|
||||
import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";
|
||||
@@ -74,7 +74,7 @@ export const createConfiguration = (
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
cookies().set(sessionTokenCookieName, sessionToken, {
|
||||
(await cookies()).set(sessionTokenCookieName, sessionToken, {
|
||||
path: "/",
|
||||
expires: expires,
|
||||
httpOnly: true,
|
||||
@@ -99,8 +99,9 @@ export const createConfiguration = (
|
||||
error: "/auth/login",
|
||||
},
|
||||
jwt: {
|
||||
encode() {
|
||||
const cookie = cookies().get(sessionTokenCookieName)?.value;
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async encode() {
|
||||
const cookie = (await cookies()).get(sessionTokenCookieName)?.value;
|
||||
return cookie ?? "";
|
||||
},
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
const trueStrings = ["1", "yes", "t", "true"];
|
||||
const falseStrings = ["0", "no", "f", "false"];
|
||||
import { createBooleanSchema, createDurationSchema, shouldSkipEnvValidation } from "@homarr/common/env-validation";
|
||||
import { supportedAuthProviders } from "@homarr/definitions";
|
||||
|
||||
const supportedAuthProviders = ["credentials", "oidc", "ldap"];
|
||||
const authProvidersSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -14,7 +13,7 @@ const authProvidersSchema = z
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.filter((provider) => {
|
||||
if (supportedAuthProviders.includes(provider)) return true;
|
||||
if (supportedAuthProviders.some((supportedProvider) => supportedProvider === provider)) return true;
|
||||
else if (!provider)
|
||||
console.log("One or more of the entries for AUTH_PROVIDER could not be parsed and/or returned null.");
|
||||
else console.log(`The value entered for AUTH_PROVIDER "${provider}" is incorrect.`);
|
||||
@@ -23,41 +22,7 @@ const authProvidersSchema = z
|
||||
)
|
||||
.default("credentials");
|
||||
|
||||
const createDurationSchema = (defaultValue) =>
|
||||
z
|
||||
.string()
|
||||
.regex(/^\d+[smhd]?$/)
|
||||
.default(defaultValue)
|
||||
.transform((duration) => {
|
||||
const lastChar = duration[duration.length - 1];
|
||||
if (!isNaN(Number(lastChar))) {
|
||||
return Number(defaultValue);
|
||||
}
|
||||
|
||||
const multipliers = {
|
||||
s: 1,
|
||||
m: 60,
|
||||
h: 60 * 60,
|
||||
d: 60 * 60 * 24,
|
||||
};
|
||||
const numberDuration = Number(duration.slice(0, -1));
|
||||
const multiplier = multipliers[lastChar];
|
||||
|
||||
return numberDuration * multiplier;
|
||||
});
|
||||
|
||||
const booleanSchema = z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((value, ctx) => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (trueStrings.includes(normalized)) return true;
|
||||
if (falseStrings.includes(normalized)) return false;
|
||||
|
||||
throw new Error(`Invalid boolean value for ${ctx.path.join(".")}`);
|
||||
});
|
||||
|
||||
const skipValidation = Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION);
|
||||
const skipValidation = shouldSkipEnvValidation();
|
||||
const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.env.AUTH_PROVIDERS);
|
||||
|
||||
export const env = createEnv({
|
||||
@@ -71,9 +36,10 @@ export const env = createEnv({
|
||||
AUTH_OIDC_CLIENT_ID: z.string().min(1),
|
||||
AUTH_OIDC_CLIENT_SECRET: z.string().min(1),
|
||||
AUTH_OIDC_CLIENT_NAME: z.string().min(1).default("OIDC"),
|
||||
AUTH_OIDC_AUTO_LOGIN: booleanSchema,
|
||||
AUTH_OIDC_AUTO_LOGIN: createBooleanSchema(false),
|
||||
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
|
||||
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
|
||||
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
|
||||
}
|
||||
: {}),
|
||||
...(authProviders.includes("ldap")
|
||||
@@ -117,6 +83,7 @@ export const env = createEnv({
|
||||
AUTH_LDAP_USER_MAIL_ATTRIBUTE: process.env.AUTH_LDAP_USER_MAIL_ATTRIBUTE,
|
||||
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG,
|
||||
AUTH_OIDC_AUTO_LOGIN: process.env.AUTH_OIDC_AUTO_LOGIN,
|
||||
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: process.env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE,
|
||||
},
|
||||
skipValidation,
|
||||
});
|
||||
+15
-8
@@ -8,7 +8,8 @@ import { groupMembers, groups, users } from "@homarr/db/schema";
|
||||
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { env } from "./env.mjs";
|
||||
import { env } from "./env";
|
||||
import { extractProfileName } from "./providers/oidc/oidc-provider";
|
||||
|
||||
export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["events"], undefined>["signIn"] => {
|
||||
return async ({ user, profile }) => {
|
||||
@@ -43,18 +44,24 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
);
|
||||
}
|
||||
|
||||
const profileUsername = profile?.preferred_username?.includes("@") ? profile.name : profile?.preferred_username;
|
||||
if (profileUsername && dbUser.name !== profileUsername) {
|
||||
await db.update(users).set({ name: profileUsername }).where(eq(users.id, user.id));
|
||||
logger.info(
|
||||
`Username for user of oidc provider has changed. user=${user.id} old='${dbUser.name}' new='${profileUsername}'`,
|
||||
);
|
||||
if (profile) {
|
||||
const profileUsername = extractProfileName(profile);
|
||||
if (!profileUsername) {
|
||||
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
|
||||
}
|
||||
|
||||
if (dbUser.name !== profileUsername) {
|
||||
await db.update(users).set({ name: profileUsername }).where(eq(users.id, user.id));
|
||||
logger.info(
|
||||
`Username for user of oidc provider has changed. user=${user.id} old='${dbUser.name}' new='${profileUsername}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`User '${dbUser.name}' logged in at ${dayjs().format()}`);
|
||||
|
||||
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
|
||||
cookies().set(colorSchemeCookieKey, dbUser.colorScheme, {
|
||||
(await cookies()).set(colorSchemeCookieKey, dbUser.colorScheme, {
|
||||
path: "/",
|
||||
expires: dayjs().add(1, "year").toDate(),
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ declare module "next-auth" {
|
||||
export * from "./security";
|
||||
|
||||
// See why it's unknown in the [...nextauth]/route.ts file
|
||||
export const createHandlers = (provider: SupportedAuthProvider | "unknown", useSecureCookies: boolean) =>
|
||||
createConfiguration(provider, headers(), useSecureCookies);
|
||||
export const createHandlersAsync = async (provider: SupportedAuthProvider | "unknown", useSecureCookies: boolean) =>
|
||||
createConfiguration(provider, await headers(), useSecureCookies);
|
||||
|
||||
export { getSessionFromTokenAsync as getSessionFromToken, sessionTokenCookieName } from "./session";
|
||||
@@ -11,7 +11,7 @@
|
||||
"./client": "./client.ts",
|
||||
"./server": "./server.ts",
|
||||
"./shared": "./shared.ts",
|
||||
"./env.mjs": "./env.mjs"
|
||||
"./env": "./env.ts"
|
||||
},
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
@@ -33,11 +33,11 @@
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cookies": "^0.9.1",
|
||||
"ldapts": "7.3.0",
|
||||
"next": "^14.2.22",
|
||||
"ldapts": "7.3.1",
|
||||
"next": "15.1.4",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
@@ -45,8 +45,8 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cookies": "0.9.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint": "^9.18.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import { env } from "../env";
|
||||
|
||||
export const isProviderEnabled = (provider: SupportedAuthProvider) => {
|
||||
// The question mark is placed there because isProviderEnabled is called during static build of about page
|
||||
|
||||
@@ -7,7 +7,7 @@ import { logger } from "@homarr/log";
|
||||
import type { validation } from "@homarr/validation";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { env } from "../../../env.mjs";
|
||||
import { env } from "../../../env";
|
||||
import { LdapClient } from "../ldap-client";
|
||||
|
||||
export const authorizeWithLdapCredentialsAsync = async (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Client } from "ldapts";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
|
||||
import { env } from "../../env.mjs";
|
||||
import { env } from "../../env";
|
||||
|
||||
export interface BindOptions {
|
||||
distinguishedName: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Provider } from "next-auth/providers";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import { env } from "../env";
|
||||
|
||||
export const filterProviders = (providers: Exclude<Provider, () => unknown>[]) => {
|
||||
// During build this will be undefined, so we default to an empty array
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
import type { OIDCConfig } from "next-auth/providers";
|
||||
import type { OIDCConfig } from "@auth/core/providers";
|
||||
import type { Profile } from "@auth/core/types";
|
||||
|
||||
import { env } from "../../env.mjs";
|
||||
import { env } from "../../env";
|
||||
import { createRedirectUri } from "../../redirect";
|
||||
|
||||
interface Profile {
|
||||
sub: string;
|
||||
name: string;
|
||||
email: string;
|
||||
groups: string[];
|
||||
preferred_username: string;
|
||||
email_verified: boolean;
|
||||
}
|
||||
|
||||
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
|
||||
id: "oidc",
|
||||
name: env.AUTH_OIDC_CLIENT_NAME,
|
||||
@@ -28,12 +20,28 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
|
||||
},
|
||||
},
|
||||
profile(profile) {
|
||||
if (!profile.sub) {
|
||||
throw new Error(`OIDC provider did not return a sub property='${Object.keys(profile).join(",")}'`);
|
||||
}
|
||||
const name = extractProfileName(profile);
|
||||
if (!name) {
|
||||
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: profile.sub,
|
||||
// Use the name as the username if the preferred_username is an email address
|
||||
name: profile.preferred_username.includes("@") ? profile.name : profile.preferred_username,
|
||||
name,
|
||||
email: profile.email,
|
||||
provider: "oidc",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const extractProfileName = (profile: Profile) => {
|
||||
if (!env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE) {
|
||||
// Use the name as the username if the preferred_username is an email address
|
||||
return profile.preferred_username?.includes("@") ? profile.name : profile.preferred_username;
|
||||
}
|
||||
|
||||
return profile[env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE as keyof typeof profile] as string;
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import { createDb } from "@homarr/db/test";
|
||||
import { authorizeWithLdapCredentialsAsync } from "../credentials/authorization/ldap-authorization";
|
||||
import * as ldapClient from "../credentials/ldap-client";
|
||||
|
||||
vi.mock("../../env.mjs", () => ({
|
||||
vi.mock("../../env", () => ({
|
||||
env: {
|
||||
AUTH_LDAP_BIND_DN: "bind_dn",
|
||||
AUTH_LDAP_BIND_PASSWORD: "bind_password",
|
||||
|
||||
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user