feat(auth+terms): add iron-session auth, per-session Nextcloud client; protect file/folder APIs; add Terms modal + gating; wire OnboardingGate; update envs and routes. Step 1 & 2 complete

This commit is contained in:
nicholai 2025-09-13 12:33:37 -06:00
parent eba3fe7bf2
commit 34e8b6389f
38 changed files with 962 additions and 1369 deletions

315
package-lock.json generated
View File

@ -25,6 +25,7 @@
"date-fns": "^4.1.0",
"deck.gl": "^9.1.14",
"framer-motion": "^12.23.12",
"iron-session": "^8.0.4",
"lucide-react": "^0.544.0",
"next": "15.5.3",
"next-themes": "^0.4.6",
@ -32,7 +33,6 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-loading-skeleton": "^3.5.0",
"recharts": "^3.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"umap-js": "^1.4.0",
@ -4640,32 +4640,6 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-commonjs": {
"version": "28.0.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz",
@ -5559,18 +5533,6 @@
"integrity": "sha512-ARhyoYDnY1LES3vYI0fiG6e9esWfTNcXcO6+MPJJXcnyMV3bim4lnFt45VXouV7y82F4x3YH8nOQ6VztuvUiWg==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -6279,27 +6241,6 @@
"integrity": "sha512-5sNP3DmtSnSozxcjqmzQKsDOuVJXZkceo1KJScDc1982kk/TS9mTPc6lpli1gTu1MIBF1YWutpHpjucNWcIj5g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz",
@ -6309,27 +6250,12 @@
"@types/d3-time": "^2"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz",
"integrity": "sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@ -6482,12 +6408,6 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.43.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz",
@ -8629,6 +8549,15 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/core-assert": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz",
@ -8726,15 +8655,6 @@
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
@ -8762,15 +8682,6 @@
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@ -8787,18 +8698,6 @@
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
@ -8823,15 +8722,6 @@
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -8994,12 +8884,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deck.gl": {
"version": "9.1.14",
"resolved": "https://registry.npmjs.org/deck.gl/-/deck.gl-9.1.14.tgz",
@ -9402,16 +9286,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.39.10",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
"integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
@ -9939,12 +9813,6 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@ -10803,16 +10671,6 @@
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -10892,6 +10750,30 @@
"node": ">=12"
}
},
"node_modules/iron-session": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz",
"integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==",
"funding": [
"https://github.com/sponsors/vvo",
"https://github.com/sponsors/brc-dd"
],
"license": "MIT",
"dependencies": {
"cookie": "^0.7.2",
"iron-webcrypto": "^1.2.1",
"uncrypto": "^0.1.3"
}
},
"node_modules/iron-webcrypto": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
"integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/brc-dd"
}
},
"node_modules/is-any-array": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-0.1.1.tgz",
@ -13367,29 +13249,6 @@
"react": ">=16.8.0"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
@ -13498,48 +13357,6 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.0.tgz",
"integrity": "sha512-fX0xCgNXo6mag9wz3oLuANR+dUQM4uIlTYBGTGq9CBRgW/8TZPzqPGYs5NTt8aENCf+i1CI8vqxT1py8L/5J2w==",
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -13614,12 +13431,6 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@ -14851,12 +14662,6 @@
"node": ">=18.12.0"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -15220,6 +15025,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/uncrypto": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
},
"node_modules/undici": {
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
@ -15384,15 +15195,6 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -15412,43 +15214,6 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/victory-vendor/node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/victory-vendor/node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.20",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz",

View File

@ -31,6 +31,7 @@
"date-fns": "^4.1.0",
"deck.gl": "^9.1.14",
"framer-motion": "^12.23.12",
"iron-session": "^8.0.4",
"lucide-react": "^0.544.0",
"next": "15.5.3",
"next-themes": "^0.4.6",
@ -38,7 +39,6 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-loading-skeleton": "^3.5.0",
"recharts": "^3.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"umap-js": "^1.4.0",

View File

@ -1,36 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { getActivity } from "@/lib/analytics";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const from = searchParams.get("from") || undefined;
const to = searchParams.get("to") || undefined;
const interval = (searchParams.get("interval") as "day" | "week" | "month") || "day";
const result = await Sentry.startSpan(
{ op: "db.query", name: "analytics.activity" },
async (span) => {
span.setAttribute("interval", interval);
if (from) span.setAttribute("from", from);
if (to) span.setAttribute("to", to);
const data = await getActivity({ from, to, interval });
return data;
},
);
return json(result);
} catch (error: unknown) {
Sentry.captureException(error);
const message = error instanceof Error ? error.message : String(error);
return json({ error: "Failed to fetch activity", message }, { status: 500 });
}
}

View File

@ -1,34 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { getFileTypes } from "@/lib/analytics";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const limit = Number(searchParams.get("limit") || "20") || 20;
const withSize = searchParams.get("withSize") === "true";
const result = await Sentry.startSpan(
{ op: "db.query", name: "analytics.file-types" },
async (span) => {
span.setAttribute("limit", limit);
span.setAttribute("withSize", withSize);
const data = await getFileTypes({ limit, withSize });
return data;
},
);
return json(result);
} catch (error: unknown) {
Sentry.captureException(error);
const message = error instanceof Error ? error.message : String(error);
return json({ error: "Failed to fetch file types", message }, { status: 500 });
}
}

View File

@ -1,32 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { getSummary } from "@/lib/analytics";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const ownersLimit = Number(searchParams.get("ownersLimit") || "10") || 10;
const result = await Sentry.startSpan(
{ op: "db.query", name: "analytics.summary" },
async (span) => {
span.setAttribute("ownersLimit", ownersLimit);
const summary = await getSummary({ ownersLimit });
return summary;
},
);
return json(result);
} catch (error: unknown) {
Sentry.captureException(error);
const message = error instanceof Error ? error.message : String(error);
return json({ error: "Failed to fetch analytics summary", message }, { status: 500 });
}
}

View File

@ -1,32 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { getTopTags } from "@/lib/analytics";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const limit = Number(searchParams.get("limit") || "20") || 20;
const result = await Sentry.startSpan(
{ op: "db.query", name: "analytics.tags" },
async (span) => {
span.setAttribute("limit", limit);
const data = await getTopTags({ limit });
return data;
},
);
return json(result);
} catch (error: unknown) {
Sentry.captureException(error);
const message = error instanceof Error ? error.message : String(error);
return json({ error: "Failed to fetch top tags", message }, { status: 500 });
}
}

View File

@ -0,0 +1,42 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { readAuth } from "@/lib/session";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
export async function GET(req: Request) {
try {
const result = await Sentry.startSpan(
{ op: "function", name: "api.auth.session" },
async (span) => {
const auth = await readAuth(req);
if (!auth) {
span.setAttribute("authenticated", false);
return { authenticated: false as const };
}
span.setAttribute("authenticated", true);
span.setAttribute("username", auth.username);
return {
authenticated: true as const,
username: auth.username,
createdAt: auth.createdAt,
};
},
);
return json(result);
} catch (error) {
Sentry.captureException(error);
return json(
{
error: "SESSION_CHECK_FAILED",
message: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@ -0,0 +1,121 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { NextcloudClient } from "@/lib/webdav";
import { env } from "@/lib/env";
import { joinPath, parentPath } from "@/lib/paths";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type SignInBody = {
baseUrl?: string;
username?: string;
appPassword?: string;
};
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
function normalizeBaseUrl(input: string): string {
// Throws on invalid URL
const u = new URL(input);
let s = u.toString();
if (s.endsWith("/")) s = s.slice(0, -1);
return s;
}
function deriveRootPath(username: string): string {
// Use the pattern of the configured root path to substitute the username.
// Example: /remote.php/dav/files/admin -> /remote.php/dav/files/{username}
const baseParent = parentPath(env.NEXTCLOUD_ROOT_PATH) || "/remote.php/dav/files";
return joinPath(baseParent, username);
}
export async function POST(req: Request) {
try {
const body = (await req.json().catch(() => ({}))) as SignInBody;
const baseUrlRaw = body.baseUrl?.trim();
const username = body.username?.trim();
const appPassword = body.appPassword?.trim();
if (!baseUrlRaw || !username || !appPassword) {
return json(
{
error: "INVALID_REQUEST",
message: "baseUrl, username, and appPassword are required",
},
{ status: 400 },
);
}
const result = await Sentry.startSpan(
{ op: "function", name: "api.auth.signin" },
async (span) => {
let baseUrl: string;
try {
baseUrl = normalizeBaseUrl(baseUrlRaw);
} catch {
return {
ok: false,
status: 400 as const,
payload: { error: "INVALID_BASE_URL", message: "baseUrl must be a valid URL" },
};
}
span.setAttribute("baseUrl", baseUrl);
span.setAttribute("username", username);
// Build a per-session client rooted to the user's files path
const rootPath = deriveRootPath(username);
const client = new NextcloudClient(rootPath, {
baseUrl,
username,
appPassword,
});
// Validate credentials with a lightweight directory listing
try {
await client.listDirectory();
} catch (err) {
Sentry.captureException(err);
return {
ok: false,
status: 401 as const,
payload: {
error: "INVALID_CREDENTIALS",
message: "Failed to authenticate with Nextcloud WebDAV",
},
};
}
// Persist session
const res = NextResponse.json({ ok: true });
const session = await getSession(req, res);
session.auth = {
baseUrl,
username,
appPassword,
createdAt: Date.now(),
};
await session.save();
return { ok: true, status: 200 as const, response: res };
},
);
if (!result.ok) {
return json(result.payload, { status: result.status });
}
return result.response!;
} catch (error) {
Sentry.captureException(error);
return json(
{
error: "SIGNIN_FAILED",
message: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@ -0,0 +1,33 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { getSession, destroySession } from "@/lib/session";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
export async function POST(req: Request) {
try {
const res = NextResponse.json({ ok: true });
await Sentry.startSpan(
{ op: "function", name: "api.auth.signout" },
async () => {
const session = await getSession(req, res);
await destroySession(session);
},
);
return res;
} catch (error) {
Sentry.captureException(error);
return json(
{
error: "SIGNOUT_FAILED",
message: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
import { normalizePath } from "@/lib/paths";
export const runtime = "nodejs";
@ -20,11 +20,19 @@ export async function GET(req: Request) {
}
const path = normalizePath(rawPath);
let client;
try {
client = await requireClient(req);
} catch (err) {
if (err instanceof UnauthorizedError) return unauthorizedJson();
throw err;
}
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.content.get" },
async (span) => {
span.setAttribute("path", path);
const res = await nextcloud.readText(path);
const res = await client.readText(path);
return res;
},
);
@ -52,13 +60,21 @@ export async function PUT(req: Request) {
const path = normalizePath(body.path);
const mime = body.mimeType || "text/markdown";
let client;
try {
client = await requireClient(req);
} catch (err) {
if (err instanceof UnauthorizedError) return unauthorizedJson();
throw err;
}
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.content.put" },
async (span) => {
span.setAttribute("path", path);
span.setAttribute("content.length", body.content!.length);
span.setAttribute("mimeType", mime);
const res = await nextcloud.writeText(path, body.content!, mime);
const res = await client.writeText(path, body.content!, mime);
return res;
},
);

View File

@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
import { normalizePath } from "@/lib/paths";
export const runtime = "nodejs";
@ -24,12 +24,20 @@ export async function POST(req: Request) {
const from = normalizePath(body.from);
const to = normalizePath(body.to);
let client;
try {
client = await requireClient(req);
} catch (err) {
if (err instanceof UnauthorizedError) return unauthorizedJson();
throw err;
}
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.copy" },
async (span) => {
span.setAttribute("path_from", from);
span.setAttribute("path_to", to);
const res = await nextcloud.copyFile(from, to);
const res = await client.copyFile(from, to);
return res;
},
);

View File

@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
import { normalizePath } from "@/lib/paths";
export const runtime = "nodejs";
@ -22,11 +22,19 @@ export async function POST(req: Request) {
const path = normalizePath(body.path);
let client;
try {
client = await requireClient(req);
} catch (err) {
if (err instanceof UnauthorizedError) return unauthorizedJson();
throw err;
}
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.delete" },
async (span) => {
span.setAttribute("path", path);
const res = await nextcloud.deletePath(path);
const res = await client.deletePath(path);
return res;
},
);

View File

@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
import { normalizePath } from "@/lib/paths";
import { Readable } from "node:stream";
@ -18,15 +18,23 @@ export async function GET(req: Request) {
if (!rawPath) return badRequest("Missing path");
const path = normalizePath(rawPath);
let client;
try {
client = await requireClient(req);
} catch (err) {
if (err instanceof UnauthorizedError) return unauthorizedJson();
throw err;
}
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.download" },
async (span) => {
span.setAttribute("path", path);
// Optionally stat to get metadata (mime/name). Non-fatal if it fails.
const stat = await nextcloud.stat(path).catch(() => null);
const stat = await client.stat(path).catch(() => null);
const nodeStream = await nextcloud.downloadStream(path);
const nodeStream = await client.downloadStream(path);
// Convert Node.js readable to Web ReadableStream
const asReadable = Readable as unknown as {
toWeb?: (s: NodeJS.ReadableStream) => ReadableStream;

View File

@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
import { normalizePath, parentPath } from "@/lib/paths";
export const runtime = "nodejs";
@ -20,12 +20,20 @@ export async function GET(req: Request) {
const perPage = Math.min(100, Math.max(1, Number(searchParams.get("perPage") || "25") || 25));
const from = (page - 1) * perPage;
let client;
try {
client = await requireClient(req);
} catch (err) {
if (err instanceof UnauthorizedError) return unauthorizedJson();
throw err;
}
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.list" },
async (span) => {
if (path) span.setAttribute("path", path);
const entries = await nextcloud.listDirectory(path);
const entries = await client.listDirectory(path);
const files = entries
.filter((e) => !e.isDirectory)
.map((e) => ({

View File

@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
import { normalizePath } from "@/lib/paths";
export const runtime = "nodejs";
@ -24,12 +24,20 @@ export async function POST(req: Request) {
const from = normalizePath(body.from);
const to = normalizePath(body.to);
let client;
try {
client = await requireClient(req);
} catch (err) {
if (err instanceof UnauthorizedError) return unauthorizedJson();
throw err;
}
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.rename" },
async (span) => {
span.setAttribute("path_from", from);
span.setAttribute("path_to", to);
const res = await nextcloud.moveFile(from, to);
const res = await client.moveFile(from, to);
return res;
},
);

View File

@ -1,6 +1,8 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { getEsClient } from "@/lib/elasticsearch";
import { readAuth } from "@/lib/session";
import { unauthorizedJson } from "@/lib/nextcloud-session";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@ -21,6 +23,11 @@ export async function GET(req: Request) {
return json({ error: "path is required" }, { status: 400 });
}
const auth = await readAuth(req);
if (!auth) {
return unauthorizedJson();
}
const result = await Sentry.startSpan(
{ op: "db.query", name: "ES list tag history" },
async (span) => {

View File

@ -4,6 +4,8 @@ import { getEsClient } from "@/lib/elasticsearch";
import { env } from "@/lib/env";
import { pathToId } from "@/lib/paths";
import type { TagHistoryEvent } from "@/types/files";
import { readAuth } from "@/lib/session";
import { unauthorizedJson } from "@/lib/nextcloud-session";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@ -25,6 +27,12 @@ export async function POST(req: Request) {
const path = body.path;
const tags = body.tags;
const auth = await readAuth(req);
if (!auth) {
return unauthorizedJson();
}
const id = pathToId(path);
const index = env.ELASTICSEARCH_ALIAS || env.ELASTICSEARCH_INDEX;
const eventsIndex = "files_events";

View File

@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
import { normalizePath, parentPath } from "@/lib/paths";
export const runtime = "nodejs";
@ -29,6 +29,14 @@ export async function POST(req: Request) {
}
const destPath = normalizePath(rawDest);
let client;
try {
client = await requireClient(req);
} catch (err) {
if (err instanceof UnauthorizedError) return unauthorizedJson();
throw err;
}
const result = await Sentry.startSpan(
{ op: "function", name: "api.files.upload" },
async (span) => {
@ -37,7 +45,7 @@ export async function POST(req: Request) {
span.setAttribute("filename", file.name);
if (file.type) span.setAttribute("mimeType", file.type);
const upload = await nextcloud.uploadFile(destPath, file, file.type || undefined);
const upload = await client.uploadFile(destPath, file, file.type || undefined);
return {
ok: upload.ok,

View File

@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
import { normalizePath } from "@/lib/paths";
export const runtime = "nodejs";
@ -19,11 +19,19 @@ export async function POST(req: Request) {
}
const path = normalizePath(rawPath);
let client;
try {
client = await requireClient(req);
} catch (err) {
if (err instanceof UnauthorizedError) return unauthorizedJson();
throw err;
}
const result = await Sentry.startSpan(
{ op: "function", name: "api.folders.create" },
async (span) => {
span.setAttribute("path", path);
const res = await nextcloud.createFolder(path);
const res = await client.createFolder(path);
return res;
},
);

View File

@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { nextcloud } from "@/lib/webdav";
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
import { normalizePath, parentPath } from "@/lib/paths";
export const runtime = "nodejs";
@ -16,13 +16,21 @@ export async function GET(req: Request) {
const rawPath = searchParams.get("path") || undefined;
const path = rawPath ? normalizePath(rawPath) : undefined;
let client;
try {
client = await requireClient(req);
} catch (err) {
if (err instanceof UnauthorizedError) return unauthorizedJson();
throw err;
}
const span = Sentry.startSpan(
{ op: "function", name: "api.folders.list" },
async (span) => {
if (path) {
span.setAttribute("path", path);
}
const entries = await nextcloud.listDirectory(path);
const entries = await client.listDirectory(path);
// Provide folder meta and a basic partition for folders vs files
const folders = entries
.filter((e) => e.isDirectory)

View File

@ -0,0 +1,34 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { acceptTerms } from "@/lib/terms";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
export async function POST(_req: Request) {
try {
const res = NextResponse.json({ ok: true });
await Sentry.startSpan(
{ op: "function", name: "api.terms.accept" },
async (span) => {
// Accept current terms by setting versioned cookie
acceptTerms(res);
span.setAttribute("accepted", true);
},
);
return res;
} catch (error) {
Sentry.captureException(error);
return json(
{
error: "TERMS_ACCEPT_FAILED",
message: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@ -0,0 +1,35 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { getTermsAccepted, getTermsVersion } from "@/lib/terms";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
return NextResponse.json(data, init);
}
export async function GET(req: Request) {
try {
const result = await Sentry.startSpan(
{ op: "function", name: "api.terms.status" },
async (span) => {
const accepted = getTermsAccepted(req);
const version = getTermsVersion();
span.setAttribute("accepted", accepted);
span.setAttribute("version", version);
return { accepted, version };
},
);
return json(result);
} catch (error) {
Sentry.captureException(error);
return json(
{
error: "TERMS_STATUS_FAILED",
message: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@ -1,51 +0,0 @@
"use client";
import * as React from "react";
import { KPICards } from "@/components/dashboard/kpis";
import { FileTypesChart } from "@/components/dashboard/charts/file-types-chart";
import { ActivityChart } from "@/components/dashboard/charts/activity-chart";
import { TopTags } from "@/components/dashboard/charts/top-tags";
import { VectorPanel } from "@/components/dashboard/vector-panel";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
export default function DashboardPage() {
const router = useRouter();
function handleDrillSearch(tag: string) {
// For now, surface a hint; in a future PR we can wire a tag filter to the search API directly from the dashboard
// Here we just navigate to the home page; the user can use the global search input
alert(`Search for tag: ${tag}. Use the global search box on the home page.`);
router.push("/");
}
return (
<div className="h-screen flex flex-col">
<header className="border-b p-3 flex items-center gap-3">
<div className="text-lg font-semibold">Dashboard</div>
<div className="ml-auto">
<Button variant="outline" onClick={() => router.push("/")}>Open Explorer</Button>
</div>
</header>
<main className="p-3 flex-1 overflow-auto">
<div className="space-y-3">
{/* KPIs */}
<KPICards />
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<FileTypesChart />
<ActivityChart />
</div>
{/* Tags + Vector Panel */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<TopTags onDrillSearch={handleDrillSearch} />
<VectorPanel />
</div>
</div>
</main>
</div>
);
}

View File

@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { QueryProvider } from "@/components/providers/query-provider";
import { ThemeProvider } from "@/components/theme/theme-provider";
import { OnboardingGate } from "@/components/onboarding/onboarding-gate";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -28,7 +29,9 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<ThemeProvider>
<QueryProvider>{children}</QueryProvider>
<QueryProvider>
<OnboardingGate>{children}</OnboardingGate>
</QueryProvider>
</ThemeProvider>
</body>
</html>

View File

@ -0,0 +1,133 @@
"use client";
import * as React from "react";
import * as Sentry from "@sentry/nextjs";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
type SignInBody = {
baseUrl: string;
username: string;
appPassword: string;
};
export function SignInDialog({ open, onOpenChange }: Props) {
const qc = useQueryClient();
const [baseUrl, setBaseUrl] = React.useState("");
const [username, setUsername] = React.useState("");
const [appPassword, setAppPassword] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const disabled = !baseUrl || !username || !appPassword;
const mutation = useMutation({
mutationFn: async (payload: SignInBody) => {
return Sentry.startSpan(
{ op: "ui.click", name: "auth.signin" },
async (span) => {
span.setAttribute("baseUrl", payload.baseUrl);
span.setAttribute("username", payload.username);
const res = await fetch("/api/auth/signin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const msg =
typeof data?.message === "string"
? data.message
: `Sign-in failed (${res.status})`;
throw new Error(msg);
}
return res.json();
},
);
},
onSuccess: async () => {
setError(null);
// Invalidate session status so OnboardingGate refreshes
await qc.invalidateQueries({ queryKey: ["auth", "session"] });
onOpenChange(false);
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
Sentry.captureException(err);
},
});
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (disabled || mutation.isPending) return;
mutation.mutate({ baseUrl, username, appPassword });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Sign in to Nextcloud</DialogTitle>
<DialogDescription>
Enter your Nextcloud Base URL, username, and app password. Your credentials are stored securely in a server-side session.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="grid gap-3">
<label className="grid gap-1">
<span className="text-sm font-medium">Base URL</span>
<Input
type="url"
placeholder="https://your-nextcloud.example.com"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
required
/>
</label>
<label className="grid gap-1">
<span className="text-sm font-medium">Username</span>
<Input
type="text"
placeholder="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</label>
<label className="grid gap-1">
<span className="text-sm font-medium">App Password</span>
<Input
type="password"
placeholder="app password"
value={appPassword}
onChange={(e) => setAppPassword(e.target.value)}
required
/>
</label>
{error ? (
<p className="text-sm text-red-600">{error}</p>
) : null}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
type="submit"
disabled={disabled || mutation.isPending}
>
{mutation.isPending ? "Signing in..." : "Sign In"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,93 +0,0 @@
"use client";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import type { ActivitySeries } from "@/types/analytics";
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Legend,
} from "recharts";
async function fetchActivity(
from?: string,
to?: string,
interval: "day" | "week" | "month" = "day",
): Promise<ActivitySeries> {
const url = new URL("/api/analytics/activity", window.location.origin);
if (from) url.searchParams.set("from", from);
if (to) url.searchParams.set("to", to);
url.searchParams.set("interval", interval);
const res = await fetch(url.toString(), { cache: "no-store" });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.message || `Failed to load activity (${res.status})`);
}
return (await res.json()) as ActivitySeries;
}
export function ActivityChart() {
const q = useQuery({
queryKey: ["analytics", "activity", "day"],
queryFn: () => fetchActivity(undefined, undefined, "day"),
});
const data =
q.data?.points.map((p) => ({
date: p.date.slice(0, 10),
uploaded: p.uploaded ?? 0,
modified: p.modified ?? 0,
})) ?? [];
return (
<div className="rounded-md border p-3">
<div className="text-sm font-medium mb-2">Activity</div>
<div className="h-64">
{q.isLoading ? (
<div className="text-xs text-muted-foreground">Loading</div>
) : data.length === 0 ? (
<div className="text-xs text-muted-foreground">No data</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
color: "hsl(var(--popover-foreground))",
}}
/>
<Legend />
<Area
type="monotone"
dataKey="uploaded"
name="Uploaded"
stroke="hsl(var(--chart-2))"
fill="hsl(var(--chart-2))"
fillOpacity={0.25}
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="modified"
name="Modified"
stroke="hsl(var(--chart-1))"
fill="hsl(var(--chart-1))"
fillOpacity={0.25}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}

View File

@ -1,68 +0,0 @@
"use client";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileTypesResponse } from "@/types/analytics";
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
} from "recharts";
async function fetchFileTypes(limit = 15, withSize = false): Promise<FileTypesResponse> {
const url = new URL("/api/analytics/file-types", window.location.origin);
url.searchParams.set("limit", String(limit));
url.searchParams.set("withSize", String(withSize));
const res = await fetch(url.toString(), { cache: "no-store" });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.message || `Failed to load file types (${res.status})`);
}
return (await res.json()) as FileTypesResponse;
}
export function FileTypesChart() {
const q = useQuery({
queryKey: ["analytics", "file-types"],
queryFn: () => fetchFileTypes(15, false),
});
const data =
q.data?.buckets.map((b) => ({
name: b.mimeType || "unknown",
count: b.count,
})) ?? [];
return (
<div className="rounded-md border p-3">
<div className="text-sm font-medium mb-2">File Types</div>
<div className="h-64">
{q.isLoading ? (
<div className="text-xs text-muted-foreground">Loading</div>
) : data.length === 0 ? (
<div className="text-xs text-muted-foreground">No data</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
contentStyle={{
background: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
color: "hsl(var(--popover-foreground))",
}}
/>
<Bar dataKey="count" fill="hsl(var(--primary))" />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}

View File

@ -1,64 +0,0 @@
"use client";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import type { TagsResponse } from "@/types/analytics";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
async function fetchTopTags(limit = 20): Promise<TagsResponse> {
const url = new URL("/api/analytics/tags", window.location.origin);
url.searchParams.set("limit", String(limit));
const res = await fetch(url.toString(), { cache: "no-store" });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.message || `Failed to load tags (${res.status})`);
}
return (await res.json()) as TagsResponse;
}
export function TopTags({
onDrillSearch,
}: {
onDrillSearch?: (tag: string) => void;
}) {
const q = useQuery({
queryKey: ["analytics", "top-tags"],
queryFn: () => fetchTopTags(20),
});
const data = q.data?.buckets ?? [];
return (
<div className="rounded-md border p-3 flex flex-col">
<div className="text-sm font-medium mb-2">Top Tags</div>
<ScrollArea className="h-64">
<div className="space-y-1">
{q.isLoading && <div className="text-xs text-muted-foreground">Loading</div>}
{!q.isLoading && data.length === 0 && (
<div className="text-xs text-muted-foreground">No tags</div>
)}
{data.map((t) => (
<div
key={t.tag}
className="flex items-center justify-between rounded border px-2 py-1"
title={t.tag}
>
<div className="truncate">{t.tag || "(empty)"}</div>
<div className="flex items-center gap-2">
<div className="text-xs text-muted-foreground">{t.count.toLocaleString()}</div>
<Button
size="sm"
variant="outline"
onClick={() => onDrillSearch?.(t.tag)}
>
Search
</Button>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@ -1,77 +0,0 @@
"use client";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import type { AnalyticsSummary } from "@/types/analytics";
async function fetchSummary(ownersLimit = 8): Promise<AnalyticsSummary> {
const url = new URL("/api/analytics/summary", window.location.origin);
url.searchParams.set("ownersLimit", String(ownersLimit));
const res = await fetch(url.toString(), { cache: "no-store" });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.message || `Failed to load summary (${res.status})`);
}
return (await res.json()) as AnalyticsSummary;
}
function formatBytes(bytes?: number) {
const b = Number(bytes || 0);
if (!b) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.floor(Math.log(b) / Math.log(k));
return `${parseFloat((b / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
export function KPICards() {
const q = useQuery({
queryKey: ["analytics", "summary"],
queryFn: () => fetchSummary(8),
});
const s = q.data;
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<KPI title="Total Files" value={q.isLoading ? "…" : (s?.totalFiles ?? 0).toLocaleString()} />
<KPI title="Total Size" value={q.isLoading ? "…" : formatBytes(s?.totalSizeBytes)} />
<KPI title="Unique Tags" value={q.isLoading ? "…" : (s?.uniqueTags ?? 0).toLocaleString()} />
<KPI
title="Last Modified"
value={q.isLoading ? "…" : (s?.lastModifiedAt ? new Date(s.lastModifiedAt).toLocaleString() : "—")}
/>
{/* Owners list */}
<div className="rounded-md border p-3 col-span-1 md:col-span-2">
<div className="text-sm font-medium mb-1">Top Owners</div>
{q.isLoading && <div className="text-xs text-muted-foreground">Loading</div>}
{!q.isLoading && (s?.owners?.length ?? 0) === 0 && (
<div className="text-xs text-muted-foreground">No owner data</div>
)}
<ul className="text-sm space-y-1">
{(s?.owners ?? []).map((o) => (
<li key={o.owner} className="flex items-center justify-between">
<span className="truncate">{o.owner || "unknown"}</span>
<span className="text-muted-foreground">{o.count.toLocaleString()}</span>
</li>
))}
</ul>
</div>
<div className="rounded-md border p-3">
<div className="text-sm font-medium mb-1">Last Uploaded</div>
<div className="text-lg">
{q.isLoading ? "…" : (s?.lastUploadedAt ? new Date(s.lastUploadedAt).toLocaleString() : "—")}
</div>
</div>
</div>
);
}
function KPI({ title, value }: { title: string; value: React.ReactNode }) {
return (
<div className="rounded-md border p-3">
<div className="text-sm text-muted-foreground">{title}</div>
<div className="text-2xl font-semibold mt-1">{value}</div>
</div>
);
}

View File

@ -1,221 +0,0 @@
"use client";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { EmbeddingScatter, type EmbeddingPoint } from "@/components/qdrant/embedding-scatter";
type CollectionsResp = { collections: Array<{ name: string; points_count?: number; vectors_count?: number }> };
type PointsResp = { points: Array<{ id: string | number; payload?: Record<string, unknown>; vector?: number[] | Record<string, number> }>; next_page_offset?: string | number | null };
async function fetchCollections(): Promise<CollectionsResp> {
const res = await fetch("/api/qdrant/collections", { cache: "no-store" });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.message || `Failed to load collections (${res.status})`);
}
return (await res.json()) as CollectionsResp;
}
async function fetchPoints(collection: string, limit = 100, offset?: string | number | null, withVector = true): Promise<PointsResp> {
const url = new URL("/api/qdrant/points", window.location.origin);
url.searchParams.set("collection", collection);
url.searchParams.set("limit", String(limit));
if (offset != null) url.searchParams.set("offset", String(offset));
url.searchParams.set("withVector", String(withVector));
const res = await fetch(url.toString(), { cache: "no-store" });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.message || `Failed to load points (${res.status})`);
}
return (await res.json()) as PointsResp;
}
export function VectorPanel() {
const [selectedCollection, setSelectedCollection] = React.useState<string>("");
const [limit, setLimit] = React.useState(200);
const [withVector, setWithVector] = React.useState(true);
const [offset, setOffset] = React.useState<string | number | null>(null);
const [selection, setSelection] = React.useState<EmbeddingPoint[]>([]);
const collectionsQuery = useQuery({
queryKey: ["qdrant", "collections"],
queryFn: fetchCollections,
});
React.useEffect(() => {
const names = collectionsQuery.data?.collections?.map((c) => c.name) ?? [];
if (!selectedCollection && names.length > 0) {
const preferred =
names.find((n) => n === "fortura-db") ||
names.find((n) => n === "miguel_responses") ||
names[0];
setSelectedCollection(preferred);
}
}, [collectionsQuery.data, selectedCollection]);
const pointsQuery = useQuery({
queryKey: ["qdrant", "points", selectedCollection, limit, offset, withVector],
queryFn: () => fetchPoints(selectedCollection!, limit, offset, withVector),
enabled: !!selectedCollection,
});
const points = (pointsQuery.data?.points ?? []) as unknown as EmbeddingPoint[];
function doDrillOpenExplorer() {
// Prefer path from payload, otherwise noop
const paths =
selection
.map((p) => (p.payload?.path as string | undefined))
.filter(Boolean)
.slice(0, 1) as string[];
if (paths.length > 0) {
// Navigate to app root; this app does not yet parse query params for path navigation.
// We open a download for now as a practical drill-through.
const path = paths[0]!;
const url = new URL("/api/files/download", window.location.origin);
url.searchParams.set("path", path);
window.open(url.toString(), "_blank", "noopener,noreferrer");
} else {
alert("No path available in selected points. Please select points with payload.path.");
}
}
function doDrillSearchES() {
// Make a simple search from first payload.name or id
const first = selection[0];
const name = (first?.payload?.name as string | undefined) || String(first?.id ?? "");
const q = name ?? "";
if (!q) return;
// Navigate to root with a hint; the app currently doesn't read URL q param, so we just show info.
alert(`Search hint (manual): ${q}`);
}
return (
<div className="rounded-md border p-3 flex flex-col">
<div className="text-sm font-medium mb-2">Vector Exploration</div>
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center gap-2">
<span className="text-sm">Collection</span>
<Input
value={selectedCollection}
onChange={(e) => {
setSelectedCollection(e.target.value);
setOffset(null);
}}
className="w-56"
placeholder="collection name"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">Limit</span>
<Input
type="number"
className="w-24"
value={limit}
onChange={(e) => setLimit(Math.max(1, Math.min(1000, Number(e.target.value) || 100)))}
/>
</div>
<Button variant={withVector ? "default" : "outline"} onClick={() => setWithVector((v) => !v)}>
{withVector ? "Vectors: on" : "Vectors: off"}
</Button>
<Button
variant="secondary"
onClick={() => {
setOffset(null);
pointsQuery.refetch();
}}
disabled={!selectedCollection || pointsQuery.isFetching}
>
{pointsQuery.isFetching ? "Loading…" : "Refresh"}
</Button>
<div className="ml-auto text-xs text-muted-foreground">
Selected: {selection.length}
</div>
</div>
<div className="grid grid-cols-[280px_1fr] gap-3 min-h-[380px]">
<div className="rounded-md border">
<div className="border-b px-3 py-2 text-sm font-medium">Collections</div>
<ScrollArea className="h-[340px]">
<div className="p-2 space-y-1">
{collectionsQuery.isLoading && <div className="text-xs text-muted-foreground">Loading</div>}
{collectionsQuery.error && (
<div className="text-xs text-destructive">{(collectionsQuery.error as Error).message}</div>
)}
{(collectionsQuery.data?.collections ?? []).map((c) => (
<button
key={c.name}
className={`w-full text-left px-2 py-1 rounded hover:bg-accent ${
selectedCollection === c.name ? "bg-accent" : ""
}`}
onClick={() => {
setSelectedCollection(c.name);
setOffset(null);
}}
title={`${c.points_count ?? 0} pts`}
>
<div className="font-medium">{c.name}</div>
<div className="text-xs text-muted-foreground">
pts: {c.points_count ?? "—"} vec: {c.vectors_count ?? "—"}
</div>
</button>
))}
{(collectionsQuery.data?.collections ?? []).length === 0 && !collectionsQuery.isLoading && (
<div className="text-xs text-muted-foreground px-2">No collections</div>
)}
</div>
</ScrollArea>
</div>
<div className="rounded-md border p-3 flex flex-col">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium">Embedding Scatter</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setOffset(null)} disabled={pointsQuery.isFetching}>
Reset Offset
</Button>
<Button
size="sm"
onClick={() => setOffset(pointsQuery.data?.next_page_offset ?? null)}
disabled={pointsQuery.isFetching || !pointsQuery.data?.next_page_offset}
>
Next Page
</Button>
</div>
</div>
<div className="flex-1 min-h-[360px]">
{pointsQuery.isLoading ? (
<div className="text-xs text-muted-foreground">Loading</div>
) : (
<EmbeddingScatter
points={points}
onSelect={(sel) => setSelection(sel)}
onOpenPoint={(p) => {
const path = (p.payload?.path as string | undefined) ?? "";
if (path) {
const url = new URL("/api/files/download", window.location.origin);
url.searchParams.set("path", path);
window.open(url.toString(), "_blank", "noopener,noreferrer");
}
}}
/>
)}
</div>
<div className="mt-2 flex items-center gap-2">
<Button size="sm" onClick={doDrillOpenExplorer} disabled={selection.length === 0}>
Drill: Open Explorer
</Button>
<Button size="sm" variant="outline" onClick={doDrillSearchES} disabled={selection.length === 0}>
Drill: Search in ES
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,83 @@
"use client";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { SignInDialog } from "@/components/auth/signin-dialog";
import { TermsModal } from "@/components/onboarding/terms-modal";
type SessionResp =
| { authenticated: false }
| { authenticated: true; username: string; createdAt: number };
type TermsResp = { accepted: boolean; version: string };
async function fetchJSON<T>(url: string): Promise<T> {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
// For session/terms status endpoints, treat errors as unauthenticated/unaccepted to keep UX flowing
try {
const data = (await res.json()) as unknown as T;
return data;
} catch {
throw new Error(`${url} failed (${res.status})`);
}
}
return (await res.json()) as T;
}
/**
* OnboardingGate:
* - Shows SignInDialog when unauthenticated
* - After authenticated, shows TermsModal until current TERMS_VERSION is accepted
* The Radix Dialog overlay blocks interaction with the underlying UI, enforcing the gate.
*/
export function OnboardingGate({ children }: { children: React.ReactNode }) {
// Poll session and terms with low-frequency revalidation (user-driven events will invalidate)
const { data: session } = useQuery<SessionResp>({
queryKey: ["auth", "session"],
queryFn: () => fetchJSON<SessionResp>("/api/auth/session"),
staleTime: 60_000,
});
const { data: terms } = useQuery<TermsResp>({
queryKey: ["terms", "status"],
queryFn: () => fetchJSON<TermsResp>("/api/terms/status"),
// Don't fetch terms until we know auth status; if unauthenticated, terms aren't relevant
enabled: session?.authenticated === true,
staleTime: 60_000,
});
// Dialog open states are derived from API responses, but we allow them to be controlled to prevent flicker
const [signinOpen, setSigninOpen] = React.useState(false);
const [termsOpen, setTermsOpen] = React.useState(false);
React.useEffect(() => {
// If explicitly unauthenticated, force sign-in open
if (session && session.authenticated === false) {
setSigninOpen(true);
} else {
setSigninOpen(false);
}
}, [session]);
React.useEffect(() => {
// Open terms modal only when authenticated and not accepted
if (session?.authenticated === true && terms && !terms.accepted) {
setTermsOpen(true);
} else {
setTermsOpen(false);
}
}, [session, terms]);
const version = terms?.version;
const canRenderApp = session?.authenticated === true && !!terms && terms.accepted;
return (
<>
{canRenderApp ? children : null}
<SignInDialog open={signinOpen} onOpenChange={setSigninOpen} />
<TermsModal open={termsOpen} onOpenChange={setTermsOpen} version={version} />
</>
);
}

View File

@ -0,0 +1,81 @@
"use client";
import * as React from "react";
import * as Sentry from "@sentry/nextjs";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
version?: string;
};
export function TermsModal({ open, onOpenChange, version }: Props) {
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: async () => {
return Sentry.startSpan(
{ op: "ui.click", name: "terms.accept" },
async () => {
const res = await fetch("/api/terms/accept", { method: "POST" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const msg =
typeof data?.message === "string"
? data.message
: `Failed to accept terms (${res.status})`;
throw new Error(msg);
}
return res.json();
},
);
},
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["terms", "status"] });
onOpenChange(false);
},
onError: (err) => {
Sentry.captureException(err);
},
});
return (
<Dialog open={open} onOpenChange={(o) => !mutation.isPending && onOpenChange(o)}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Terms & Conditions {version ? `(${version})` : ""}</DialogTitle>
<DialogDescription>
To continue, you must accept the Terms & Conditions for this application.
</DialogDescription>
</DialogHeader>
<div className="prose prose-sm dark:prose-invert max-h-[40vh] overflow-auto border rounded-md p-3">
<p className="text-sm">
By using this application, you agree to the processing of your files and metadata through
your configured Nextcloud instance and the connected services (Elasticsearch, Qdrant, etc.)
in accordance with your organization's policies. Do not upload sensitive information
unless you have the proper authorization. Your actions may be logged for diagnostic and
security purposes.
</p>
<p className="text-sm">
You also acknowledge that some features (e.g., semantic search, "Why this result")
may call external APIs when enabled by configuration.
</p>
</div>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="default"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
{mutation.isPending ? "Accepting..." : "I Accept"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,290 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import type { estypes } from "@elastic/elasticsearch";
import { getEsClient } from "@/lib/elasticsearch";
import { env } from "@/lib/env";
import type {
AnalyticsSummary,
FileTypesResponse,
ActivitySeries,
TagsResponse,
} from "@/types/analytics";
/**
* Elasticsearch aggregations for dashboard analytics.
* Uses the alias if configured, otherwise the base index name.
*/
function indexName() {
return env.ELASTICSEARCH_ALIAS || env.ELASTICSEARCH_INDEX;
}
/**
* Summary:
* - totalFiles: value_count over id or doc_count if strict index
* - totalSizeBytes: sum(sizeBytes)
* - uniqueTags: cardinality(tags)
* - lastModifiedAt: max(modifiedAt)
* - lastUploadedAt: max(uploadedAt)
* - owners: top owners (terms)
*/
export async function getSummary(opts?: { ownersLimit?: number }): Promise<AnalyticsSummary> {
return Sentry.startSpan({ op: "db.query", name: "ES analytics.getSummary" }, async (span) => {
const client = getEsClient();
const ownersLimit = Math.min(50, Math.max(1, opts?.ownersLimit ?? 10));
const res = await client.search({
index: indexName(),
size: 0,
aggs: {
totalFiles: { value_count: { field: "id" } },
totalSizeBytes: { sum: { field: "sizeBytes" } },
uniqueTags: { cardinality: { field: "tags" } },
lastModifiedAt: { max: { field: "modifiedAt" } },
lastUploadedAt: { max: { field: "uploadedAt" } },
owners: {
terms: {
field: "owner",
size: ownersLimit,
order: { _count: "desc" },
},
},
},
});
type OwnersBucket = { key: string; doc_count: number };
type SummaryAggs = {
totalFiles?: { value?: number };
totalSizeBytes?: { value?: number };
uniqueTags?: { value?: number };
lastModifiedAt?: { value_as_string?: string };
lastUploadedAt?: { value_as_string?: string };
owners?: { buckets?: OwnersBucket[] };
};
const aggs = (res.aggregations as unknown) as SummaryAggs;
const ownersBuckets: OwnersBucket[] = aggs?.owners?.buckets ?? [];
const summary: AnalyticsSummary = {
totalFiles:
typeof aggs?.totalFiles?.value === "number"
? aggs.totalFiles.value
: (res.hits.total as any)?.value ?? 0,
totalSizeBytes: Math.round(Number(aggs?.totalSizeBytes?.value || 0)),
uniqueTags: Number(aggs?.uniqueTags?.value || 0),
lastModifiedAt: aggs?.lastModifiedAt?.value_as_string,
lastUploadedAt: aggs?.lastUploadedAt?.value_as_string,
owners: ownersBuckets.map((b) => ({ owner: String(b.key), count: b.doc_count })),
};
span.setAttribute("owners.count", summary.owners?.length ?? 0);
return summary;
});
}
/**
* File Types:
* - terms aggregation on mimeType
* - optional sum of sizeBytes can be included if needed later
*/
export async function getFileTypes(opts?: {
limit?: number;
withSize?: boolean;
}): Promise<FileTypesResponse> {
return Sentry.startSpan({ op: "db.query", name: "ES analytics.getFileTypes" }, async (span) => {
const client = getEsClient();
const limit = Math.min(100, Math.max(1, opts?.limit ?? 20));
const withSize = !!opts?.withSize;
const aggs: Record<string, estypes.AggregationsAggregationContainer> = {
fileTypes: {
terms: {
field: "mimeType",
size: limit,
order: { _count: "desc" },
},
aggs: withSize
? {
totalSize: { sum: { field: "sizeBytes" } },
}
: undefined,
},
};
const res = await client.search({
index: indexName(),
size: 0,
aggs,
});
type FileTypesAggs = {
fileTypes?: {
buckets?: Array<{ key: string; doc_count: number; totalSize?: { value?: number } }>;
};
};
const agg = (res.aggregations as unknown) as FileTypesAggs;
const buckets =
agg.fileTypes?.buckets ?? [];
const data = {
buckets: buckets.map((b) => ({
mimeType: b.key,
count: b.doc_count,
sizeBytes: withSize ? Math.round(Number(b.totalSize?.value || 0)) : undefined,
})),
total:
typeof res.hits.total === "number"
? (res.hits.total as number)
: (res.hits.total as any)?.value ?? 0,
};
span.setAttribute("filetypes.count", data.buckets.length);
return data;
});
}
/**
* Activity:
* - date histogram on uploadedAt and modifiedAt
* - returns daily series
*/
export async function getActivity(opts?: {
from?: string; // ISO
to?: string; // ISO
interval?: "day" | "week" | "month";
}): Promise<ActivitySeries> {
return Sentry.startSpan({ op: "db.query", name: "ES analytics.getActivity" }, async (span) => {
const client = getEsClient();
const interval = opts?.interval ?? "day";
const makeDateHist = (field: string) => ({
date_histogram: {
field,
fixed_interval:
interval === "month" ? "30d" : interval === "week" ? "7d" : "1d",
min_doc_count: 0,
...(opts?.from || opts?.to
? {
extended_bounds: {
min: opts.from,
max: opts.to,
},
}
: {}),
},
});
const res = await client.search({
index: indexName(),
size: 0,
query:
opts?.from || opts?.to
? {
bool: {
should: [
{
range: {
uploadedAt: {
gte: opts.from,
lte: opts.to,
},
},
},
{
range: {
modifiedAt: {
gte: opts.from,
lte: opts.to,
},
},
},
],
minimum_should_match: 1,
},
}
: { match_all: {} },
aggs: {
uploaded: makeDateHist("uploadedAt"),
modified: makeDateHist("modifiedAt"),
},
});
type HistBucket = { key_as_string: string; doc_count: number };
type ActivityAggs = {
uploaded?: { buckets?: HistBucket[] };
modified?: { buckets?: HistBucket[] };
};
const a = (res.aggregations as unknown) as ActivityAggs;
const upBuckets: HistBucket[] = a.uploaded?.buckets ?? [];
const modBuckets: HistBucket[] = a.modified?.buckets ?? [];
// Merge series by date string
const map = new Map<
string,
{
date: string;
uploaded?: number;
modified?: number;
}
>();
for (const b of upBuckets) {
map.set(b.key_as_string, { date: b.key_as_string, uploaded: b.doc_count });
}
for (const b of modBuckets) {
const prev = map.get(b.key_as_string) || { date: b.key_as_string };
prev.modified = b.doc_count;
map.set(b.key_as_string, prev);
}
const points = Array.from(map.values()).sort((a, b) =>
a.date.localeCompare(b.date),
);
const series: ActivitySeries = { points };
span.setAttribute("activity.points", points.length);
return series;
});
}
/**
* Top Tags:
* - terms aggregation on tags
*/
export async function getTopTags(opts?: { limit?: number }): Promise<TagsResponse> {
return Sentry.startSpan({ op: "db.query", name: "ES analytics.getTopTags" }, async (span) => {
const client = getEsClient();
const limit = Math.min(200, Math.max(1, opts?.limit ?? 20));
const res = await client.search({
index: indexName(),
size: 0,
aggs: {
tags: {
terms: {
field: "tags",
size: limit,
order: { _count: "desc" },
},
},
},
});
type TagsAggs = { tags?: { buckets?: Array<{ key: string; doc_count: number }> } };
const t = (res.aggregations as unknown) as TagsAggs;
const buckets = t.tags?.buckets ?? [];
const data: TagsResponse = {
buckets: buckets.map((b) => ({ tag: b.key, count: b.doc_count })),
total:
typeof res.hits.total === "number"
? (res.hits.total as number)
: (res.hits.total as any)?.value ?? 0,
};
span.setAttribute("tags.count", data.buckets.length);
return data;
});
}

View File

@ -0,0 +1,53 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
import { readAuth } from "@/lib/session";
import { createNextcloudClient, NextcloudClient } from "@/lib/webdav";
/**
* Error type used to signal unauthenticated access from helpers.
* Route handlers can catch this and return 401.
*/
export class UnauthorizedError extends Error {
status = 401 as const;
code = "UNAUTHENTICATED" as const;
constructor(message = "Authentication required") {
super(message);
this.name = "UnauthorizedError";
}
}
/**
* Build a NextcloudClient from the current user's session.
* Throws UnauthorizedError (401) if no session is present.
*/
export async function requireClient(req: Request): Promise<NextcloudClient> {
return Sentry.startSpan(
{ op: "function", name: "requireClient" },
async (span) => {
const auth = await readAuth(req);
if (!auth) {
span.setAttribute("authenticated", false);
throw new UnauthorizedError();
}
span.setAttribute("authenticated", true);
span.setAttribute("username", auth.username);
// Never attach appPassword to the span
const client = createNextcloudClient({
baseUrl: auth.baseUrl,
username: auth.username,
appPassword: auth.appPassword,
});
return client;
},
);
}
/**
* Convenience helper to return a standardized 401 response.
*/
export function unauthorizedJson() {
return NextResponse.json(
{ error: "UNAUTHENTICATED", message: "Sign-in required" },
{ status: 401 },
);
}

85
src/lib/session.ts Normal file
View File

@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import { getIronSession, type IronSession, type SessionOptions } from "iron-session";
/**
* Auth session payload stored server-side in iron-session.
* Never expose appPassword to the client.
*/
export type AuthSession = {
baseUrl: string;
username: string;
appPassword: string;
createdAt: number; // epoch ms
};
/**
* Build iron-session options from env. We intentionally read process.env
* directly here to avoid making SESSION_* required in the global env parser
* until onboarding is wired. We validate at the point of use.
*/
function getSessionOptions(): SessionOptions {
const cookieName = process.env.SESSION_COOKIE_NAME || "ncx_session";
const password = process.env.SESSION_PASSWORD;
if (!password || password.length < 32) {
throw new Error(
"SESSION_PASSWORD is missing or too short (min 32 chars). Set it in .env.local",
);
}
return {
cookieName,
password,
ttl: 60 * 60 * 24 * 7, // 7 days
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
},
};
}
/**
* Get the iron session bound to this request/response. Use this in route handlers:
*
* const res = NextResponse.json({}, { status: 200 });
* const session = await getSession(req, res);
* session.auth = { ... };
* await session.save();
* return res;
*/
export async function getSession(
req: Request,
res: NextResponse,
): Promise<IronSession<{ auth?: AuthSession }>> {
return getIronSession(req, res, getSessionOptions());
}
/**
* Read-only convenience helper. Creates a throwaway response container in case
* iron-session needs to touch cookie headers. Do not use this if you intend
* to modify the session; use getSession(req, res) instead.
*/
export async function readAuth(req: Request): Promise<AuthSession | null> {
const res = new NextResponse();
try {
const session = await getSession(req, res);
return session.auth ?? null;
} catch {
return null;
}
}
/**
* Destroy the session and clear the cookie on the provided response.
* Usage:
* const res = NextResponse.json({ ok: true });
* const session = await getSession(req, res);
* await destroySession(session);
* return res;
*/
export async function destroySession(
session: IronSession<{ auth?: AuthSession }>,
) {
await session.destroy();
}

50
src/lib/terms.ts Normal file
View File

@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
/**
* Versioned Terms & Conditions helpers using an HTTP-only cookie.
* - Cookie stores only the accepted version string.
* - Server-only usage for setting/reading the cookie in route handlers/layout.
*/
export const TERMS_COOKIE = "tc_version";
export function getTermsVersion(): string {
return process.env.TERMS_VERSION || "v1";
}
/**
* Check whether the incoming Request has accepted the current Terms version.
* Safe for use in Route Handlers where you only have a Request object.
*/
export function getTermsAccepted(req: Request): boolean {
const cookieHeader = req.headers.get("cookie") || "";
const cookies = Object.fromEntries(
cookieHeader
.split(/;\s*/)
.filter(Boolean)
.map((pair) => {
const idx = pair.indexOf("=");
if (idx === -1) return [pair, ""];
const k = decodeURIComponent(pair.slice(0, idx).trim());
const v = decodeURIComponent(pair.slice(idx + 1).trim());
return [k, v];
}),
);
const val = cookies[TERMS_COOKIE];
return typeof val === "string" && val === getTermsVersion();
}
/**
* Set the Terms cookie on the provided NextResponse to the current version.
* - httpOnly, sameSite=lax, path=/, secure in production, ~365 days.
*/
export function acceptTerms(res: NextResponse): void {
const version = getTermsVersion();
res.cookies.set(TERMS_COOKIE, version, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365, // 1 year
});
}

View File

@ -7,11 +7,19 @@ import type { WebDavEntry } from "@/types/files";
/**
* Nextcloud WebDAV wrapper with Sentry spans.
* Uses credentials from env and talks directly to the Nextcloud WebDAV endpoint.
* - Default singleton uses credentials from env (scripts, legacy).
* - Supports per-session clients via constructor auth override.
*/
type Credentials = {
baseUrl: string;
username: string;
appPassword: string;
};
let _client: WebDAVClient | null = null;
function getClient(): WebDAVClient {
function getEnvClient(): WebDAVClient {
if (_client) return _client;
_client = createClient(env.NEXTCLOUD_BASE_URL, {
username: env.NEXTCLOUD_USERNAME,
@ -86,9 +94,24 @@ function mapStatToEntry(stat: FileStat): WebDavEntry {
export class NextcloudClient {
private rootPath: string;
private client: WebDAVClient;
constructor(rootPath: string = env.NEXTCLOUD_ROOT_PATH) {
/**
* Create a NextcloudClient.
* - If auth is provided, use per-session credentials.
* - Else fallback to env-based singleton client.
*/
constructor(
rootPath: string = env.NEXTCLOUD_ROOT_PATH,
auth?: Credentials,
) {
this.rootPath = normalizePath(rootPath);
this.client = auth
? createClient(auth.baseUrl, {
username: auth.username,
password: auth.appPassword,
})
: getEnvClient();
}
/**
@ -107,8 +130,7 @@ export class NextcloudClient {
async (span) => {
const target = this.resolve(path);
span.setAttribute("path", target);
const client = getClient();
const stats = (await client.getDirectoryContents(target)) as FileStat[];
const stats = (await this.client.getDirectoryContents(target)) as FileStat[];
return stats.map(mapStatToEntry);
},
);
@ -120,8 +142,7 @@ export class NextcloudClient {
async (span) => {
const target = this.resolve(path);
span.setAttribute("path", target);
const client = getClient();
await client.createDirectory(target);
await this.client.createDirectory(target);
// ETag not typically returned by createDirectory in webdav client
return { ok: true };
},
@ -139,9 +160,8 @@ export class NextcloudClient {
const target = this.resolve(destPath);
span.setAttribute("path", target);
if (contentType) span.setAttribute("contentType", contentType);
const client = getClient();
const data = await toWebdavData(file);
await client.putFileContents(target, data, { overwrite: true });
await this.client.putFileContents(target, data, { overwrite: true });
return { ok: true };
},
);
@ -153,8 +173,7 @@ export class NextcloudClient {
async (span) => {
const target = this.resolve(path);
span.setAttribute("path", target);
const client = getClient();
const stream = client.createReadStream(target) as unknown as NodeJS.ReadableStream;
const stream = this.client.createReadStream(target) as unknown as NodeJS.ReadableStream;
return stream;
},
);
@ -166,9 +185,8 @@ export class NextcloudClient {
async (span) => {
const target = this.resolve(path);
span.setAttribute("path", target);
const client = getClient();
try {
const s = (await client.stat(target)) as FileStat;
const s = (await this.client.stat(target)) as FileStat;
return mapStatToEntry(s);
} catch (err) {
// 404 -> not found
@ -187,8 +205,7 @@ export class NextcloudClient {
const dst = this.resolve(to);
span.setAttribute("path_from", src);
span.setAttribute("path_to", dst);
const client = getClient();
await client.moveFile(src, dst, { overwrite: true });
await this.client.moveFile(src, dst, { overwrite: true });
return { ok: true, from: src, to: dst };
},
);
@ -202,8 +219,7 @@ export class NextcloudClient {
const dst = this.resolve(to);
span.setAttribute("path_from", src);
span.setAttribute("path_to", dst);
const client = getClient();
await client.copyFile(src, dst, { overwrite: true });
await this.client.copyFile(src, dst, { overwrite: true });
return { ok: true, from: src, to: dst };
},
);
@ -215,14 +231,13 @@ export class NextcloudClient {
async (span) => {
const target = this.resolve(path);
span.setAttribute("path", target);
const client = getClient();
try {
// Determine if directory or file for the correct delete call
const s = (await client.stat(target)) as FileStat;
const s = (await this.client.stat(target)) as FileStat;
if (s.type === "directory") {
await client.deleteFile(target); // webdav client uses deleteFile for both in many versions
await this.client.deleteFile(target); // webdav client uses deleteFile for both in many versions
} else {
await client.deleteFile(target);
await this.client.deleteFile(target);
}
return { ok: true };
} catch (err) {
@ -239,8 +254,7 @@ export class NextcloudClient {
async (span) => {
const target = this.resolve(path);
span.setAttribute("path", target);
const client = getClient();
const text = (await client.getFileContents(target, { format: "text" })) as unknown as string;
const text = (await this.client.getFileContents(target, { format: "text" })) as unknown as string;
const s = await this.stat(target);
return { ok: true, content: text, mimeType: s?.contentType || "text/plain" };
},
@ -254,12 +268,25 @@ export class NextcloudClient {
const target = this.resolve(path);
span.setAttribute("path", target);
if (mimeType) span.setAttribute("contentType", mimeType);
const client = getClient();
await client.putFileContents(target, content, { overwrite: true });
await this.client.putFileContents(target, content, { overwrite: true });
return { ok: true };
},
);
}
}
/**
* Factory to build a per-session Nextcloud client.
*/
export function createNextcloudClient(
auth: Credentials,
rootPath?: string,
) {
const rp = rootPath ? normalizePath(rootPath) : env.NEXTCLOUD_ROOT_PATH;
return new NextcloudClient(rp, auth);
}
/**
* Legacy/env-based singleton instance (for scripts, etc.)
*/
export const nextcloud = new NextcloudClient();

View File

@ -1,49 +0,0 @@
export type AnalyticsSummary = {
totalFiles: number;
totalSizeBytes: number;
uniqueTags: number;
lastModifiedAt?: string;
lastUploadedAt?: string;
owners?: Array<{ owner: string; count: number }>;
};
export type FileTypeBucket = {
mimeType: string;
count: number;
sizeBytes?: number;
};
export type FileTypesResponse = {
buckets: FileTypeBucket[];
total: number;
};
export type ActivityPoint = {
date: string; // ISO date (day resolution)
uploaded?: number;
modified?: number;
};
export type ActivitySeries = {
points: ActivityPoint[];
};
export type TagBucket = {
tag: string;
count: number;
};
export type TagsResponse = {
buckets: TagBucket[];
total: number;
};
export type VectorSelection = {
ids: Array<string | number>;
count: number;
};
export type DrillTarget = {
path: string;
id?: string;
};