From 34e8b6389ff250af4242f3cde00e85c650ac6712 Mon Sep 17 00:00:00 2001 From: nicholai Date: Sat, 13 Sep 2025 12:33:37 -0600 Subject: [PATCH] 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 --- package-lock.json | 315 +++--------------- package.json | 2 +- src/app/api/analytics/activity/route.ts | 36 -- src/app/api/analytics/file-types/route.ts | 34 -- src/app/api/analytics/summary/route.ts | 32 -- src/app/api/analytics/tags/route.ts | 32 -- src/app/api/auth/session/route.ts | 42 +++ src/app/api/auth/signin/route.ts | 121 +++++++ src/app/api/auth/signout/route.ts | 33 ++ src/app/api/files/content/route.ts | 22 +- src/app/api/files/copy/route.ts | 12 +- src/app/api/files/delete/route.ts | 12 +- src/app/api/files/download/route.ts | 14 +- src/app/api/files/list/route.ts | 12 +- src/app/api/files/rename/route.ts | 12 +- src/app/api/files/tags/history/route.ts | 7 + src/app/api/files/tags/route.ts | 8 + src/app/api/files/upload/route.ts | 12 +- src/app/api/folders/create/route.ts | 12 +- src/app/api/folders/list/route.ts | 12 +- src/app/api/terms/accept/route.ts | 34 ++ src/app/api/terms/status/route.ts | 35 ++ src/app/dashboard/page.tsx | 51 --- src/app/layout.tsx | 5 +- src/components/auth/signin-dialog.tsx | 133 ++++++++ .../dashboard/charts/activity-chart.tsx | 93 ------ .../dashboard/charts/file-types-chart.tsx | 68 ---- src/components/dashboard/charts/top-tags.tsx | 64 ---- src/components/dashboard/kpis.tsx | 77 ----- src/components/dashboard/vector-panel.tsx | 221 ------------ src/components/onboarding/onboarding-gate.tsx | 83 +++++ src/components/onboarding/terms-modal.tsx | 81 +++++ src/lib/analytics.ts | 290 ---------------- src/lib/nextcloud-session.ts | 53 +++ src/lib/session.ts | 85 +++++ src/lib/terms.ts | 50 +++ src/lib/webdav.ts | 77 +++-- src/types/analytics.ts | 49 --- 38 files changed, 962 insertions(+), 1369 deletions(-) delete mode 100644 src/app/api/analytics/activity/route.ts delete mode 100644 src/app/api/analytics/file-types/route.ts delete mode 100644 src/app/api/analytics/summary/route.ts delete mode 100644 src/app/api/analytics/tags/route.ts create mode 100644 src/app/api/auth/session/route.ts create mode 100644 src/app/api/auth/signin/route.ts create mode 100644 src/app/api/auth/signout/route.ts create mode 100644 src/app/api/terms/accept/route.ts create mode 100644 src/app/api/terms/status/route.ts delete mode 100644 src/app/dashboard/page.tsx create mode 100644 src/components/auth/signin-dialog.tsx delete mode 100644 src/components/dashboard/charts/activity-chart.tsx delete mode 100644 src/components/dashboard/charts/file-types-chart.tsx delete mode 100644 src/components/dashboard/charts/top-tags.tsx delete mode 100644 src/components/dashboard/kpis.tsx delete mode 100644 src/components/dashboard/vector-panel.tsx create mode 100644 src/components/onboarding/onboarding-gate.tsx create mode 100644 src/components/onboarding/terms-modal.tsx delete mode 100644 src/lib/analytics.ts create mode 100644 src/lib/nextcloud-session.ts create mode 100644 src/lib/session.ts create mode 100644 src/lib/terms.ts delete mode 100644 src/types/analytics.ts diff --git a/package-lock.json b/package-lock.json index f1c9736..4c554a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2ae4bf7..e807ee5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/analytics/activity/route.ts b/src/app/api/analytics/activity/route.ts deleted file mode 100644 index 2399b48..0000000 --- a/src/app/api/analytics/activity/route.ts +++ /dev/null @@ -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(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 }); - } -} diff --git a/src/app/api/analytics/file-types/route.ts b/src/app/api/analytics/file-types/route.ts deleted file mode 100644 index f7604b0..0000000 --- a/src/app/api/analytics/file-types/route.ts +++ /dev/null @@ -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(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 }); - } -} diff --git a/src/app/api/analytics/summary/route.ts b/src/app/api/analytics/summary/route.ts deleted file mode 100644 index 9b9a127..0000000 --- a/src/app/api/analytics/summary/route.ts +++ /dev/null @@ -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(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 }); - } -} diff --git a/src/app/api/analytics/tags/route.ts b/src/app/api/analytics/tags/route.ts deleted file mode 100644 index d81042f..0000000 --- a/src/app/api/analytics/tags/route.ts +++ /dev/null @@ -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(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 }); - } -} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..d81569d --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -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(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 }, + ); + } +} diff --git a/src/app/api/auth/signin/route.ts b/src/app/api/auth/signin/route.ts new file mode 100644 index 0000000..9ebf2d9 --- /dev/null +++ b/src/app/api/auth/signin/route.ts @@ -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(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 }, + ); + } +} diff --git a/src/app/api/auth/signout/route.ts b/src/app/api/auth/signout/route.ts new file mode 100644 index 0000000..45e76a0 --- /dev/null +++ b/src/app/api/auth/signout/route.ts @@ -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(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 }, + ); + } +} diff --git a/src/app/api/files/content/route.ts b/src/app/api/files/content/route.ts index 4912c48..bdbfe8c 100644 --- a/src/app/api/files/content/route.ts +++ b/src/app/api/files/content/route.ts @@ -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; }, ); diff --git a/src/app/api/files/copy/route.ts b/src/app/api/files/copy/route.ts index 84e07bf..3be01a4 100644 --- a/src/app/api/files/copy/route.ts +++ b/src/app/api/files/copy/route.ts @@ -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; }, ); diff --git a/src/app/api/files/delete/route.ts b/src/app/api/files/delete/route.ts index b7b9612..b69f4c1 100644 --- a/src/app/api/files/delete/route.ts +++ b/src/app/api/files/delete/route.ts @@ -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; }, ); diff --git a/src/app/api/files/download/route.ts b/src/app/api/files/download/route.ts index 97138bb..4644e35 100644 --- a/src/app/api/files/download/route.ts +++ b/src/app/api/files/download/route.ts @@ -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; diff --git a/src/app/api/files/list/route.ts b/src/app/api/files/list/route.ts index 1b9fb0b..f963f65 100644 --- a/src/app/api/files/list/route.ts +++ b/src/app/api/files/list/route.ts @@ -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) => ({ diff --git a/src/app/api/files/rename/route.ts b/src/app/api/files/rename/route.ts index 063d4e1..9c21b11 100644 --- a/src/app/api/files/rename/route.ts +++ b/src/app/api/files/rename/route.ts @@ -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; }, ); diff --git a/src/app/api/files/tags/history/route.ts b/src/app/api/files/tags/history/route.ts index 90e169d..6ebc9e7 100644 --- a/src/app/api/files/tags/history/route.ts +++ b/src/app/api/files/tags/history/route.ts @@ -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) => { diff --git a/src/app/api/files/tags/route.ts b/src/app/api/files/tags/route.ts index 84ac907..b70f6f9 100644 --- a/src/app/api/files/tags/route.ts +++ b/src/app/api/files/tags/route.ts @@ -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"; diff --git a/src/app/api/files/upload/route.ts b/src/app/api/files/upload/route.ts index 9bc34d8..9e98cb1 100644 --- a/src/app/api/files/upload/route.ts +++ b/src/app/api/files/upload/route.ts @@ -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, diff --git a/src/app/api/folders/create/route.ts b/src/app/api/folders/create/route.ts index de26c6b..9e2062e 100644 --- a/src/app/api/folders/create/route.ts +++ b/src/app/api/folders/create/route.ts @@ -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; }, ); diff --git a/src/app/api/folders/list/route.ts b/src/app/api/folders/list/route.ts index 15f407b..b5932fb 100644 --- a/src/app/api/folders/list/route.ts +++ b/src/app/api/folders/list/route.ts @@ -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) diff --git a/src/app/api/terms/accept/route.ts b/src/app/api/terms/accept/route.ts new file mode 100644 index 0000000..59f2a42 --- /dev/null +++ b/src/app/api/terms/accept/route.ts @@ -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(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 }, + ); + } +} diff --git a/src/app/api/terms/status/route.ts b/src/app/api/terms/status/route.ts new file mode 100644 index 0000000..1a3934a --- /dev/null +++ b/src/app/api/terms/status/route.ts @@ -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(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 }, + ); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx deleted file mode 100644 index 843190a..0000000 --- a/src/app/dashboard/page.tsx +++ /dev/null @@ -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 ( -
-
-
Dashboard
-
- -
-
- -
-
- {/* KPIs */} - - - {/* Charts row */} -
- - -
- - {/* Tags + Vector Panel */} -
- - -
-
-
-
- ); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 889aee0..a7716ca 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ - {children} + + {children} + diff --git a/src/components/auth/signin-dialog.tsx b/src/components/auth/signin-dialog.tsx new file mode 100644 index 0000000..692d7e6 --- /dev/null +++ b/src/components/auth/signin-dialog.tsx @@ -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(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 ( + + + + Sign in to Nextcloud + + Enter your Nextcloud Base URL, username, and app password. Your credentials are stored securely in a server-side session. + + + +
+ + + + + + + {error ? ( +

{error}

+ ) : null} + +
+ +
+
+
+
+ ); +} diff --git a/src/components/dashboard/charts/activity-chart.tsx b/src/components/dashboard/charts/activity-chart.tsx deleted file mode 100644 index c6e4b13..0000000 --- a/src/components/dashboard/charts/activity-chart.tsx +++ /dev/null @@ -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 { - 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 ( -
-
Activity
-
- {q.isLoading ? ( -
Loading…
- ) : data.length === 0 ? ( -
No data
- ) : ( - - - - - - - - - - - - )} -
-
- ); -} diff --git a/src/components/dashboard/charts/file-types-chart.tsx b/src/components/dashboard/charts/file-types-chart.tsx deleted file mode 100644 index 11d4550..0000000 --- a/src/components/dashboard/charts/file-types-chart.tsx +++ /dev/null @@ -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 { - 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 ( -
-
File Types
-
- {q.isLoading ? ( -
Loading…
- ) : data.length === 0 ? ( -
No data
- ) : ( - - - - - - - - - - )} -
-
- ); -} diff --git a/src/components/dashboard/charts/top-tags.tsx b/src/components/dashboard/charts/top-tags.tsx deleted file mode 100644 index 0b9a95c..0000000 --- a/src/components/dashboard/charts/top-tags.tsx +++ /dev/null @@ -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 { - 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 ( -
-
Top Tags
- -
- {q.isLoading &&
Loading…
} - {!q.isLoading && data.length === 0 && ( -
No tags
- )} - {data.map((t) => ( -
-
{t.tag || "(empty)"}
-
-
{t.count.toLocaleString()}
- -
-
- ))} -
-
-
- ); -} diff --git a/src/components/dashboard/kpis.tsx b/src/components/dashboard/kpis.tsx deleted file mode 100644 index a932931..0000000 --- a/src/components/dashboard/kpis.tsx +++ /dev/null @@ -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 { - 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 ( -
- - - - - {/* Owners list */} -
-
Top Owners
- {q.isLoading &&
Loading…
} - {!q.isLoading && (s?.owners?.length ?? 0) === 0 && ( -
No owner data
- )} -
    - {(s?.owners ?? []).map((o) => ( -
  • - {o.owner || "unknown"} - {o.count.toLocaleString()} -
  • - ))} -
-
-
-
Last Uploaded
-
- {q.isLoading ? "…" : (s?.lastUploadedAt ? new Date(s.lastUploadedAt).toLocaleString() : "—")} -
-
-
- ); -} - -function KPI({ title, value }: { title: string; value: React.ReactNode }) { - return ( -
-
{title}
-
{value}
-
- ); -} diff --git a/src/components/dashboard/vector-panel.tsx b/src/components/dashboard/vector-panel.tsx deleted file mode 100644 index 79b70d0..0000000 --- a/src/components/dashboard/vector-panel.tsx +++ /dev/null @@ -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; vector?: number[] | Record }>; next_page_offset?: string | number | null }; - -async function fetchCollections(): Promise { - 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 { - 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(""); - const [limit, setLimit] = React.useState(200); - const [withVector, setWithVector] = React.useState(true); - const [offset, setOffset] = React.useState(null); - const [selection, setSelection] = React.useState([]); - - 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 ( -
-
Vector Exploration
- -
-
- Collection - { - setSelectedCollection(e.target.value); - setOffset(null); - }} - className="w-56" - placeholder="collection name" - /> -
-
- Limit - setLimit(Math.max(1, Math.min(1000, Number(e.target.value) || 100)))} - /> -
- - -
- Selected: {selection.length} -
-
- -
-
-
Collections
- -
- {collectionsQuery.isLoading &&
Loading…
} - {collectionsQuery.error && ( -
{(collectionsQuery.error as Error).message}
- )} - {(collectionsQuery.data?.collections ?? []).map((c) => ( - - ))} - {(collectionsQuery.data?.collections ?? []).length === 0 && !collectionsQuery.isLoading && ( -
No collections
- )} -
-
-
- -
-
-
Embedding Scatter
-
- - -
-
-
- {pointsQuery.isLoading ? ( -
Loading…
- ) : ( - 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"); - } - }} - /> - )} -
-
- - -
-
-
-
- ); -} diff --git a/src/components/onboarding/onboarding-gate.tsx b/src/components/onboarding/onboarding-gate.tsx new file mode 100644 index 0000000..9ce41fe --- /dev/null +++ b/src/components/onboarding/onboarding-gate.tsx @@ -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(url: string): Promise { + 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({ + queryKey: ["auth", "session"], + queryFn: () => fetchJSON("/api/auth/session"), + staleTime: 60_000, + }); + + const { data: terms } = useQuery({ + queryKey: ["terms", "status"], + queryFn: () => fetchJSON("/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} + + + + ); +} diff --git a/src/components/onboarding/terms-modal.tsx b/src/components/onboarding/terms-modal.tsx new file mode 100644 index 0000000..5ccd3c7 --- /dev/null +++ b/src/components/onboarding/terms-modal.tsx @@ -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 ( + !mutation.isPending && onOpenChange(o)}> + + + Terms & Conditions {version ? `(${version})` : ""} + + To continue, you must accept the Terms & Conditions for this application. + + + +
+

+ 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. +

+

+ You also acknowledge that some features (e.g., semantic search, "Why this result") + may call external APIs when enabled by configuration. +

+
+ +
+ +
+
+
+ ); +} diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts deleted file mode 100644 index f254f50..0000000 --- a/src/lib/analytics.ts +++ /dev/null @@ -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 { - 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 { - 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 = { - 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 { - 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 { - 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; - }); -} diff --git a/src/lib/nextcloud-session.ts b/src/lib/nextcloud-session.ts new file mode 100644 index 0000000..3995837 --- /dev/null +++ b/src/lib/nextcloud-session.ts @@ -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 { + 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 }, + ); +} diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..f7ea3ac --- /dev/null +++ b/src/lib/session.ts @@ -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> { + 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 { + 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(); +} diff --git a/src/lib/terms.ts b/src/lib/terms.ts new file mode 100644 index 0000000..f5fc915 --- /dev/null +++ b/src/lib/terms.ts @@ -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 + }); +} diff --git a/src/lib/webdav.ts b/src/lib/webdav.ts index a4cfa6e..3543ad4 100644 --- a/src/lib/webdav.ts +++ b/src/lib/webdav.ts @@ -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(); diff --git a/src/types/analytics.ts b/src/types/analytics.ts deleted file mode 100644 index 855ecd6..0000000 --- a/src/types/analytics.ts +++ /dev/null @@ -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; - count: number; -}; - -export type DrillTarget = { - path: string; - id?: string; -};