Compare commits
No commits in common. "main" and "V4.0" have entirely different histories.
40 changed files with 151 additions and 6051 deletions
24
.env
24
.env
|
|
@ -1,20 +1,6 @@
|
||||||
|
DATABASE_URL="postgresql://stephen:Web2025$$@192.168.1.210:5432/webcalibre2"
|
||||||
|
|
||||||
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
|
# Credentials for your Microsoft App (NextAuth)
|
||||||
# values generated by Gemini
|
AZURE_AD_CLIENT_ID="549931a-f491-436b-b7bc-d37d8ca3c17e"
|
||||||
# Generated for security
|
AZURE_AD_CLIENT_SECRET="6a242be1-c711-4cc3-a132-03c1f57993ed"
|
||||||
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k="
|
AZURE_AD_TENANT_ID="1c06ce7c-7884-4796-8652-d4c32d75a5d0"
|
||||||
|
|
||||||
# Use 'common' for multi-tenant (Work + Personal) support
|
|
||||||
AUTH_MICROSOFT_ENTRA_ID_TENANT_ID="common"
|
|
||||||
|
|
||||||
# The Application (client) ID
|
|
||||||
AUTH_MICROSOFT_ENTRA_ID_ID="b549931a-f491-436b-b7bc-d37d8ca3c17e"
|
|
||||||
|
|
||||||
# The Client Secret VALUE (ensure no leading '6' or spaces)
|
|
||||||
AUTH_MICROSOFT_ENTRA_ID_SECRET="O3p8Q~oMph-0kSLwkvzEJzJdx_iHGOjJjrr5Pa1A"
|
|
||||||
|
|
||||||
# Required to tell Auth.js to trust your localhost/proxy URL
|
|
||||||
AUTH_TRUST_HOST=true
|
|
||||||
|
|
||||||
# added initial admin user
|
|
||||||
INITIAL_ADMIN_EMAIL="slohning@live.com.au"
|
|
||||||
6
.env-bak
6
.env-bak
|
|
@ -1,6 +0,0 @@
|
||||||
DATABASE_URL="postgresql://stephen:Web2025$$@192.168.1.210:5432/webcalibre2"
|
|
||||||
|
|
||||||
# Credentials for your Microsoft App (NextAuth)
|
|
||||||
AZURE_AD_CLIENT_ID="549931a-f491-436b-b7bc-d37d8ca3c17e"
|
|
||||||
AZURE_AD_CLIENT_SECRET="6a242be1-c711-4cc3-a132-03c1f57993ed"
|
|
||||||
AZURE_AD_TENANT_ID="1c06ce7c-7884-4796-8652-d4c32d75a5d0"
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
|
|
||||||
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
|
|
||||||
# values generated by Gemini
|
# values generated by Gemini
|
||||||
# Generated for security
|
# Generated for security
|
||||||
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k="
|
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k="
|
||||||
|
|
@ -15,6 +14,3 @@ AUTH_MICROSOFT_ENTRA_ID_SECRET="O3p8Q~oMph-0kSLwkvzEJzJdx_iHGOjJjrr5Pa1A"
|
||||||
|
|
||||||
# Required to tell Auth.js to trust your localhost/proxy URL
|
# Required to tell Auth.js to trust your localhost/proxy URL
|
||||||
AUTH_TRUST_HOST=true
|
AUTH_TRUST_HOST=true
|
||||||
|
|
||||||
# added initial admin user
|
|
||||||
INITIAL_ADMIN_EMAIL="slohning@live.com.au"
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -39,5 +39,3 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Binary file not shown.
140
docs/notes.md
140
docs/notes.md
|
|
@ -4,7 +4,6 @@
|
||||||
- [1.3. Overview of user consent and how to manage it in Microsoft Entra | Microsoft](#13-overview-of-user-consent-and-how-to-manage-it-in-microsoft-entra--microsoft)
|
- [1.3. Overview of user consent and how to manage it in Microsoft Entra | Microsoft](#13-overview-of-user-consent-and-how-to-manage-it-in-microsoft-entra--microsoft)
|
||||||
- [1.4. Add Tailwind CSS to an Existing Next js Project](#14-add-tailwind-css-to-an-existing-next-js-project)
|
- [1.4. Add Tailwind CSS to an Existing Next js Project](#14-add-tailwind-css-to-an-existing-next-js-project)
|
||||||
- [1.5. How to style Material UI component with Tailwindcss in React project](#15-how-to-style-material-ui-component-with-tailwindcss-in-react-project)
|
- [1.5. How to style Material UI component with Tailwindcss in React project](#15-how-to-style-material-ui-component-with-tailwindcss-in-react-project)
|
||||||
- [1.6. How to Use Prisma 7 in Next.js](#16-how-to-use-prisma-7-in-nextjs)
|
|
||||||
- [2. Project Structure](#2-project-structure)
|
- [2. Project Structure](#2-project-structure)
|
||||||
- [3. App Registrations](#3-app-registrations)
|
- [3. App Registrations](#3-app-registrations)
|
||||||
- [3.1. Permissions Needed](#31-permissions-needed)
|
- [3.1. Permissions Needed](#31-permissions-needed)
|
||||||
|
|
@ -15,16 +14,6 @@
|
||||||
- [4.4. Test nextjs](#44-test-nextjs)
|
- [4.4. Test nextjs](#44-test-nextjs)
|
||||||
- [4.5. Check Authentication](#45-check-authentication)
|
- [4.5. Check Authentication](#45-check-authentication)
|
||||||
- [4.6. Install NextAuth.js v5](#46-install-nextauthjs-v5)
|
- [4.6. Install NextAuth.js v5](#46-install-nextauthjs-v5)
|
||||||
- [5. .env files](#5-env-files)
|
|
||||||
- [5.1. .env.local](#51-envlocal)
|
|
||||||
- [5.2. .env](#52-env)
|
|
||||||
- [6. Prisma Install](#6-prisma-install)
|
|
||||||
- [6.1. The CLI is for development tasks like migrations](#61-the-cli-is-for-development-tasks-like-migrations)
|
|
||||||
- [6.2. Create the prisma folder](#62-create-the-prisma-folder)
|
|
||||||
- [7. Run this whenever you change your schema.prisma file](#7-run-this-whenever-you-change-your-schemaprisma-file)
|
|
||||||
- [7.1. Pro-Tip: Update your package.json](#71-pro-tip-update-your-packagejson)
|
|
||||||
- [8. Where we are up to 8/1/2026](#8-where-we-are-up-to-812026)
|
|
||||||
- [9. Testing Creating And Uploading Folders and Files](#9-testing-creating-and-uploading-folders-and-files)
|
|
||||||
|
|
||||||
# 1. Reference
|
# 1. Reference
|
||||||
|
|
||||||
|
|
@ -48,11 +37,6 @@
|
||||||
|
|
||||||
[How to style Material UI component with Tailwindcss in React project](https://www.youtube.com/watch?v=QQIfuMlA6TI)
|
[How to style Material UI component with Tailwindcss in React project](https://www.youtube.com/watch?v=QQIfuMlA6TI)
|
||||||
|
|
||||||
|
|
||||||
## 1.6. How to Use Prisma 7 in Next.js
|
|
||||||
|
|
||||||
[How to Use Prisma 7 in Next.js](https://www.youtube.com/watch?v=Ndhx_rNkoUw)
|
|
||||||
|
|
||||||
# 2. Project Structure
|
# 2. Project Structure
|
||||||
|
|
||||||
The following is the desire structure
|
The following is the desire structure
|
||||||
|
|
@ -246,127 +230,3 @@ created .env.local
|
||||||
```text
|
```text
|
||||||
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k=" # Added by `npx auth`. Read more: https://cli.authjs.dev
|
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k=" # Added by `npx auth`. Read more: https://cli.authjs.dev
|
||||||
```
|
```
|
||||||
|
|
||||||
# 5. .env files
|
|
||||||
|
|
||||||
## 5.1. .env.local
|
|
||||||
```text
|
|
||||||
# values generated by Gemini
|
|
||||||
# Generated for security
|
|
||||||
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k="
|
|
||||||
|
|
||||||
# Use 'common' for multi-tenant (Work + Personal) support
|
|
||||||
AUTH_MICROSOFT_ENTRA_ID_TENANT_ID="common"
|
|
||||||
|
|
||||||
# The Application (client) ID
|
|
||||||
AUTH_MICROSOFT_ENTRA_ID_ID="b549931a-f491-436b-b7bc-d37d8ca3c17e"
|
|
||||||
|
|
||||||
# The Client Secret VALUE (ensure no leading '6' or spaces)
|
|
||||||
AUTH_MICROSOFT_ENTRA_ID_SECRET="O3p8Q~oMph-0kSLwkvzEJzJdx_iHGOjJjrr5Pa1A"
|
|
||||||
|
|
||||||
# Required to tell Auth.js to trust your localhost/proxy URL
|
|
||||||
AUTH_TRUST_HOST=true
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5.2. .env
|
|
||||||
```
|
|
||||||
DATABASE_URL="postgresql://stephen:Web2025$$@192.168.1.210:5432/webcalibre2"
|
|
||||||
|
|
||||||
# Credentials for your Microsoft App (NextAuth)
|
|
||||||
AZURE_AD_CLIENT_ID="549931a-f491-436b-b7bc-d37d8ca3c17e"
|
|
||||||
AZURE_AD_CLIENT_SECRET="6a242be1-c711-4cc3-a132-03c1f57993ed"
|
|
||||||
AZURE_AD_TENANT_ID="1c06ce7c-7884-4796-8652-d4c32d75a5d0"
|
|
||||||
```
|
|
||||||
|
|
||||||
# 6. Prisma Install
|
|
||||||
|
|
||||||
To get Prisma fully installed and ready to talk to your PostgreSQL database, you need to install the CLI (for migrations) and the Client (for your code to query the database).
|
|
||||||
|
|
||||||
Run these three commands in your terminal:
|
|
||||||
|
|
||||||
1. Install the Prisma packages
|
|
||||||
Bash
|
|
||||||
|
|
||||||
## 6.1. The CLI is for development tasks like migrations
|
|
||||||
npm install prisma --save-dev
|
|
||||||
|
|
||||||
##The Client is used by your Next.js code to talk to the DB
|
|
||||||
```zsh
|
|
||||||
npm install @prisma/client
|
|
||||||
```
|
|
||||||
2. Install the Dotenv helper
|
|
||||||
Since you are keeping your DATABASE_URL in .env.local (and your password has special characters), you need this to bridge the gap between Next.js and the Prisma CLI:
|
|
||||||
|
|
||||||
```zsh
|
|
||||||
|
|
||||||
npm install dotenv-cli --save-dev
|
|
||||||
```
|
|
||||||
3. Initialize and Generate
|
|
||||||
Once the packages are installed, you need to initialize the Prisma folder (if you haven't already) and generate the types for your FileNode model:
|
|
||||||
|
|
||||||
Bash
|
|
||||||
|
|
||||||
## 6.2. Create the prisma folder
|
|
||||||
```zsh
|
|
||||||
npx prisma init
|
|
||||||
```
|
|
||||||
# 7. Run this whenever you change your schema.prisma file
|
|
||||||
npx dotenv -e .env.local -- npx prisma generate
|
|
||||||
4. Apply your Schema to PostgreSQL
|
|
||||||
Finally, run this to actually create the tables in your webcalibre2 database:
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
npx dotenv -e .env.local -- npx prisma migrate dev --name init_database
|
|
||||||
|
|
||||||
## 7.1. Pro-Tip: Update your package.json
|
|
||||||
To avoid typing that long dotenv command every time, open your package.json and add this to the "scripts" section:
|
|
||||||
|
|
||||||
JSON
|
|
||||||
|
|
||||||
"scripts": {
|
|
||||||
"db:migrate": "dotenv -e .env.local -- prisma migrate dev",
|
|
||||||
"db:studio": "dotenv -e .env.local -- prisma studio"
|
|
||||||
}
|
|
||||||
Now, you can just run npm run db:migrate whenever you update your schema!
|
|
||||||
|
|
||||||
# 8. Where we are up to 8/1/2026
|
|
||||||
|
|
||||||
We can authenticate with MS Azure, upload a file and store it on my personal OneDrive.
|
|
||||||
I can see a few problems,
|
|
||||||
|
|
||||||
1. if the file gets stored with its original filename, if I store the same file again it tells that it fail, but in reality it did not fail the it got renamed with 1.pdf added this is not added data base.
|
|
||||||
|
|
||||||
2. We may be better to create a folder and put the file in the folder, to me the logical name would id of the file, but that is automatically create and assigned when the data is stored as a UUID.
|
|
||||||
|
|
||||||
3. We would have to create the UUID ourselves, so that we can create a folder with that specific UUID and store the file in the folder.
|
|
||||||
|
|
||||||
4. The Upload file Page needs to be able to create folder as well select a parent , add a description of either the file
|
|
||||||
|
|
||||||
# 9. Testing Creating And Uploading Folders and Files
|
|
||||||
|
|
||||||
The functionality works may not the best user interface
|
|
||||||
|
|
||||||
Projects ID 0371fdfe-a2d1-4f25-b229-804f299bc064
|
|
||||||
Project-1 ID c8eebe0e-6e90-4cfb-8e44-c05f7064f3b1 parentID 0371fdfe-a2d1-4f25-b229-804f299bc064
|
|
||||||
AS-NZS3000-2018.pdf parentID c8eebe0e-6e90-4cfb-8e44-c05f7064f3b1
|
|
||||||
|
|
||||||
Now we will go now and add metadata
|
|
||||||
|
|
||||||
Now we have the metadata along with some great filters on the data board page
|
|
||||||
|
|
||||||
Now we are going to implement an an update of a file or folder stored. 12/1/2026
|
|
||||||
|
|
||||||
For download there 2 function we need to implement
|
|
||||||
|
|
||||||
1) down load the file to ~/user/Downloads, this should work for any file type including pdf
|
|
||||||
|
|
||||||
2) if the file is a pdf open the file in another tab in the browser as most browser support reading a pdf
|
|
||||||
|
|
||||||
For download there 2 function we need to implement
|
|
||||||
|
|
||||||
1) down load the file to ~/user/Downloads, this should work for any file type including pdf
|
|
||||||
|
|
||||||
2) if the file is a pdf open the file in another tab in the browser as most browser support reading a pdf
|
|
||||||
|
|
||||||
So we need to implement 2 new actions, downLoad file and open pdf to read. Does this make sense ?
|
|
||||||
BIN
docs/notes.pdf
BIN
docs/notes.pdf
Binary file not shown.
|
|
@ -2,12 +2,6 @@ import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
experimental: {
|
|
||||||
serverActions: {
|
|
||||||
// Set this higher than your MAX_FILE_SIZE in upload-view.tsx
|
|
||||||
bodySizeLimit: '150mb',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
1419
package-lock.json
generated
1419
package-lock.json
generated
File diff suppressed because it is too large
Load diff
15
package.json
15
package.json
|
|
@ -6,37 +6,26 @@
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint"
|
||||||
"db:migrate": "prisma migrate dev",
|
|
||||||
"db:generate": "prisma generate",
|
|
||||||
"db:studio": "prisma studio"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
|
||||||
"@emotion/cache": "^11.14.0",
|
"@emotion/cache": "^11.14.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.6",
|
"@mui/icons-material": "^7.3.6",
|
||||||
"@mui/material": "^7.3.7",
|
"@mui/material": "^7.3.6",
|
||||||
"@mui/material-nextjs": "^7.3.6",
|
"@mui/material-nextjs": "^7.3.6",
|
||||||
"@mui/x-data-grid": "^8.24.0",
|
|
||||||
"@prisma/adapter-pg": "^7.2.0",
|
|
||||||
"@prisma/client": "^7.2.0",
|
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"pg": "^8.16.3",
|
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.16.0",
|
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"dotenv-cli": "^11.0.0",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.1.1",
|
||||||
"prisma": "^7.2.0",
|
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { config } from "dotenv";
|
|
||||||
config({ path: ".env.local" });
|
|
||||||
|
|
||||||
import { defineConfig } from "prisma/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
schema: "prisma/schema.prisma",
|
|
||||||
datasource: {
|
|
||||||
// This is now the ONLY place where the DB connection string is defined
|
|
||||||
url: process.env.DATABASE_URL,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "User" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"azureAdUserId" TEXT NOT NULL,
|
|
||||||
"email" TEXT NOT NULL,
|
|
||||||
"displayName" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "FileNode" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"size" BIGINT,
|
|
||||||
"isFolder" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"oneDriveId" TEXT,
|
|
||||||
"path" TEXT NOT NULL,
|
|
||||||
"orderIndex" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"description" TEXT,
|
|
||||||
"ownerId" TEXT NOT NULL,
|
|
||||||
"parentId" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "FileNode_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_azureAdUserId_key" ON "User"("azureAdUserId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "FileNode_oneDriveId_key" ON "FileNode"("oneDriveId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "FileNode_parentId_idx" ON "FileNode"("parentId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "FileNode_orderIndex_idx" ON "FileNode"("orderIndex");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "FileNode_ownerId_path_key" ON "FileNode"("ownerId", "path");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "FileNode" ADD CONSTRAINT "FileNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileNode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `displayName` on the `User` table. All the data in the column will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" DROP COLUMN "displayName",
|
|
||||||
ADD COLUMN "emailVerified" TIMESTAMP(3),
|
|
||||||
ADD COLUMN "image" TEXT,
|
|
||||||
ADD COLUMN "name" TEXT,
|
|
||||||
ALTER COLUMN "azureAdUserId" DROP NOT NULL;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Account" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"provider" TEXT NOT NULL,
|
|
||||||
"providerAccountId" TEXT NOT NULL,
|
|
||||||
"refresh_token" TEXT,
|
|
||||||
"access_token" TEXT,
|
|
||||||
"expires_at" INTEGER,
|
|
||||||
"token_type" TEXT,
|
|
||||||
"scope" TEXT,
|
|
||||||
"id_token" TEXT,
|
|
||||||
"session_state" TEXT,
|
|
||||||
|
|
||||||
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Session" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"sessionToken" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"expires" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (e.g., Git)
|
|
||||||
provider = "postgresql"
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
}
|
|
||||||
|
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Define the possible roles
|
|
||||||
enum Role {
|
|
||||||
USER
|
|
||||||
ADMIN
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
name String?
|
|
||||||
email String @unique
|
|
||||||
role Role @default(USER) // 2. Add this line (Defaults to USER)
|
|
||||||
emailVerified DateTime?
|
|
||||||
image String?
|
|
||||||
azureAdUserId String? @unique
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
nodes FileNode[]
|
|
||||||
accounts Account[]
|
|
||||||
sessions Session[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Account {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
userId String
|
|
||||||
type String
|
|
||||||
provider String
|
|
||||||
providerAccountId String
|
|
||||||
refresh_token String? @db.Text
|
|
||||||
access_token String? @db.Text
|
|
||||||
expires_at Int?
|
|
||||||
token_type String?
|
|
||||||
scope String?
|
|
||||||
id_token String? @db.Text
|
|
||||||
session_state String?
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([provider, providerAccountId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Session {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
sessionToken String @unique
|
|
||||||
userId String
|
|
||||||
expires DateTime
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model FileNode {
|
|
||||||
id String @id
|
|
||||||
name String
|
|
||||||
size BigInt? // Preserved your BigInt size column
|
|
||||||
isFolder Boolean @default(false)
|
|
||||||
oneDriveId String? @unique
|
|
||||||
path String
|
|
||||||
orderIndex Int @default(0)
|
|
||||||
metadata Json @default("{}")
|
|
||||||
description String?
|
|
||||||
|
|
||||||
ownerId String
|
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
|
||||||
|
|
||||||
parentId String?
|
|
||||||
// Added onDelete: Cascade here to allow deleting folders and their children automatically
|
|
||||||
parent FileNode? @relation("TreeHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
|
|
||||||
children FileNode[] @relation("TreeHierarchy")
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([ownerId, path])
|
|
||||||
@@index([parentId])
|
|
||||||
@@index([orderIndex])
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
// src/app/api/download/route.ts
|
|
||||||
"use server"
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { auth } from "@/auth"; // Import the auth function from your central config
|
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// 1. Check Authentication using the V5 auth() helper
|
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
// In V5, tokens are usually handled in the session callback
|
|
||||||
if (!session || !session.accessToken) {
|
|
||||||
return new NextResponse("Unauthorized - No Access Token found", { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get parameters from URL
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const fileNodeId = searchParams.get('id');
|
|
||||||
const mode = searchParams.get('mode') === 'inline' ? 'inline' : 'attachment';
|
|
||||||
|
|
||||||
if (!fileNodeId) {
|
|
||||||
return new NextResponse("File ID is required", { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Find the file in your Postgres FileNode table
|
|
||||||
const fileNode = await prisma.fileNode.findUnique({
|
|
||||||
where: { id: fileNodeId }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fileNode || !fileNode.oneDriveId) {
|
|
||||||
return new NextResponse("File not found in database", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Fetch the file stream from Microsoft Graph
|
|
||||||
const graphResponse = await fetch(
|
|
||||||
`https://graph.microsoft.com/v1.0/me/drive/items/${fileNode.oneDriveId}/content`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${session.accessToken}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!graphResponse.ok) {
|
|
||||||
const errorText = await graphResponse.text();
|
|
||||||
console.error('MS Graph Error:', errorText);
|
|
||||||
return new NextResponse(`OneDrive Error: ${graphResponse.statusText}`, { status: graphResponse.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Stream the response directly to the client
|
|
||||||
return new NextResponse(graphResponse.body, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': fileNode.mimeType || 'application/octet-stream',
|
|
||||||
// Note: Using encodeURIComponent for filename to handle special characters
|
|
||||||
'Content-Disposition': `${mode}; filename="${encodeURIComponent(fileNode.name)}"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Download error:', error);
|
|
||||||
return new NextResponse("Internal Server Error", { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
'use server';
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { getFreshAccessToken } from "@/lib/auth-utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 1. FETCH: Get all file nodes for the Dashboard
|
|
||||||
*/
|
|
||||||
export async function getFileNodes() {
|
|
||||||
try {
|
|
||||||
const nodes = await prisma.fileNode.findMany({
|
|
||||||
orderBy: {
|
|
||||||
updatedAt: 'desc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return nodes;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching file nodes:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 2. DOWNLOAD: Generates the authenticated OneDrive URL
|
|
||||||
*/
|
|
||||||
export async function getDownloadUrlAction(id: string) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
|
||||||
|
|
||||||
const file = await prisma.fileNode.findUnique({ where: { id } });
|
|
||||||
if (!file || !file.oneDriveId) throw new Error("File not found or missing cloud ID");
|
|
||||||
|
|
||||||
const accessToken = await getFreshAccessToken(session.user.id);
|
|
||||||
const res = await fetch(
|
|
||||||
`https://graph.microsoft.com/v1.0/me/drive/items/${file.oneDriveId}`,
|
|
||||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error("Failed to contact OneDrive");
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
const downloadUrl = data["@microsoft.graph.downloadUrl"];
|
|
||||||
|
|
||||||
if (!downloadUrl) throw new Error("OneDrive did not provide a download link");
|
|
||||||
|
|
||||||
return { downloadUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 3. DELETE: Remove from OneDrive (via ID) and Database
|
|
||||||
* Folders are virtual (DB only), so cloud deletion is skipped if oneDriveId is null.
|
|
||||||
*/
|
|
||||||
export async function deleteFileAction(fileId: string) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
|
||||||
|
|
||||||
const node = await prisma.fileNode.findUnique({
|
|
||||||
where: { id: fileId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!node) {
|
|
||||||
revalidatePath("/dashboard");
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const isAdmin = session.user.role === "ADMIN";
|
|
||||||
const isOwner = node.ownerId === session.user.id;
|
|
||||||
|
|
||||||
if (!isAdmin && !isOwner) {
|
|
||||||
throw new Error("Permission Denied.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const accessToken = await getFreshAccessToken(session.user.id);
|
|
||||||
|
|
||||||
// Only attempt cloud deletion if it's a file/storage with a oneDriveId.
|
|
||||||
// Virtual folders created in the DB have no oneDriveId and are skipped.
|
|
||||||
if (accessToken && node.oneDriveId) {
|
|
||||||
const onedriveRes = await fetch(
|
|
||||||
`https://graph.microsoft.com/v1.0/me/drive/items/${node.oneDriveId}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!onedriveRes.ok && onedriveRes.status !== 404) {
|
|
||||||
console.warn("OneDrive Deletion Warning: Cloud record might still exist.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (cloudError) {
|
|
||||||
console.error("Cloud cleanup failed:", cloudError);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.fileNode.delete({ where: { id: fileId } });
|
|
||||||
revalidatePath("/dashboard");
|
|
||||||
revalidatePath("/upload");
|
|
||||||
return { success: true };
|
|
||||||
} catch (dbError) {
|
|
||||||
throw new Error("Failed to remove the record from the database.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 4. MOVE: Assign file to folder or folder to another folder (Virtual Move)
|
|
||||||
*/
|
|
||||||
export async function moveNodeAction(nodeId: string, newParentId: string | null) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
|
||||||
if (nodeId === newParentId) throw new Error("Cannot move to self.");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.fileNode.update({
|
|
||||||
where: { id: nodeId },
|
|
||||||
data: { parentId: newParentId }
|
|
||||||
});
|
|
||||||
revalidatePath("/dashboard");
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error("Move failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 5. UPDATE & REPLACE: Full update of metadata and OneDrive content
|
|
||||||
*/
|
|
||||||
export async function updateFileFullAction(formData: FormData) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
|
||||||
|
|
||||||
const id = formData.get("id") as string;
|
|
||||||
const name = formData.get("name") as string;
|
|
||||||
const description = formData.get("description") as string;
|
|
||||||
const parentIdRaw = formData.get("parentId") as string;
|
|
||||||
const metadataStr = formData.get("metadata") as string;
|
|
||||||
const newFile = formData.get("file") as File | null;
|
|
||||||
|
|
||||||
const parentId = parentIdRaw === "root" ? null : parentIdRaw;
|
|
||||||
let metadata = JSON.parse(metadataStr);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const accessToken = await getFreshAccessToken(session.user.id);
|
|
||||||
const node = await prisma.fileNode.findUnique({ where: { id } });
|
|
||||||
|
|
||||||
// Update physical file content only if a new file is uploaded and we have a target oneDriveId
|
|
||||||
if (newFile && newFile.size > 0 && node?.oneDriveId) {
|
|
||||||
const onedrivePath = `https://graph.microsoft.com/v1.0/me/drive/items/${node.oneDriveId}/content`;
|
|
||||||
|
|
||||||
const uploadRes = await fetch(onedrivePath, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
"Content-Type": newFile.type
|
|
||||||
},
|
|
||||||
body: Buffer.from(await newFile.arrayBuffer()),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!uploadRes.ok) throw new Error("OneDrive content update failed");
|
|
||||||
|
|
||||||
metadata.type = newFile.name.split('.').pop()?.toUpperCase() || 'UNKNOWN';
|
|
||||||
metadata.mimeType = newFile.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.fileNode.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
parentId,
|
|
||||||
metadata,
|
|
||||||
size: newFile ? BigInt(newFile.size) : undefined,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/dashboard");
|
|
||||||
return { success: true };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Full Update Failure:", error);
|
|
||||||
throw new Error(error.message || "Failed to update record.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
// src/app/dashboard/dashboard-view.tsx
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
Box,
|
|
||||||
Chip,
|
|
||||||
IconButton,
|
|
||||||
Typography,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
InputAdornment,
|
|
||||||
Tooltip
|
|
||||||
} from "@mui/material";
|
|
||||||
import {
|
|
||||||
DataGrid,
|
|
||||||
GridColDef,
|
|
||||||
Toolbar,
|
|
||||||
QuickFilter,
|
|
||||||
QuickFilterControl,
|
|
||||||
QuickFilterClear,
|
|
||||||
} from "@mui/x-data-grid";
|
|
||||||
import SyncIcon from "@mui/icons-material/Sync";
|
|
||||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
|
||||||
import FolderIcon from "@mui/icons-material/Folder";
|
|
||||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
|
||||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
|
||||||
|
|
||||||
import { syncOneDrive } from "./sync-actions";
|
|
||||||
import { deleteFileAction } from "./actions";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
function CustomToolbar() {
|
|
||||||
return (
|
|
||||||
<Toolbar sx={{ p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Typography variant="h6" fontWeight="bold" color="primary">
|
|
||||||
Library
|
|
||||||
</Typography>
|
|
||||||
<QuickFilter sx={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
<QuickFilterControl
|
|
||||||
render={({ ref, ...controlProps }, state) => (
|
|
||||||
<TextField
|
|
||||||
{...controlProps}
|
|
||||||
inputRef={ref}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
placeholder="Search files and metadata..."
|
|
||||||
sx={{ width: 350 }}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<SearchIcon fontSize="small" />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
endAdornment: state.value ? (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<QuickFilterClear size="small">
|
|
||||||
<CancelIcon fontSize="small" />
|
|
||||||
</QuickFilterClear>
|
|
||||||
</InputAdornment>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</QuickFilter>
|
|
||||||
</Toolbar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardViewProps {
|
|
||||||
initialFiles: any[];
|
|
||||||
user?: {
|
|
||||||
id?: string;
|
|
||||||
role?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardView({ initialFiles, user }: DashboardViewProps) {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
const isAdmin = user?.role === "ADMIN";
|
|
||||||
|
|
||||||
const getVirtualPath = (parentId: string | null): string => {
|
|
||||||
if (!parentId) return "WebCalibre";
|
|
||||||
const parent = initialFiles.find((f) => f.id === parentId);
|
|
||||||
if (!parent) return "WebCalibre";
|
|
||||||
const prefix = parent.parentId ? `${getVirtualPath(parent.parentId)} / ` : "";
|
|
||||||
return `${prefix}${parent.name}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSync = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await syncOneDrive();
|
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Sync failed:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
setIsRefreshing(true);
|
|
||||||
router.refresh();
|
|
||||||
setTimeout(() => setIsRefreshing(false), 800);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
|
||||||
if (!confirm(`Are you sure you want to delete "${name}"?`)) return;
|
|
||||||
try {
|
|
||||||
await deleteFileAction(id);
|
|
||||||
router.refresh();
|
|
||||||
} catch (error: any) {
|
|
||||||
alert(error.message || "Failed to delete file");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- NEW DOWNLOAD FUNCTIONS ---
|
|
||||||
const handleDownload = (id: string) => {
|
|
||||||
// Triggers local folder download via Content-Disposition: attachment
|
|
||||||
window.location.href = `/api/download?id=${id}&mode=attachment`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewInTab = (id: string) => {
|
|
||||||
// Opens in a new tab via Content-Disposition: inline
|
|
||||||
window.open(`/api/download?id=${id}&mode=inline`, '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
|
||||||
{
|
|
||||||
field: "name",
|
|
||||||
headerName: "Name",
|
|
||||||
flex: 1.5,
|
|
||||||
minWidth: 250,
|
|
||||||
renderCell: (params) => (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
||||||
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
|
|
||||||
<Typography variant="body2">{params.value}</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "parentId",
|
|
||||||
headerName: "Location",
|
|
||||||
flex: 1,
|
|
||||||
renderCell: (params) => <Chip label={getVirtualPath(params.value)} size="small" variant="outlined" />
|
|
||||||
},
|
|
||||||
{ field: "description", headerName: "Description", flex: 1 },
|
|
||||||
{
|
|
||||||
field: "type",
|
|
||||||
headerName: "Type",
|
|
||||||
width: 120,
|
|
||||||
valueGetter: (value, row) => row.metadata?.type || (row.isFolder ? "Folder" : "File"),
|
|
||||||
renderCell: (params) => (
|
|
||||||
<Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold', color: 'text.secondary' }}>
|
|
||||||
{params.value}
|
|
||||||
</Typography>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "size",
|
|
||||||
headerName: "Size",
|
|
||||||
width: 100,
|
|
||||||
renderCell: (params) => params.row.isFolder ? "--" : `${(Number(params.value) / 1024 / 1024).toFixed(2)} MB`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "actions",
|
|
||||||
headerName: "Actions",
|
|
||||||
width: 180, // Increased width to accommodate new buttons
|
|
||||||
align: 'right',
|
|
||||||
renderCell: (params) => {
|
|
||||||
const isOwner = params.row.ownerId === user?.id;
|
|
||||||
const isFolder = params.row.isFolder;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
|
||||||
{!isFolder && (
|
|
||||||
<>
|
|
||||||
<Tooltip title="View in Tab">
|
|
||||||
<IconButton size="small" color="info" onClick={() => handleViewInTab(params.row.id)}>
|
|
||||||
<OpenInNewIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Download to Folder">
|
|
||||||
<IconButton size="small" color="success" onClick={() => handleDownload(params.row.id)}>
|
|
||||||
<DownloadIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(isAdmin || isOwner) && (
|
|
||||||
<>
|
|
||||||
<Tooltip title="Edit Details">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => router.push(`/update/${params.row.id}`)}
|
|
||||||
>
|
|
||||||
<EditIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Delete">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
onClick={() => handleDelete(params.row.id, params.row.name)}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "metadata_search",
|
|
||||||
headerName: "Metadata Search",
|
|
||||||
width: 0,
|
|
||||||
valueGetter: (value, row) => {
|
|
||||||
if (!row.metadata) return "";
|
|
||||||
return Object.entries(row.metadata)
|
|
||||||
.filter(([k]) => k !== 'type' && k !== 'mimeType')
|
|
||||||
.map(([k, v]) => `${k}:${v}`)
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="space-y-4">
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mb: 2 }}>
|
|
||||||
<Button variant="outlined" startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />} onClick={handleRefresh}>
|
|
||||||
Refresh List
|
|
||||||
</Button>
|
|
||||||
<Button variant="contained" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />} onClick={handleSync} disabled={loading}>
|
|
||||||
Sync OneDrive
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ height: 700, width: "100%", bgcolor: 'background.paper', borderRadius: 3, boxShadow: 1, overflow: 'hidden' }}>
|
|
||||||
<DataGrid
|
|
||||||
rows={initialFiles}
|
|
||||||
columns={columns}
|
|
||||||
slots={{ toolbar: CustomToolbar }}
|
|
||||||
showToolbar
|
|
||||||
disableRowSelectionOnClick
|
|
||||||
initialState={{
|
|
||||||
columns: {
|
|
||||||
columnVisibilityModel: {
|
|
||||||
metadata_search: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
border: 'none',
|
|
||||||
'& .MuiDataGrid-columnHeaders': { bgcolor: '#f8f9fa' },
|
|
||||||
'& .MuiDataGrid-toolbarContainer': { borderBottom: '1px solid #eee' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +1,9 @@
|
||||||
import { auth } from "@/auth";
|
import React from 'react'
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getFileNodes } from "./actions";
|
|
||||||
import DashboardView from "./dashboard-view";
|
|
||||||
import { Box, Typography, Chip } from "@mui/material";
|
|
||||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
// Guard: If not logged in, go back to home
|
|
||||||
if (!session?.user) {
|
|
||||||
redirect("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch initial files from PostgreSQL
|
|
||||||
const initialFiles = await getFileNodes();
|
|
||||||
|
|
||||||
// Determine admin status for the header display
|
|
||||||
// @ts-ignore
|
|
||||||
const isAdmin = session.user.role === "ADMIN";
|
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
return (
|
return (
|
||||||
<main className="p-8">
|
<div>Dashboard</div>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 6 }}>
|
)
|
||||||
<Box>
|
|
||||||
<Typography variant="h4" fontWeight={800} sx={{ color: 'text.primary' }}>
|
|
||||||
My OneDrive Library
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" color="text.secondary">
|
|
||||||
Manage your synchronized files and project folders.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<Chip
|
|
||||||
icon={<AdminPanelSettingsIcon />}
|
|
||||||
label="Admin Access"
|
|
||||||
color="primary"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Pass both initialFiles AND the user object.
|
|
||||||
The DashboardView will use user.role and user.id to
|
|
||||||
decide who can see the 'Delete' button.
|
|
||||||
*/}
|
|
||||||
<DashboardView
|
|
||||||
initialFiles={initialFiles}
|
|
||||||
user={session.user}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Dashboard
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
'use server';
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { getFreshAccessToken } from "@/lib/auth-utils";
|
|
||||||
|
|
||||||
export async function syncOneDrive() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const accessToken = await getFreshAccessToken(session.user.id);
|
|
||||||
const response = await fetch("https://graph.microsoft.com/v1.0/me/drive/root:/WebCalibre:/children", {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) return { success: true, count: 0 };
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
let syncedCount = 0;
|
|
||||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
||||||
|
|
||||||
for (const item of data.value) {
|
|
||||||
const isFolder = !!item.folder;
|
|
||||||
|
|
||||||
// Only skip if it's a folder AND it's a UUID (storage container)
|
|
||||||
// If a user named a file with a UUID, we still want it.
|
|
||||||
if (isFolder && uuidRegex.test(item.name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toUpperCase() || 'UNKNOWN');
|
|
||||||
|
|
||||||
await prisma.fileNode.upsert({
|
|
||||||
where: { oneDriveId: item.id }, // Primary match
|
|
||||||
update: {
|
|
||||||
name: item.name,
|
|
||||||
size: BigInt(item.size || 0),
|
|
||||||
isFolder: isFolder,
|
|
||||||
path: item.parentReference?.path + '/' + item.name,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
oneDriveId: item.id,
|
|
||||||
name: item.name,
|
|
||||||
size: BigInt(item.size || 0),
|
|
||||||
isFolder: isFolder,
|
|
||||||
path: item.parentReference?.path + '/' + item.name,
|
|
||||||
ownerId: session.user.id,
|
|
||||||
metadata: { type: extension, mimeType: item.file?.mimeType || null },
|
|
||||||
}
|
|
||||||
});
|
|
||||||
syncedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath('/dashboard');
|
|
||||||
return { success: true, count: syncedCount };
|
|
||||||
} catch (error: any) {
|
|
||||||
throw new Error(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
'use server';
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { getFreshAccessToken } from "@/lib/auth-utils";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
|
|
||||||
export async function uploadFileAction(formData: FormData) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
|
||||||
|
|
||||||
const file = formData.get("file") as File;
|
|
||||||
const folderName = "WebCalibre";
|
|
||||||
const accessToken = await getFreshAccessToken(session.user.id);
|
|
||||||
|
|
||||||
// 1. Create/Check WebCalibre Folder
|
|
||||||
const folderPath = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}`;
|
|
||||||
const folderCheck = await fetch(folderPath, {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (folderCheck.status === 404) {
|
|
||||||
await fetch(`https://graph.microsoft.com/v1.0/me/drive/root/children`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: folderName, folder: {} })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Create Upload Session (Supports files > 4MB)
|
|
||||||
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}/${file.name}:/createUploadSession`;
|
|
||||||
const sessionRes = await fetch(sessionUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "rename" } })
|
|
||||||
});
|
|
||||||
|
|
||||||
const { uploadUrl } = await sessionRes.json();
|
|
||||||
|
|
||||||
// 3. Upload File Data
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
const uploadRes = await fetch(uploadUrl, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Length": `${file.size}`,
|
|
||||||
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
|
|
||||||
},
|
|
||||||
body: buffer
|
|
||||||
});
|
|
||||||
|
|
||||||
const driveItem = await uploadRes.json();
|
|
||||||
|
|
||||||
// 4. Record in PostgreSQL
|
|
||||||
await prisma.fileNode.create({
|
|
||||||
data: {
|
|
||||||
oneDriveId: driveItem.id,
|
|
||||||
name: file.name,
|
|
||||||
size: BigInt(file.size),
|
|
||||||
isFolder: false,
|
|
||||||
path: `/${folderName}/${file.name}`,
|
|
||||||
ownerId: session.user.id,
|
|
||||||
metadata: { type: file.name.split('.').pop()?.toUpperCase() }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/dashboard");
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button, Typography, Box, LinearProgress } from "@mui/material";
|
|
||||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
|
||||||
import { uploadFileToOneDrive } from "./upload-actions";
|
|
||||||
|
|
||||||
export default function UploadForm() {
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
|
|
||||||
async function handleAction(formData: FormData) {
|
|
||||||
const file = formData.get("file") as File;
|
|
||||||
if (!file || file.size === 0) return;
|
|
||||||
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
await uploadFileToOneDrive(formData);
|
|
||||||
alert("Uploaded successfully!");
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
alert("Upload failed.");
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="p-4 border-2 border-dashed border-gray-300 rounded-lg text-center">
|
|
||||||
<form action={handleAction}>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
name="file"
|
|
||||||
id="file-upload"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
// Optional: Trigger auto-submit or show filename
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label htmlFor="file-upload">
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
component="span"
|
|
||||||
startIcon={<CloudUploadIcon />}
|
|
||||||
disabled={uploading}
|
|
||||||
>
|
|
||||||
Select Book / File
|
|
||||||
</Button>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{uploading && (
|
|
||||||
<Box sx={{ width: '100%', mt: 2 }}>
|
|
||||||
<Typography variant="caption">Uploading to OneDrive & Database...</Typography>
|
|
||||||
<LinearProgress />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box mt={2}>
|
|
||||||
<Button type="submit" variant="contained" disabled={uploading}>
|
|
||||||
Confirm Upload
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</form>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -12,17 +12,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||||
// Fetch the session server-side to prevent UI flickering
|
// Fetch the session server-side to prevent UI flickering
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
/**
|
|
||||||
* We enhance the user object before passing it to the Navbar.
|
|
||||||
* This ensures the Navbar knows if the current user is the "Bootstrap Admin"
|
|
||||||
* defined in our environment variables.
|
|
||||||
*/
|
|
||||||
const navbarUser = session?.user ? {
|
|
||||||
...session.user,
|
|
||||||
// Add the bootstrap flag for administrative UI access
|
|
||||||
isBootstrap: session.user.email === process.env.INITIAL_ADMIN_EMAIL
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -30,10 +19,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||||
|
|
||||||
{/* ThemeRegistry handles the MUI Theme and Cache Provider */}
|
{/* ThemeRegistry handles the MUI Theme and Cache Provider */}
|
||||||
<ThemeRegistry>
|
<ThemeRegistry>
|
||||||
{/* We pass navbarUser (which includes role and isBootstrap)
|
{/* We pass the user object to the Navbar so it can show the profile pic */}
|
||||||
so the Drawer knows whether to show the Settings link.
|
<Navbar user={session?.user} />
|
||||||
*/}
|
|
||||||
<Navbar user={navbarUser} />
|
|
||||||
|
|
||||||
<main style={{ minHeight: '100vh' }}>
|
<main style={{ minHeight: '100vh' }}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
'use server';
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles a user's role between 'ADMIN' and 'USER'.
|
|
||||||
* * Security Logic:
|
|
||||||
* 1. Checks if the caller is the Bootstrap Admin (via .env).
|
|
||||||
* 2. Checks if the caller has the 'ADMIN' role in the database.
|
|
||||||
* 3. Prevents the Bootstrap Admin from being demoted to 'USER'.
|
|
||||||
*/
|
|
||||||
export async function toggleUserRoleAction(targetUserId: string) {
|
|
||||||
const session = await auth();
|
|
||||||
const callerEmail = session?.user?.email;
|
|
||||||
|
|
||||||
if (!callerEmail) {
|
|
||||||
throw new Error("Unauthorized: No session found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Authorization: Who is trying to change the role?
|
|
||||||
const isBootstrap = callerEmail === process.env.INITIAL_ADMIN_EMAIL;
|
|
||||||
|
|
||||||
const callerDbRecord = await prisma.user.findUnique({
|
|
||||||
where: { email: callerEmail },
|
|
||||||
select: { role: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAdmin = isBootstrap || callerDbRecord?.role === "ADMIN";
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
throw new Error("Forbidden: You do not have permission to manage roles.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fetch the target user to be modified
|
|
||||||
const targetUser = await prisma.user.findUnique({
|
|
||||||
where: { id: targetUserId },
|
|
||||||
select: { id: true, email: true, role: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!targetUser) {
|
|
||||||
throw new Error("User not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Protection: Prevent demoting the primary bootstrap admin
|
|
||||||
// This ensures you don't accidentally lock yourself out of the settings page.
|
|
||||||
if (targetUser.email === process.env.INITIAL_ADMIN_EMAIL && targetUser.role === "ADMIN") {
|
|
||||||
throw new Error("Security Restriction: The primary Bootstrap Admin role cannot be removed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Determine new role
|
|
||||||
const newRole = targetUser.role === "ADMIN" ? "USER" : "ADMIN";
|
|
||||||
|
|
||||||
// 5. Execute Update
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: targetUserId },
|
|
||||||
data: { role: newRole }
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Refresh the data on the Settings page
|
|
||||||
revalidatePath("/settings");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `User ${targetUser.email} is now a ${newRole}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Typography,
|
|
||||||
Paper,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Box,
|
|
||||||
Alert,
|
|
||||||
Breadcrumbs
|
|
||||||
} from "@mui/material";
|
|
||||||
import Link from "next/link";
|
|
||||||
import UserRow from "./user-row";
|
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
|
||||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
|
||||||
const session = await auth();
|
|
||||||
const userEmail = session?.user?.email || "";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 1. Access Control
|
|
||||||
* Check if the logged-in user is the Bootstrap Admin from .env
|
|
||||||
* or has the ADMIN role assigned in the database.
|
|
||||||
*/
|
|
||||||
const isBootstrap = userEmail === process.env.INITIAL_ADMIN_EMAIL;
|
|
||||||
const dbUser = await prisma.user.findUnique({
|
|
||||||
where: { email: userEmail },
|
|
||||||
select: { id: true, role: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAdmin = isBootstrap || dbUser?.role === "ADMIN";
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
redirect("/dashboard"); // Unauthorized users are sent back to Dashboard
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 2. Data Fetching
|
|
||||||
* Get all users to display in the management table.
|
|
||||||
*/
|
|
||||||
const allUsers = await prisma.user.findMany({
|
|
||||||
orderBy: { email: 'asc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container maxWidth="md" sx={{ py: 6 }}>
|
|
||||||
{/* Breadcrumbs for easier navigation */}
|
|
||||||
<Breadcrumbs
|
|
||||||
separator={<NavigateNextIcon fontSize="small" />}
|
|
||||||
aria-label="breadcrumb"
|
|
||||||
sx={{ mb: 3 }}
|
|
||||||
>
|
|
||||||
<Link href="/dashboard" style={{ textDecoration: 'none', color: 'inherit' }}>
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
<Typography color="text.primary">Settings</Typography>
|
|
||||||
</Breadcrumbs>
|
|
||||||
|
|
||||||
<Box mb={4} sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
<SettingsIcon color="primary" sx={{ fontSize: 40 }} />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h4" fontWeight={800} color="text.primary">
|
|
||||||
User Management
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" color="text.secondary">
|
|
||||||
Assign Administrative privileges and manage user access.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 3. Bootstrap Warning Box */}
|
|
||||||
{isBootstrap && (
|
|
||||||
<Alert severity="info" variant="outlined" sx={{ mb: 4, borderRadius: 2 }}>
|
|
||||||
You are authenticated via <strong>INITIAL_ADMIN_EMAIL</strong>.
|
|
||||||
This provides permanent access to this page regardless of database settings.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 4. User Management Table */}
|
|
||||||
<Paper elevation={0} sx={{ border: '1px solid #e0e0e0', borderRadius: 3, overflow: 'hidden' }}>
|
|
||||||
<Table>
|
|
||||||
<TableHead sx={{ bgcolor: '#f8f9fa' }}>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell sx={{ fontWeight: 700 }}>User Identity</TableCell>
|
|
||||||
<TableCell sx={{ fontWeight: 700 }}>Current Role</TableCell>
|
|
||||||
<TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{allUsers.length > 0 ? (
|
|
||||||
allUsers.map((user) => (
|
|
||||||
<UserRow
|
|
||||||
key={user.id}
|
|
||||||
user={user}
|
|
||||||
currentUserId={dbUser?.id || ""}
|
|
||||||
isBootstrapAdmin={isBootstrap}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={3} align="center" sx={{ py: 4 }}>
|
|
||||||
<Typography color="text.secondary">No users found in database.</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Box mt={3}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Note: Users must log out and log back in for role changes to take effect in their active session.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
TableRow,
|
|
||||||
TableCell,
|
|
||||||
Chip,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
Typography,
|
|
||||||
Tooltip
|
|
||||||
} from "@mui/material";
|
|
||||||
import { toggleUserRoleAction } from "./actions";
|
|
||||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
|
||||||
import PersonIcon from '@mui/icons-material/Person';
|
|
||||||
import SecurityIcon from '@mui/icons-material/Security';
|
|
||||||
|
|
||||||
interface UserRowProps {
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
name?: string | null;
|
|
||||||
};
|
|
||||||
currentUserId: string;
|
|
||||||
isBootstrapAdmin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserRow({ user, currentUserId, isBootstrapAdmin }: UserRowProps) {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleToggle = async () => {
|
|
||||||
const confirmMsg = `Are you sure you want to change ${user.email} to a ${user.role === 'ADMIN' ? 'USER' : 'ADMIN'}?`;
|
|
||||||
if (!confirm(confirmMsg)) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await toggleUserRoleAction(user.id);
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || "An error occurred while updating the role.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSelf = user.id === currentUserId;
|
|
||||||
const isAdmin = user.role === "ADMIN";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* REVISED LOGIC:
|
|
||||||
* We only "Lock" the button if:
|
|
||||||
* 1. You are the Bootstrap Admin AND
|
|
||||||
* 2. You are already an ADMIN in the database.
|
|
||||||
* This allows you to promote yourself from USER to ADMIN, but prevents demotion.
|
|
||||||
*/
|
|
||||||
const cannotDemote = isBootstrapAdmin && isSelf && isAdmin;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow hover sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
|
|
||||||
<TableCell>
|
|
||||||
<Typography variant="body2" fontWeight={isSelf ? 700 : 400}>
|
|
||||||
{user.email}
|
|
||||||
</Typography>
|
|
||||||
{isSelf && (
|
|
||||||
<Typography variant="caption" color="primary" sx={{ display: 'block' }}>
|
|
||||||
Current Session
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<Chip
|
|
||||||
icon={isAdmin ? <AdminPanelSettingsIcon /> : <PersonIcon />}
|
|
||||||
label={isAdmin ? "ADMIN" : "USER"}
|
|
||||||
color={isAdmin ? "primary" : "default"}
|
|
||||||
variant={isAdmin ? "filled" : "outlined"}
|
|
||||||
size="small"
|
|
||||||
sx={{ fontWeight: 600, px: 1 }}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell align="right">
|
|
||||||
{cannotDemote ? (
|
|
||||||
<Tooltip title="The Primary Admin cannot be demoted to ensure system access.">
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
disabled
|
|
||||||
startIcon={<SecurityIcon />}
|
|
||||||
sx={{ minWidth: 120, textTransform: 'none' }}
|
|
||||||
>
|
|
||||||
Primary Admin
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="contained"
|
|
||||||
color={isAdmin ? "inherit" : "primary"}
|
|
||||||
onClick={handleToggle}
|
|
||||||
disabled={loading}
|
|
||||||
sx={{ minWidth: 120, textTransform: 'none' }}
|
|
||||||
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : null}
|
|
||||||
>
|
|
||||||
{isAdmin ? "Demote to User" : "Promote to Admin"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
'use server';
|
|
||||||
|
|
||||||
//src/app/update/[id]/_actions.ts
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
|
|
||||||
export async function updateFileAction(formData: FormData) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
|
||||||
|
|
||||||
const id = formData.get("id") as string;
|
|
||||||
const name = formData.get("name") as string;
|
|
||||||
const description = formData.get("description") as string;
|
|
||||||
const parentIdRaw = formData.get("parentId") as string;
|
|
||||||
const customMetadataRaw = formData.get("customMetadata") as string;
|
|
||||||
|
|
||||||
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
|
|
||||||
const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Get existing record to preserve system metadata (like mimeType)
|
|
||||||
const existing = await prisma.fileNode.findUnique({ where: { id } });
|
|
||||||
const existingMetadata = (existing?.metadata as Record<string, any>) || {};
|
|
||||||
|
|
||||||
// 2. Update the record
|
|
||||||
await prisma.fileNode.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
parentId,
|
|
||||||
metadata: {
|
|
||||||
...customMetadata, // User's new keys
|
|
||||||
type: name.split('.').pop()?.toUpperCase() || existingMetadata.type || "FILE",
|
|
||||||
mimeType: existingMetadata.mimeType // Preserve the original mimeType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/dashboard");
|
|
||||||
revalidatePath(`/update/${id}`);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Update error:", error);
|
|
||||||
return { success: false, message: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { redirect, notFound } from "next/navigation";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { Container } from "@mui/material";
|
|
||||||
import UpdateView from "./update-view";
|
|
||||||
|
|
||||||
// Note: params is now handled as a Promise
|
|
||||||
export default async function UpdatePage({ params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session) redirect("/");
|
|
||||||
|
|
||||||
// 1. Await the params to get the actual ID
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
// 2. Fetch the specific file using the awaited ID
|
|
||||||
const fileNode = await prisma.fileNode.findUnique({
|
|
||||||
where: { id: id }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fileNode) notFound();
|
|
||||||
|
|
||||||
// Fetch folders for the destination dropdown
|
|
||||||
const folders = await prisma.fileNode.findMany({
|
|
||||||
where: { isFolder: true },
|
|
||||||
orderBy: { name: 'asc' },
|
|
||||||
select: { id: true, name: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container maxWidth="md" sx={{ py: 8 }}>
|
|
||||||
<UpdateView fileNode={fileNode} folders={folders} />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Box, Button, Typography, Paper, Stack,
|
|
||||||
TextField, MenuItem, IconButton, Grid, Divider
|
|
||||||
} from "@mui/material";
|
|
||||||
import SaveIcon from "@mui/icons-material/Save";
|
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
||||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
|
||||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
|
||||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
|
||||||
import FolderIcon from "@mui/icons-material/Folder";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { updateFileAction } from "./_actions";
|
|
||||||
|
|
||||||
interface MetadataPair {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UpdateView({ fileNode, folders }: any) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// 1. Initialize Basic Info
|
|
||||||
const [name, setName] = useState(fileNode.name);
|
|
||||||
const [description, setDescription] = useState(fileNode.description || "");
|
|
||||||
const [parentId, setParentId] = useState(fileNode.parentId || "");
|
|
||||||
|
|
||||||
// 2. Parse existing JSON metadata into Key/Value array for the UI
|
|
||||||
// We filter out 'type' and 'mimeType' as they are system-managed
|
|
||||||
const initialMetadata = Object.entries(fileNode.metadata || {})
|
|
||||||
.filter(([key]) => !['type', 'mimeType'].includes(key))
|
|
||||||
.map(([key, value]) => ({ key, value: String(value) }));
|
|
||||||
|
|
||||||
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>(initialMetadata);
|
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("id", fileNode.id);
|
|
||||||
formData.append("name", name);
|
|
||||||
formData.append("description", description);
|
|
||||||
formData.append("parentId", parentId);
|
|
||||||
|
|
||||||
// Convert array back to object for storage
|
|
||||||
const metadataObj = customMetadata.reduce((acc, curr) => {
|
|
||||||
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, string>);
|
|
||||||
|
|
||||||
formData.append("customMetadata", JSON.stringify(metadataObj));
|
|
||||||
|
|
||||||
const res = await updateFileAction(formData);
|
|
||||||
if (res.success) {
|
|
||||||
router.push("/dashboard");
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
alert("Update failed");
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}>
|
|
||||||
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>
|
|
||||||
Edit File Details
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack spacing={4} sx={{ mt: 2 }}>
|
|
||||||
{/* Name Field */}
|
|
||||||
<TextField
|
|
||||||
label="File Name"
|
|
||||||
fullWidth value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Folder Select */}
|
|
||||||
<TextField
|
|
||||||
id="update-dest-select"
|
|
||||||
select fullWidth label="Destination Folder"
|
|
||||||
value={parentId}
|
|
||||||
onChange={(e) => setParentId(e.target.value)}
|
|
||||||
slotProps={{
|
|
||||||
select: { displayEmpty: true },
|
|
||||||
inputLabel: { shrink: true }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem value=""><em>-- Root --</em></MenuItem>
|
|
||||||
{folders.map((f: any) => (
|
|
||||||
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
{/* Custom Metadata Section */}
|
|
||||||
<Box>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<AssignmentIcon color="primary" /> Custom Attributes
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
startIcon={<AddCircleOutlineIcon />}
|
|
||||||
size="small"
|
|
||||||
onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "" }])}
|
|
||||||
>
|
|
||||||
Add Field
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack spacing={2}>
|
|
||||||
{customMetadata.map((row, index) => (
|
|
||||||
<Grid container spacing={1} key={index} alignItems="center">
|
|
||||||
<Grid item xs={5}>
|
|
||||||
<TextField
|
|
||||||
fullWidth size="small" placeholder="Key"
|
|
||||||
value={row.key}
|
|
||||||
onChange={(e) => {
|
|
||||||
const updated = [...customMetadata];
|
|
||||||
updated[index].key = e.target.value;
|
|
||||||
setCustomMetadata(updated);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6}>
|
|
||||||
<TextField
|
|
||||||
fullWidth size="small" placeholder="Value"
|
|
||||||
value={row.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const updated = [...customMetadata];
|
|
||||||
updated[index].value = e.target.value;
|
|
||||||
setCustomMetadata(updated);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={1}>
|
|
||||||
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}>
|
|
||||||
<DeleteOutlineIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Description"
|
|
||||||
multiline rows={4} fullWidth
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained" size="large" fullWidth
|
|
||||||
startIcon={<SaveIcon />}
|
|
||||||
onClick={handleUpdate}
|
|
||||||
disabled={loading}
|
|
||||||
sx={{ py: 1.5, fontWeight: 'bold' }}
|
|
||||||
>
|
|
||||||
{loading ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
'use server';
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { getFreshAccessToken } from "@/lib/auth-utils";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 1. CREATE FOLDER: Virtual Only
|
|
||||||
* Logic: User-created organizational folders exist ONLY in the database.
|
|
||||||
* No call to OneDrive is made here.
|
|
||||||
*/
|
|
||||||
export async function createFolderAction(name: string, parentId?: string | null) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const internalId = crypto.randomUUID();
|
|
||||||
|
|
||||||
const newNode = await prisma.fileNode.create({
|
|
||||||
data: {
|
|
||||||
id: internalId,
|
|
||||||
oneDriveId: null, // Virtual folders do not have a cloud ID
|
|
||||||
name: name,
|
|
||||||
isFolder: true,
|
|
||||||
path: `virtual:/${name}`,
|
|
||||||
ownerId: session.user.id,
|
|
||||||
parentId: parentId || null,
|
|
||||||
metadata: { type: "FOLDER" }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/upload");
|
|
||||||
revalidatePath("/dashboard");
|
|
||||||
|
|
||||||
return { success: true, node: newNode };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Folder creation error:", error);
|
|
||||||
throw new Error(error.message || "Failed to create virtual folder");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 2. UPLOAD FILE: Physical Container
|
|
||||||
* Logic: Creates a physical folder (UUID) on OneDrive to hold the file.
|
|
||||||
* This ensures every file has a unique storage space in the cloud.
|
|
||||||
*/
|
|
||||||
export async function uploadFileAction(formData: FormData) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
|
||||||
|
|
||||||
const file = formData.get("file") as File;
|
|
||||||
const description = formData.get("description") as string || "";
|
|
||||||
const parentIdRaw = formData.get("parentId") as string | null;
|
|
||||||
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
|
|
||||||
|
|
||||||
const customMetadataRaw = formData.get("customMetadata") as string;
|
|
||||||
const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
|
|
||||||
|
|
||||||
if (!file) throw new Error("No file selected");
|
|
||||||
|
|
||||||
const accessToken = await getFreshAccessToken(session.user.id);
|
|
||||||
const rootFolder = "WebCalibre";
|
|
||||||
const internalId = crypto.randomUUID(); // This UUID will be the OneDrive folder name
|
|
||||||
|
|
||||||
// 1. Create the Physical Storage Folder on OneDrive
|
|
||||||
const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: internalId,
|
|
||||||
folder: {},
|
|
||||||
"@microsoft.graph.conflictBehavior": "fail"
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!createSubFolderRes.ok) {
|
|
||||||
const errorData = await createSubFolderRes.json();
|
|
||||||
throw new Error(errorData.error?.message || "Storage directory creation failed");
|
|
||||||
}
|
|
||||||
const subFolderData = await createSubFolderRes.json();
|
|
||||||
|
|
||||||
// 2. Create Upload Session inside the new Physical Folder
|
|
||||||
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`;
|
|
||||||
const sessionRes = await fetch(sessionUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
|
|
||||||
});
|
|
||||||
|
|
||||||
const { uploadUrl } = await sessionRes.json();
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
|
|
||||||
// 3. PUT the file binary
|
|
||||||
const uploadRes = await fetch(uploadUrl, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Length": `${file.size}`,
|
|
||||||
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
|
|
||||||
},
|
|
||||||
body: buffer
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadedFileData = await uploadRes.json();
|
|
||||||
const oneDriveId = uploadedFileData.id;
|
|
||||||
const extension = file.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
|
|
||||||
|
|
||||||
// 4. Create record in Database
|
|
||||||
// Link it to the VIRTUAL folder via parentId
|
|
||||||
await prisma.fileNode.create({
|
|
||||||
data: {
|
|
||||||
id: internalId,
|
|
||||||
oneDriveId: oneDriveId,
|
|
||||||
name: file.name,
|
|
||||||
description: description,
|
|
||||||
size: BigInt(file.size),
|
|
||||||
isFolder: false,
|
|
||||||
path: `/${rootFolder}/${internalId}/${file.name}`,
|
|
||||||
ownerId: session.user.id,
|
|
||||||
parentId: parentId,
|
|
||||||
metadata: {
|
|
||||||
...customMetadata,
|
|
||||||
type: extension,
|
|
||||||
mimeType: file.type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/dashboard");
|
|
||||||
revalidatePath("/upload");
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import UploadView from "./upload-view";
|
|
||||||
import { Container } from "@mui/material";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export default async function UploadPage() {
|
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
if (!session) redirect("/");
|
|
||||||
|
|
||||||
// Fetch only folders so the user can select a destination
|
|
||||||
const folders = await prisma.fileNode.findMany({
|
|
||||||
where: { isFolder: true },
|
|
||||||
orderBy: { name: 'asc' },
|
|
||||||
select: { id: true, name: true, parentId: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container maxWidth="md" sx={{ py: 8 }}>
|
|
||||||
{/* Pass folders to the view */}
|
|
||||||
<UploadView user={session.user} folders={folders} />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
|
||||||
import {
|
|
||||||
Box, Button, Typography, Paper, Stack,
|
|
||||||
TextField, MenuItem, IconButton, Divider,
|
|
||||||
Grid, CircularProgress
|
|
||||||
} from "@mui/material";
|
|
||||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
|
||||||
import FolderIcon from "@mui/icons-material/Folder";
|
|
||||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
|
||||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
|
||||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
|
||||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { uploadFileAction, createFolderAction } from "./_actions";
|
|
||||||
|
|
||||||
interface MetadataPair {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UploadView({ user, folders = [] }: any) {
|
|
||||||
const router = useRouter();
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [parentId, setParentId] = useState("");
|
|
||||||
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
|
|
||||||
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>([]);
|
|
||||||
|
|
||||||
// Folder Creation State
|
|
||||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
|
||||||
const [newFolderName, setNewFolderName] = useState("");
|
|
||||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
|
||||||
if (!newFolderName.trim()) return;
|
|
||||||
setIsCreatingFolder(true);
|
|
||||||
try {
|
|
||||||
// FIX: Pass the current parentId to the action so it nests correctly
|
|
||||||
const result = await createFolderAction(newFolderName, parentId);
|
|
||||||
if (result.success) {
|
|
||||||
setNewFolderName("");
|
|
||||||
setShowFolderInput(false);
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || "Failed to create folder");
|
|
||||||
} finally {
|
|
||||||
setIsCreatingFolder(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMetadataRow = () => setCustomMetadata([...customMetadata, { key: "", value: "" }]);
|
|
||||||
|
|
||||||
const removeMetadataRow = (index: number) => {
|
|
||||||
setCustomMetadata(customMetadata.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateMetadataRow = (index: number, field: 'key' | 'value', val: string) => {
|
|
||||||
const updated = [...customMetadata];
|
|
||||||
updated[index][field] = val;
|
|
||||||
setCustomMetadata(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpload = async () => {
|
|
||||||
if (!file) return;
|
|
||||||
setStatus('uploading');
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
formData.append("description", description);
|
|
||||||
formData.append("parentId", parentId);
|
|
||||||
|
|
||||||
const metadataObj = customMetadata.reduce((acc, curr) => {
|
|
||||||
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, string>);
|
|
||||||
|
|
||||||
formData.append("customMetadata", JSON.stringify(metadataObj));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await uploadFileAction(formData);
|
|
||||||
if (result.success) {
|
|
||||||
setStatus('success');
|
|
||||||
setFile(null);
|
|
||||||
setDescription("");
|
|
||||||
setCustomMetadata([]);
|
|
||||||
router.push("/dashboard");
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert("Upload failed.");
|
|
||||||
setStatus('idle');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto' }} elevation={3}>
|
|
||||||
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
|
|
||||||
Add to Library
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack spacing={4} sx={{ mt: 4 }}>
|
|
||||||
{/* 1. Destination */}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<FolderIcon color="primary" /> 1. Destination
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<TextField
|
|
||||||
id="project-destination-select"
|
|
||||||
select
|
|
||||||
fullWidth
|
|
||||||
label="Target Project / Folder"
|
|
||||||
value={parentId}
|
|
||||||
onChange={(e) => setParentId(e.target.value)}
|
|
||||||
size="small"
|
|
||||||
slotProps={{
|
|
||||||
select: { displayEmpty: true },
|
|
||||||
inputLabel: { shrink: true },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem value=""><em>-- Root (Main Folder) --</em></MenuItem>
|
|
||||||
{folders.map((f: any) => (
|
|
||||||
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<IconButton
|
|
||||||
color="primary"
|
|
||||||
onClick={() => setShowFolderInput(!showFolderInput)}
|
|
||||||
sx={{ border: '1px solid #ccc', borderRadius: 1 }}
|
|
||||||
>
|
|
||||||
<CreateNewFolderIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{showFolderInput && (
|
|
||||||
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8f9fa', borderRadius: 2 }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
{parentId ? `Create inside current selection` : `Create at Root`}
|
|
||||||
</Typography>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<TextField
|
|
||||||
fullWidth size="small" placeholder="Folder Name (e.g. Project-2)"
|
|
||||||
value={newFolderName}
|
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleCreateFolder()}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleCreateFolder}
|
|
||||||
disabled={isCreatingFolder || !newFolderName}
|
|
||||||
>
|
|
||||||
{isCreatingFolder ? <CircularProgress size={24} /> : "Create"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 2. Upload Area */}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<CloudUploadIcon color="primary" /> 2. Upload File
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={fileInputRef}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
sx={{ p: 4, borderStyle: 'dashed', textTransform: 'none' }}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
{file ? (
|
|
||||||
<Box>
|
|
||||||
<Typography color="success.main" fontWeight="bold">✓ {file.name}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Click to change file ({(file.size / 1024 / 1024).toFixed(2)} MB)
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
"Click to Select File"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 3. Custom Attributes */}
|
|
||||||
<Box>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
|
||||||
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<AssignmentIcon color="primary" /> 3. Custom Attributes
|
|
||||||
</Typography>
|
|
||||||
<Button startIcon={<AddCircleOutlineIcon />} size="small" onClick={addMetadataRow}>
|
|
||||||
Add Field
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack spacing={2}>
|
|
||||||
{customMetadata.map((row, index) => (
|
|
||||||
<Grid container spacing={1} key={index} alignItems="center">
|
|
||||||
<Grid item xs={5}>
|
|
||||||
<TextField
|
|
||||||
fullWidth size="small" placeholder="Key (e.g. Project-ID)"
|
|
||||||
value={row.key} onChange={(e) => updateMetadataRow(index, 'key', e.target.value)}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6}>
|
|
||||||
<TextField
|
|
||||||
fullWidth size="small" placeholder="Value"
|
|
||||||
value={row.value} onChange={(e) => updateMetadataRow(index, 'value', e.target.value)}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={1}>
|
|
||||||
<IconButton color="error" onClick={() => removeMetadataRow(index)}>
|
|
||||||
<DeleteOutlineIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="General Description"
|
|
||||||
multiline rows={2} fullWidth
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained" size="large" fullWidth
|
|
||||||
disabled={!file || status === 'uploading'}
|
|
||||||
onClick={handleUpload}
|
|
||||||
sx={{ py: 2, fontWeight: 'bold' }}
|
|
||||||
>
|
|
||||||
{status === 'uploading' ? 'Uploading to OneDrive...' : 'Start Upload'}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
|
|
||||||
import type { NextAuthConfig } from "next-auth";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
providers: [
|
|
||||||
MicrosoftEntraID({
|
|
||||||
clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID,
|
|
||||||
clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
|
|
||||||
issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER,
|
|
||||||
authorization: {
|
|
||||||
params: {
|
|
||||||
scope: "openid profile offline_access email Files.ReadWrite Files.Read",
|
|
||||||
prompt: "consent", // Forces Microsoft to show the permission screen
|
|
||||||
access_type: "offline",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
profile(profile) {
|
|
||||||
return {
|
|
||||||
id: profile.sub,
|
|
||||||
name: profile.name,
|
|
||||||
email: profile.email,
|
|
||||||
image: null,
|
|
||||||
azureAdUserId: profile.oid ?? profile.sub,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
} satisfies NextAuthConfig;
|
|
||||||
70
src/auth.ts
70
src/auth.ts
|
|
@ -1,57 +1,21 @@
|
||||||
// src/auth.ts
|
import NextAuth from "next-auth"
|
||||||
import NextAuth from "next-auth";
|
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"
|
||||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import authConfig from "./auth.config";
|
|
||||||
|
|
||||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
adapter: PrismaAdapter(prisma),
|
providers: [
|
||||||
session: { strategy: "jwt" },
|
MicrosoftEntraID({
|
||||||
...authConfig,
|
clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID,
|
||||||
callbacks: {
|
clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
|
||||||
async jwt({ token, account, user }) {
|
// Pass tenantId as 'common' to support multi-tenant + personal accounts
|
||||||
// 1. Handle OAuth tokens (from first sign-in)
|
tenantId: process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID,
|
||||||
// This captures the tokens directly from the Microsoft Azure response
|
authorization: {
|
||||||
if (account) {
|
params: {
|
||||||
token.accessToken = account.access_token;
|
// Explicitly ask for these scopes for OneDrive access later
|
||||||
token.refreshToken = account.refresh_token;
|
scope: "openid profile email offline_access User.Read Files.ReadWrite",
|
||||||
token.expiresAt = account.expires_at;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Attach User ID and Role to the token
|
|
||||||
// This runs when the user first logs in
|
|
||||||
if (user) {
|
|
||||||
token.sub = user.id;
|
|
||||||
// @ts-ignore - 'role' is a custom field in your Postgres User table
|
|
||||||
token.role = user.role;
|
|
||||||
}
|
|
||||||
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
|
|
||||||
async session({ session, token }) {
|
|
||||||
// 3. Pass values from the JWT Token into the Client-facing Session
|
|
||||||
// This makes the tokens and IDs available to your API routes and Components
|
|
||||||
if (session?.user) {
|
|
||||||
session.user.id = token.sub as string;
|
|
||||||
|
|
||||||
// @ts-ignore - Attaching the role for UI permissions
|
|
||||||
session.user.role = token.role as string;
|
|
||||||
|
|
||||||
// IMPORTANT: We must attach the accessToken here so the
|
|
||||||
// /api/download route can use it to fetch from MS Graph
|
|
||||||
session.accessToken = token.accessToken as string;
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
})
|
||||||
events: {
|
],
|
||||||
async linkAccount({ account, user }) {
|
// Required for Next.js 15/16 and Nginx production environments
|
||||||
console.log("🔗 Account linked successfully for user:", user.id);
|
trustHost: true,
|
||||||
if (!account.refresh_token) {
|
})
|
||||||
console.warn("⚠️ WARNING: No refresh_token received in linkAccount event!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,68 +1,29 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import {
|
import { AppBar, Toolbar, Typography, Button, Box, Container, Avatar, IconButton } from '@mui/material';
|
||||||
AppBar, Toolbar, Typography, Button, Box, Container,
|
|
||||||
Avatar, IconButton, Drawer, List, ListItem,
|
|
||||||
ListItemButton, ListItemIcon, ListItemText, Divider
|
|
||||||
} from '@mui/material';
|
|
||||||
import MenuIcon from '@mui/icons-material/Menu';
|
|
||||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { signIn, signOut } from "next-auth/react";
|
import { signIn, signOut } from "next-auth/react";
|
||||||
|
|
||||||
// Updated Interface to include Role and Bootstrap status
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
user?: {
|
user?: {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
role?: string; // Added
|
|
||||||
isBootstrap?: boolean; // Added
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navbar({ user }: NavbarProps) {
|
export default function Navbar({ user }: NavbarProps) {
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
|
||||||
const isLoggedIn = !!user;
|
const isLoggedIn = !!user;
|
||||||
|
|
||||||
// Logic: Is this user an Admin?
|
|
||||||
const isAdmin = user?.role === 'ADMIN' || user?.isBootstrap === true;
|
|
||||||
|
|
||||||
const toggleDrawer = (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
|
|
||||||
if (event.type === 'keydown' && ((event as React.KeyboardEvent).key === 'Tab' || (event as React.KeyboardEvent).key === 'Shift')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsDrawerOpen(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build the list dynamically based on permissions
|
|
||||||
const navItems = [
|
|
||||||
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
|
|
||||||
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
|
|
||||||
{ text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Only push Settings if the user has Admin rights
|
|
||||||
if (isAdmin) {
|
|
||||||
navItems.push({ text: 'Settings', icon: <SettingsIcon />, href: '/settings' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<AppBar position="sticky" elevation={0} sx={{ backgroundColor: 'white', color: 'text.primary', borderBottom: '1px solid #e0e0e0' }}>
|
<AppBar position="sticky" elevation={0} sx={{ backgroundColor: 'white', color: 'text.primary', borderBottom: '1px solid #e0e0e0' }}>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<Toolbar disableGutters>
|
<Toolbar disableGutters>
|
||||||
<IconButton
|
{/* Menu Icon for the future AppDrawer */}
|
||||||
edge="start"
|
<IconButton edge="start" color="inherit" aria-label="menu" sx={{ mr: 2 }}>
|
||||||
color="inherit"
|
|
||||||
aria-label="menu"
|
|
||||||
sx={{ mr: 2 }}
|
|
||||||
onClick={toggleDrawer(true)}
|
|
||||||
>
|
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
|
|
@ -78,21 +39,43 @@ export default function Navbar({ user }: NavbarProps) {
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
|
{/* User Info: Name and Small Email */}
|
||||||
<Box sx={{ textAlign: 'right', display: { xs: 'none', sm: 'block' } }}>
|
<Box sx={{ textAlign: 'right', display: { xs: 'none', sm: 'block' } }}>
|
||||||
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>{user.name}</Typography>
|
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>
|
||||||
|
{user.name}
|
||||||
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||||
{user.email} {isAdmin && "(Admin)"}
|
{user.email}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Avatar src={user.image || ""} sx={{ width: 38, height: 38, border: '1px solid #eee' }}>
|
|
||||||
|
{/* Profile Picture */}
|
||||||
|
<Avatar
|
||||||
|
src={user.image || ""}
|
||||||
|
sx={{ width: 38, height: 38, border: '1px solid #eee' }}
|
||||||
|
>
|
||||||
{user.name?.charAt(0)}
|
{user.name?.charAt(0)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Button variant="outlined" color="inherit" size="small" onClick={() => signOut({ callbackUrl: '/' })} sx={{ textTransform: 'none' }}>
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
// We are still calling the function, just giving it a destination!
|
||||||
|
onClick={() => signOut({ callbackUrl: '/' })}
|
||||||
|
sx={{ borderRadius: '8px', textTransform: 'none' }}
|
||||||
|
>
|
||||||
Logout
|
Logout
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="contained" disableElevation onClick={() => signIn("microsoft-entra-id", { callbackUrl: "/dashboard" })} sx={{ textTransform: 'none', px: 3 }}>
|
// Login button triggers the Microsoft flow directly
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disableElevation
|
||||||
|
onClick={() => signIn("microsoft-entra-id", { callbackUrl: "/dashboard" })}
|
||||||
|
sx={{ borderRadius: '8px', textTransform: 'none', px: 3 }}
|
||||||
|
>
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -100,39 +83,5 @@ export default function Navbar({ user }: NavbarProps) {
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</Container>
|
</Container>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
{/* The Drawer (Sidebar) */}
|
|
||||||
<Drawer anchor="left" open={isDrawerOpen} onClose={toggleDrawer(false)}>
|
|
||||||
<Box sx={{ width: 250 }} role="presentation" onClick={toggleDrawer(false)} onKeyDown={toggleDrawer(false)}>
|
|
||||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 2, bgcolor: 'primary.main', color: 'white' }}>
|
|
||||||
<LibraryBooksIcon />
|
|
||||||
<Typography variant="h6" fontWeight={700}>Navigation</Typography>
|
|
||||||
</Box>
|
|
||||||
<Divider />
|
|
||||||
<List>
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<ListItem key={item.text} disablePadding>
|
|
||||||
<ListItemButton component={Link} href={item.href}>
|
|
||||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
|
||||||
<ListItemText primary={item.text} />
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
{/* Visual indicator for non-admins if you want it greyed out instead of hidden */}
|
|
||||||
{!isAdmin && isLoggedIn && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<List>
|
|
||||||
<ListItem sx={{ opacity: 0.5 }}>
|
|
||||||
<ListItemIcon><SettingsIcon /></ListItemIcon>
|
|
||||||
<ListItemText primary="Settings" secondary="Admin Only" />
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Drawer>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
export async function getFreshAccessToken(userId: string) {
|
|
||||||
// 1. Find the account in PostgreSQL
|
|
||||||
const account = await prisma.account.findFirst({
|
|
||||||
where: { userId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!account || !account.refresh_token) {
|
|
||||||
throw new Error("No refresh token available. User might need to re-login.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check if the token is expired (with a 1-minute buffer)
|
|
||||||
// account.expires_at is usually in seconds, so we multiply by 1000
|
|
||||||
const isExpired = (account.expires_at ?? 0) * 1000 < Date.now() + 60000;
|
|
||||||
|
|
||||||
if (!isExpired && account.access_token) {
|
|
||||||
return account.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. If expired, request a new one from Microsoft
|
|
||||||
console.log("🔄 Access token expired. Refreshing for user:", userId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: process.env.AUTH_MICROSOFT_ENTRA_ID_ID!,
|
|
||||||
client_secret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET!,
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: account.refresh_token,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokens = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) throw tokens;
|
|
||||||
|
|
||||||
// 4. Update the Account table with the new tokens
|
|
||||||
await prisma.account.update({
|
|
||||||
where: { id: account.id },
|
|
||||||
data: {
|
|
||||||
access_token: tokens.access_token,
|
|
||||||
expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in),
|
|
||||||
refresh_token: tokens.refresh_token ?? account.refresh_token,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return tokens.access_token;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Failed to refresh Microsoft token:", error);
|
|
||||||
throw new Error("RefreshAccessTokenError");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
|
||||||
import { Pool } from 'pg';
|
|
||||||
|
|
||||||
// 1. Setup the connection pool using your .env.local variable
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
||||||
const adapter = new PrismaPg(pool);
|
|
||||||
|
|
||||||
// 2. Define the singleton logic
|
|
||||||
const prismaClientSingleton = () => {
|
|
||||||
const client = new PrismaClient({ adapter });
|
|
||||||
|
|
||||||
// ✅ HEALTH CHECK: Only runs once per client initialization
|
|
||||||
client.$connect()
|
|
||||||
.then(() => console.log("✅ Prisma 7 connected to PostgreSQL successfully"))
|
|
||||||
.catch((err) => console.error("❌ Prisma connection error:", err));
|
|
||||||
|
|
||||||
return client;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
|
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as {
|
|
||||||
prisma: PrismaClientSingleton | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. Export the client (reusing existing one if it exists)
|
|
||||||
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
|
||||||
|
|
||||||
// BigInt fix for JSON serialization (needed for file sizes)
|
|
||||||
if (typeof BigInt !== 'undefined') {
|
|
||||||
(BigInt.prototype as any).toJSON = function () {
|
|
||||||
return Number(this);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
27
src/proxy.ts
27
src/proxy.ts
|
|
@ -6,30 +6,18 @@ const apiAuthPrefix = "/api/auth";
|
||||||
|
|
||||||
export const proxy = auth((req) => {
|
export const proxy = auth((req) => {
|
||||||
const { nextUrl } = req;
|
const { nextUrl } = req;
|
||||||
const path = nextUrl.pathname;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 1. IMMEDIATE BYPASS FOR UPLOADS
|
|
||||||
* We check this first. If the user is hitting the upload route,
|
|
||||||
* we let the request pass through directly to the page/action.
|
|
||||||
* This prevents the middleware from trying to parse the 100MB body.
|
|
||||||
*/
|
|
||||||
if (path.startsWith('/upload')) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLoggedIn = !!req.auth;
|
const isLoggedIn = !!req.auth;
|
||||||
const isApiAuthRoute = path.startsWith(apiAuthPrefix);
|
|
||||||
|
|
||||||
// Check if the current path is in our protected list
|
const path = nextUrl.pathname;
|
||||||
|
const isApiAuthRoute = path.startsWith(apiAuthPrefix);
|
||||||
const isProtectedRoute = protectedRoutes.includes(path);
|
const isProtectedRoute = protectedRoutes.includes(path);
|
||||||
|
|
||||||
// 2. Allow API Auth calls (Login/Logout/Callback)
|
// 1. Allow API Auth calls (Login/Logout/Callback)
|
||||||
if (isApiAuthRoute) {
|
if (isApiAuthRoute) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Redirect to HOME (/) if trying to access a protected route while logged out
|
// 2. CHANGED: Redirect to HOME (/) instead of /login if logged out
|
||||||
if (isProtectedRoute && !isLoggedIn) {
|
if (isProtectedRoute && !isLoggedIn) {
|
||||||
return NextResponse.redirect(new URL("/", nextUrl));
|
return NextResponse.redirect(new URL("/", nextUrl));
|
||||||
}
|
}
|
||||||
|
|
@ -37,11 +25,6 @@ export const proxy = auth((req) => {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* The Matcher tells Next.js which routes this proxy should run on.
|
|
||||||
* By adding '|upload' to the negative lookahead (?!...), we tell
|
|
||||||
* Next.js to ignore the /upload route entirely at the engine level.
|
|
||||||
*/
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|upload).*)"],
|
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||||
};
|
};
|
||||||
24
src/types/next-auth.d.ts
vendored
24
src/types/next-auth.d.ts
vendored
|
|
@ -1,24 +0,0 @@
|
||||||
// src/types/next-auth.d.ts
|
|
||||||
import NextAuth, { DefaultSession } from "next-auth"
|
|
||||||
import { JWT } from "next-auth/jwt"
|
|
||||||
|
|
||||||
declare module "next-auth" {
|
|
||||||
/**
|
|
||||||
* Returned by `useSession`, `auth`, and received as a prop on the `SessionProvider` React Context
|
|
||||||
*/
|
|
||||||
interface Session {
|
|
||||||
accessToken?: string;
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
role?: string;
|
|
||||||
} & DefaultSession["user"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "next-auth/jwt" {
|
|
||||||
/** Returned by the `jwt` callback and `getToken`, when using JWT sessions */
|
|
||||||
interface JWT {
|
|
||||||
accessToken?: string;
|
|
||||||
role?: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue