Compare commits

..

No commits in common. "main" and "V6.6" have entirely different histories.
main ... V6.6

28 changed files with 375 additions and 3732 deletions

3
.env
View file

@ -15,6 +15,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"

View file

@ -15,6 +15,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"

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -24,7 +24,6 @@
- [7. Run this whenever you change your schema.prisma file](#7-run-this-whenever-you-change-your-schemaprisma-file) - [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) - [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) - [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
@ -342,31 +341,3 @@ I can see a few problems,
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. 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 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 ?

Binary file not shown.

102
package-lock.json generated
View file

@ -13,9 +13,9 @@
"@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", "@mui/x-data-grid": "^8.23.0",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"next": "16.1.1", "next": "16.1.1",
@ -1333,9 +1333,9 @@
} }
}, },
"node_modules/@mui/core-downloads-tracker": { "node_modules/@mui/core-downloads-tracker": {
"version": "7.3.7", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.7.tgz", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.6.tgz",
"integrity": "sha512-8jWwS6FweMkpyRkrJooamUGe1CQfO1yJ+lM43IyUJbrhHW/ObES+6ry4vfGi8EKaldHL3t3BG1bcLcERuJPcjg==", "integrity": "sha512-QaYtTHlr8kDFN5mE1wbvVARRKH7Fdw1ZuOjBJcFdVpfNfRYKF3QLT4rt+WaB6CKJvpqxRsmEo0kpYinhH5GeHg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@ -1369,22 +1369,22 @@
} }
}, },
"node_modules/@mui/material": { "node_modules/@mui/material": {
"version": "7.3.7", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.7.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz",
"integrity": "sha512-6bdIxqzeOtBAj2wAsfhWCYyMKPLkRO9u/2o5yexcL0C3APqyy91iGSWgT3H7hg+zR2XgE61+WAu12wXPON8b6A==", "integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7", "@mui/core-downloads-tracker": "^7.3.6",
"@mui/system": "^7.3.7", "@mui/system": "^7.3.6",
"@mui/types": "^7.4.10", "@mui/types": "^7.4.9",
"@mui/utils": "^7.3.7", "@mui/utils": "^7.3.6",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.12", "@types/react-transition-group": "^4.4.12",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"csstype": "^3.2.3", "csstype": "^3.1.3",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-is": "^19.2.3", "react-is": "^19.2.0",
"react-transition-group": "^4.4.5" "react-transition-group": "^4.4.5"
}, },
"engines": { "engines": {
@ -1397,7 +1397,7 @@
"peerDependencies": { "peerDependencies": {
"@emotion/react": "^11.5.0", "@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
"@mui/material-pigment-css": "^7.3.7", "@mui/material-pigment-css": "^7.3.6",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
@ -1459,13 +1459,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@mui/private-theming": { "node_modules/@mui/private-theming": {
"version": "7.3.7", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.7.tgz", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.6.tgz",
"integrity": "sha512-w7r1+CYhG0syCAQUWAuV5zSaU2/67WA9JXUderdb7DzCIJdp/5RmJv6L85wRjgKCMsxFF0Kfn0kPgPbPgw/jdw==", "integrity": "sha512-Ws9wZpqM+FlnbZXaY/7yvyvWQo1+02Tbx50mVdNmzWEi51C51y56KAbaDCYyulOOBL6BJxuaqG8rNNuj7ivVyw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.7", "@mui/utils": "^7.3.6",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
}, },
"engines": { "engines": {
@ -1486,16 +1486,16 @@
} }
}, },
"node_modules/@mui/styled-engine": { "node_modules/@mui/styled-engine": {
"version": "7.3.7", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.7.tgz", "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.6.tgz",
"integrity": "sha512-y/QkNXv6cF6dZ5APztd/dFWfQ6LHKPx3skyYO38YhQD4+Cxd6sFAL3Z38WMSSC8LQz145Mpp3CcLrSCLKPwYAg==", "integrity": "sha512-+wiYbtvj+zyUkmDB+ysH6zRjuQIJ+CM56w0fEXV+VDNdvOuSywG+/8kpjddvvlfMLsaWdQe5oTuYGBcodmqGzQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@emotion/cache": "^11.14.0", "@emotion/cache": "^11.14.0",
"@emotion/serialize": "^1.3.3", "@emotion/serialize": "^1.3.3",
"@emotion/sheet": "^1.4.0", "@emotion/sheet": "^1.4.0",
"csstype": "^3.2.3", "csstype": "^3.1.3",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
}, },
"engines": { "engines": {
@ -1520,18 +1520,18 @@
} }
}, },
"node_modules/@mui/system": { "node_modules/@mui/system": {
"version": "7.3.7", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.7.tgz", "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.6.tgz",
"integrity": "sha512-DovL3k+FBRKnhmatzUMyO5bKkhMLlQ9L7Qw5qHrre3m8zCZmE+31NDVBFfqrbrA7sq681qaEIHdkWD5nmiAjyQ==", "integrity": "sha512-8fehAazkHNP1imMrdD2m2hbA9sl7Ur6jfuNweh5o4l9YPty4iaZzRXqYvBCWQNwFaSHmMEj2KPbyXGp7Bt73Rg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7", "@mui/private-theming": "^7.3.6",
"@mui/styled-engine": "^7.3.7", "@mui/styled-engine": "^7.3.6",
"@mui/types": "^7.4.10", "@mui/types": "^7.4.9",
"@mui/utils": "^7.3.7", "@mui/utils": "^7.3.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"csstype": "^3.2.3", "csstype": "^3.1.3",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
}, },
"engines": { "engines": {
@ -1560,9 +1560,9 @@
} }
}, },
"node_modules/@mui/types": { "node_modules/@mui/types": {
"version": "7.4.10", "version": "7.4.9",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.10.tgz", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.9.tgz",
"integrity": "sha512-0+4mSjknSu218GW3isRqoxKRTOrTLd/vHi/7UC4+wZcUrOAqD9kRk7UQRL1mcrzqRoe7s3UT6rsRpbLkW5mHpQ==", "integrity": "sha512-dNO8Z9T2cujkSIaCnWwprfeKmTWh97cnjkgmpFJ2sbfXLx8SMZijCYHOtP/y5nnUb/Rm2omxbDMmtUoSaUtKaw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4" "@babel/runtime": "^7.28.4"
@ -1577,17 +1577,17 @@
} }
}, },
"node_modules/@mui/utils": { "node_modules/@mui/utils": {
"version": "7.3.7", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.7.tgz", "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.6.tgz",
"integrity": "sha512-+YjnjMRnyeTkWnspzoxRdiSOgkrcpTikhNPoxOZW0APXx+urHtUoXJ9lbtCZRCA5a4dg5gSbd19alL1DvRs5fg==", "integrity": "sha512-jn+Ba02O6PiFs7nKva8R2aJJ9kJC+3kQ2R0BbKNY3KQQ36Qng98GnPRFTlbwYTdMD6hLEBKaMLUktyg/rTfd2w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/types": "^7.4.10", "@mui/types": "^7.4.9",
"@types/prop-types": "^15.7.15", "@types/prop-types": "^15.7.15",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-is": "^19.2.3" "react-is": "^19.2.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@ -1613,15 +1613,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@mui/x-data-grid": { "node_modules/@mui/x-data-grid": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.23.0.tgz",
"integrity": "sha512-OCTrskRlxwJZ+vYWbgQnQPXWNMA/nY/RIkoESjbCG38N9fJicVCdyjszjcQn9V1NQH4fwkMlIN+DVU6zzbqJxQ==", "integrity": "sha512-fv8AiibTgwPKJsXtYdc5bYDDW3EQp/ieQ0iUejvZ2JbYikMIPTYjI4pHy7zW3F3RDsi6DGQnw5AW6LeoDcT0fA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.5", "@mui/utils": "^7.3.5",
"@mui/x-internals": "8.24.0", "@mui/x-internals": "8.23.0",
"@mui/x-virtualizer": "0.3.1", "@mui/x-virtualizer": "0.3.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
@ -1651,9 +1651,9 @@
} }
}, },
"node_modules/@mui/x-internals": { "node_modules/@mui/x-internals": {
"version": "8.24.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.23.0.tgz",
"integrity": "sha512-tHPk8eUcuwvhNsAc4zcia8y+issjC+bLGssrQQSdLLeLIf9ewFbcwAURNTSnNhreyj/4RJOemVrSxKXpjkdVvQ==", "integrity": "sha512-FN7wdqwTxqq1tJBYVz8TA/HMcViuaHS0Jphr4pEjT/8Iuf94Yt3P82WbsTbXyYrgOQDQl07UqE7qWcJetRcHcg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
@ -1673,14 +1673,14 @@
} }
}, },
"node_modules/@mui/x-virtualizer": { "node_modules/@mui/x-virtualizer": {
"version": "0.3.1", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.3.0.tgz",
"integrity": "sha512-MhQ7U/zmSofBxeCRMiolhZWJ80RUJMFFMRqeehJu5GqscnudiVcplZb0eNsAkz9IjnJTyrjyLS0PY+kn78enOw==", "integrity": "sha512-ScQ3xullKQAtbIT9N7PVhgW9BDTo2Hu8fr/+N08TdRMJcq8bsVJB25mlcHk6zxjlgCuojqaeGfw71S1FOvyCXQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.5", "@mui/utils": "^7.3.5",
"@mui/x-internals": "8.24.0" "@mui/x-internals": "8.23.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"

View file

@ -17,9 +17,9 @@
"@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", "@mui/x-data-grid": "^8.23.0",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"next": "16.1.1", "next": "16.1.1",

View file

@ -6,17 +6,10 @@ generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
// 1. Define the possible roles
enum Role {
USER
ADMIN
}
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
name String? name String? // Renamed from displayName for NextAuth compatibility
email String @unique email String @unique
role Role @default(USER) // 2. Add this line (Defaults to USER)
emailVerified DateTime? emailVerified DateTime?
image String? image String?
azureAdUserId String? @unique azureAdUserId String? @unique
@ -57,9 +50,9 @@ model Session {
} }
model FileNode { model FileNode {
id String @id id String @id // Removed @default(uuid()) to allow manual assignment
name String name String
size BigInt? // Preserved your BigInt size column size BigInt?
isFolder Boolean @default(false) isFolder Boolean @default(false)
oneDriveId String? @unique oneDriveId String? @unique
path String path String
@ -71,8 +64,7 @@ model FileNode {
owner User @relation(fields: [ownerId], references: [id]) owner User @relation(fields: [ownerId], references: [id])
parentId String? parentId String?
// Added onDelete: Cascade here to allow deleting folders and their children automatically parent FileNode? @relation("TreeHierarchy", fields: [parentId], references: [id])
parent FileNode? @relation("TreeHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children FileNode[] @relation("TreeHierarchy") children FileNode[] @relation("TreeHierarchy")
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View file

@ -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 });
}
}

View file

@ -1,186 +1,25 @@
'use server'; 'use server';
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { auth } from "@/auth";
import { getFreshAccessToken } from "@/lib/auth-utils";
/**
* 1. FETCH: Get all file nodes for the Dashboard
*/
export async function getFileNodes() { export async function getFileNodes() {
try { const session = await auth();
if (!session?.user?.id) {
return []; // Return empty if not logged in
}
const nodes = await prisma.fileNode.findMany({ const nodes = await prisma.fileNode.findMany({
where: {
ownerId: session.user.id, // Only get THIS user's files
},
orderBy: { orderBy: {
updatedAt: 'desc', orderIndex: 'asc',
}, },
}); });
return nodes;
} catch (error) { return nodes.map(node => ({
console.error("Error fetching file nodes:", error); ...node,
return []; size: node.size ? Number(node.size) : null,
} }));
}
/**
* 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.");
}
} }

View file

@ -1,279 +1,75 @@
'use client'; "use client";
// src/app/dashboard/dashboard-view.tsx
import { useState } from "react"; import { useState } from "react";
import { import { Button, CircularProgress } from "@mui/material";
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 SyncIcon from "@mui/icons-material/Sync";
import RefreshIcon from "@mui/icons-material/Refresh"; import { DataGrid, GridColDef } from "@mui/x-data-grid";
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 { syncOneDrive } from "./sync-actions";
import { deleteFileAction } from "./actions";
import { useRouter } from "next/navigation"; 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 { interface DashboardViewProps {
initialFiles: any[]; initialFiles: any[];
user?: {
id?: string;
role?: string;
};
} }
export default function DashboardView({ initialFiles, user }: DashboardViewProps) { export default function DashboardView({ initialFiles }: DashboardViewProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const router = useRouter(); 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 () => { const handleSync = async () => {
setLoading(true); setLoading(true);
try { try {
await syncOneDrive(); await syncOneDrive();
// This tells Next.js to re-run the Server Component (page.tsx)
// and fetch the fresh data from Postgres
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
console.error("Sync failed:", error); console.error("Sync failed:", error);
alert("Failed to sync OneDrive");
} finally { } finally {
setLoading(false); 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[] = [ const columns: GridColDef[] = [
{ field: "name", headerName: "File Name", width: 300 },
{ {
field: "name", field: "metadata",
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", headerName: "Type",
width: 120, width: 120,
valueGetter: (value, row) => row.metadata?.type || (row.isFolder ? "Folder" : "File"), valueGetter: (params) => params?.type || "Unknown"
renderCell: (params) => (
<Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold', color: 'text.secondary' }}>
{params.value}
</Typography>
)
}, },
{ {
field: "size", field: "size",
headerName: "Size", headerName: "Size (MB)",
width: 100, width: 120,
renderCell: (params) => params.row.isFolder ? "--" : `${(Number(params.value) / 1024 / 1024).toFixed(2)} MB` valueGetter: (value) => value ? (Number(value) / 1024 / 1024).toFixed(2) : "0"
}, },
{ { field: "updatedAt", headerName: "Last Synced", width: 200 },
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 ( return (
<Box className="space-y-4"> <div className="space-y-4">
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mb: 2 }}> <div className="flex justify-end">
<Button variant="outlined" startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />} onClick={handleRefresh}> <Button
Refresh List variant="contained"
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
onClick={handleSync}
disabled={loading}
>
{loading ? "Syncing..." : "Sync OneDrive"}
</Button> </Button>
<Button variant="contained" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />} onClick={handleSync} disabled={loading}> </div>
Sync OneDrive
</Button>
</Box>
<Box sx={{ height: 700, width: "100%", bgcolor: 'background.paper', borderRadius: 3, boxShadow: 1, overflow: 'hidden' }}> <div style={{ height: 600, width: "100%" }}>
<DataGrid <DataGrid
rows={initialFiles} rows={initialFiles}
columns={columns} columns={columns}
slots={{ toolbar: CustomToolbar }} pageSizeOptions={[10, 25, 50]}
showToolbar
disableRowSelectionOnClick
initialState={{ initialState={{
columns: { pagination: { paginationModel: { pageSize: 10 } },
columnVisibilityModel: {
metadata_search: false,
},
},
}}
sx={{
border: 'none',
'& .MuiDataGrid-columnHeaders': { bgcolor: '#f8f9fa' },
'& .MuiDataGrid-toolbarContainer': { borderBottom: '1px solid #eee' }
}} }}
/> />
</Box> </div>
</Box> </div>
); );
} }

View file

@ -1,9 +1,7 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getFileNodes } from "./actions"; import { getFileNodes } from "./actions";
import DashboardView from "./dashboard-view"; import DashboardView from "./dashboard-view"
import { Box, Typography, Chip } from "@mui/material";
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await auth(); const session = await auth();
@ -16,41 +14,12 @@ export default async function DashboardPage() {
// Fetch initial files from PostgreSQL // Fetch initial files from PostgreSQL
const initialFiles = await getFileNodes(); const initialFiles = await getFileNodes();
// Determine admin status for the header display
// @ts-ignore
const isAdmin = session.user.role === "ADMIN";
return ( return (
<main className="p-8"> <main className="p-8">
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 6 }}> <h1 className="text-2xl font-bold mb-6">My OneDrive Library</h1>
<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 && ( {/* Pass the data to the interactive Client Component */}
<Chip <DashboardView initialFiles={initialFiles} />
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> </main>
); );
} }

View file

@ -1,62 +1,52 @@
'use server'; 'use server';
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { getFreshAccessToken } from "@/lib/auth-utils";
export async function syncOneDrive() { export async function syncOneDrive() {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
try { // 1. Get the OAuth token from your PostgreSQL 'Account' table
const accessToken = await getFreshAccessToken(session.user.id); const account = await prisma.account.findFirst({
const response = await fetch("https://graph.microsoft.com/v1.0/me/drive/root:/WebCalibre:/children", { where: { userId: session.user.id },
headers: { Authorization: `Bearer ${accessToken}` },
}); });
if (!response.ok) return { success: true, count: 0 }; if (!account?.access_token) throw new Error("Microsoft account not linked properly");
// 2. Fetch the files from Microsoft Graph
const response = await fetch("https://graph.microsoft.com/v1.0/me/drive/root/children", {
headers: { Authorization: `Bearer ${account.access_token}` },
});
const data = await response.json(); 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;
// 3. The "Sync Loop": Map OneDrive items to your Postgres FileNode table
for (const item of data.value) { for (const item of data.value) {
const isFolder = !!item.folder; const extension = item.name.split('.').pop()?.toUpperCase() || (item.folder ? 'FOLDER' : 'UNKNOWN');
// 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({ await prisma.fileNode.upsert({
where: { oneDriveId: item.id }, // Primary match where: { oneDriveId: item.id },
update: { update: {
name: item.name, name: item.name,
size: BigInt(item.size || 0), size: item.size,
isFolder: isFolder, metadata: { type: extension }, // Store the "Type" in your JSONB field
path: item.parentReference?.path + '/' + item.name,
updatedAt: new Date(),
}, },
create: { create: {
id: crypto.randomUUID(),
oneDriveId: item.id, oneDriveId: item.id,
name: item.name, name: item.name,
size: BigInt(item.size || 0), size: item.size,
isFolder: isFolder, isFolder: !!item.folder,
path: item.parentReference?.path + '/' + item.name, path: item.parentReference.path + '/' + item.name,
ownerId: session.user.id, ownerId: session.user.id,
metadata: { type: extension, mimeType: item.file?.mimeType || null }, metadata: { type: extension },
} }
}); });
syncedCount++;
} }
// Refresh the dashboard UI to show new data
revalidatePath('/dashboard'); revalidatePath('/dashboard');
return { success: true, count: syncedCount }; return { success: true, count: data.value.length };
} catch (error: any) {
throw new Error(error.message);
}
} }

View file

@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { Box, Button, Typography, Paper, LinearProgress, Container } from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { uploadFileAction } from "../upload-actions";
export default function UploadPage() {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const handleUpload = async () => {
if (!file) return;
setLoading(true);
const formData = new FormData();
formData.append("file", file);
try {
await uploadFileAction(formData);
alert("Book added to WebCalibre!");
setFile(null);
} catch (err) {
alert("Upload failed. Check console.");
} finally {
setLoading(false);
}
};
return (
<Container maxWidth="sm" sx={{ mt: 8 }}>
<Paper elevation={3} sx={{ p: 4, textAlign: 'center', borderRadius: 4 }}>
<Typography variant="h5" gutterBottom fontWeight="bold">
Upload to Library
</Typography>
<Typography color="textSecondary" mb={4}>
Files will be saved in your OneDrive "WebCalibre" folder.
</Typography>
<Box sx={{ border: '2px dashed #ccc', p: 4, mb: 3, borderRadius: 2 }}>
<input
accept=".pdf,.epub,.mobi,.txt"
style={{ display: 'none' }}
id="file-input"
type="file"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
<label htmlFor="file-input">
<Button variant="outlined" component="span" startIcon={<CloudUploadIcon />}>
{file ? file.name : "Choose Book File"}
</Button>
</label>
</Box>
{loading && <LinearProgress sx={{ mb: 2 }} />}
<Button
variant="contained"
fullWidth
size="large"
disabled={!file || loading}
onClick={handleUpload}
>
{loading ? "Uploading..." : "Start Upload"}
</Button>
</Paper>
</Container>
);
}

View file

@ -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}

View file

@ -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}`
};
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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 };
}
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -6,44 +6,34 @@ import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
/** /**
* 1. CREATE FOLDER: Virtual Only * Creates a virtual folder in the database.
* Logic: User-created organizational folders exist ONLY in the database. * No physical folder is created on OneDrive to keep storage flat and fast.
* No call to OneDrive is made here.
*/ */
export async function createFolderAction(name: string, parentId?: string | null) { export async function createFolderAction(name: string, parentId: string | null = null) {
const session = await auth(); const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized"); if (!session?.user?.id) throw new Error("Unauthorized");
try {
const internalId = crypto.randomUUID(); const internalId = crypto.randomUUID();
const newNode = await prisma.fileNode.create({ await prisma.fileNode.create({
data: { data: {
id: internalId, id: internalId,
oneDriveId: null, // Virtual folders do not have a cloud ID
name: name, name: name,
isFolder: true, isFolder: true,
path: `virtual:/${name}`, path: `/virtual/${name}`,
ownerId: session.user.id, ownerId: session.user.id,
parentId: parentId || null, parentId: parentId || null,
metadata: { type: "FOLDER" }
} }
}); });
revalidatePath("/upload"); revalidatePath("/upload");
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true };
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 * Uploads a file to a unique UUID folder on OneDrive
* Logic: Creates a physical folder (UUID) on OneDrive to hold the file. * and links it to a virtual parent in the DB.
* This ensures every file has a unique storage space in the cloud.
*/ */
export async function uploadFileAction(formData: FormData) { export async function uploadFileAction(formData: FormData) {
const session = await auth(); const session = await auth();
@ -51,39 +41,28 @@ export async function uploadFileAction(formData: FormData) {
const file = formData.get("file") as File; const file = formData.get("file") as File;
const description = formData.get("description") as string || ""; const description = formData.get("description") as string || "";
const parentIdRaw = formData.get("parentId") as string | null; const parentId = 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"); if (!file) throw new Error("No file selected");
const accessToken = await getFreshAccessToken(session.user.id); const accessToken = await getFreshAccessToken(session.user.id);
const rootFolder = "WebCalibre"; const rootFolder = "WebCalibre";
const internalId = crypto.randomUUID(); // This UUID will be the OneDrive folder name const internalId = crypto.randomUUID();
// 1. Create the Physical Storage Folder on OneDrive // 1. Ensure WebCalibre exists (Simplified for brevity)
// ... (Keep the root folder check logic from your previous version)
// 2. Create the unique UUID folder on OneDrive
const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, { const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, {
method: "POST", method: "POST",
headers: { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
Authorization: `Bearer ${accessToken}`, body: JSON.stringify({ name: internalId, folder: {}, "@microsoft.graph.conflictBehavior": "fail" })
"Content-Type": "application/json"
},
body: JSON.stringify({
name: internalId,
folder: {},
"@microsoft.graph.conflictBehavior": "fail"
})
}); });
if (!createSubFolderRes.ok) { if (!createSubFolderRes.ok) throw new Error("Storage directory creation failed");
const errorData = await createSubFolderRes.json();
throw new Error(errorData.error?.message || "Storage directory creation failed");
}
const subFolderData = await createSubFolderRes.json(); const subFolderData = await createSubFolderRes.json();
// 2. Create Upload Session inside the new Physical Folder // 3. Create Upload Session & Upload
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`; const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`;
const sessionRes = await fetch(sessionUrl, { const sessionRes = await fetch(sessionUrl, {
method: "POST", method: "POST",
@ -93,43 +72,34 @@ export async function uploadFileAction(formData: FormData) {
const { uploadUrl } = await sessionRes.json(); const { uploadUrl } = await sessionRes.json();
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
// 3. PUT the file binary
const uploadRes = await fetch(uploadUrl, { const uploadRes = await fetch(uploadUrl, {
method: "PUT", method: "PUT",
headers: { headers: { "Content-Length": `${file.size}`, "Content-Range": `bytes 0-${file.size - 1}/${file.size}` },
"Content-Length": `${file.size}`,
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
},
body: buffer body: buffer
}); });
const uploadedFileData = await uploadRes.json(); if (!uploadRes.ok) throw new Error("OneDrive stream failed");
const oneDriveId = uploadedFileData.id; const driveItem = await uploadRes.json();
const extension = file.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
// 4. Create record in Database // 4. Record in DB
// Link it to the VIRTUAL folder via parentId
await prisma.fileNode.create({ await prisma.fileNode.create({
data: { data: {
id: internalId, id: internalId,
oneDriveId: oneDriveId, oneDriveId: driveItem.id,
name: file.name, name: file.name,
description: description, description: description,
size: BigInt(file.size), size: BigInt(file.size),
isFolder: false, isFolder: false,
path: `/${rootFolder}/${internalId}/${file.name}`, path: `/${rootFolder}/${internalId}/${file.name}`,
ownerId: session.user.id, ownerId: session.user.id,
parentId: parentId, parentId: parentId || null, // VIRTUAL HIERARCHY
metadata: { metadata: {
...customMetadata, type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
type: extension,
mimeType: file.type mimeType: file.type
} }
} }
}); });
revalidatePath("/dashboard"); revalidatePath("/dashboard");
revalidatePath("/upload");
return { success: true }; return { success: true };
} }

View file

@ -2,24 +2,16 @@ import { auth } from "@/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import UploadView from "./upload-view"; import UploadView from "./upload-view";
import { Container } from "@mui/material"; import { Container } from "@mui/material";
import { prisma } from "@/lib/prisma";
export default async function UploadPage() { export default async function UploadPage() {
const session = await auth(); const session = await auth();
// Guard: Must be logged in to upload
if (!session) redirect("/"); 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 ( return (
<Container maxWidth="md" sx={{ py: 8 }}> <Container maxWidth="md" sx={{ py: 8 }}>
{/* Pass folders to the view */} <UploadView user={session.user} />
<UploadView user={session.user} folders={folders} />
</Container> </Container>
); );
} }

View file

@ -1,72 +1,58 @@
'use client'; 'use client';
import { useState, useRef } from "react"; import { useState } from "react";
import { import {
Box, Button, Typography, Paper, Stack, Box, Button, Typography, Paper, LinearProgress, Stack,
TextField, MenuItem, IconButton, Divider, TextField, MenuItem, IconButton, Tooltip, Divider
Grid, CircularProgress
} from "@mui/material"; } from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import FolderIcon from "@mui/icons-material/Folder";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; 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"; import { uploadFileAction, createFolderAction } from "./_actions";
interface MetadataPair { /**
key: string; * Format bytes to human readable string (MiB/KiB)
value: string; */
} const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
export default function UploadView({ user, folders = [] }: any) { const k = 1024;
const router = useRouter(); const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const fileInputRef = useRef<HTMLInputElement>(null); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
export default function UploadView({ user, folders = [] }: { user: any, folders?: any[] }) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [parentId, setParentId] = useState(""); const [parentId, setParentId] = useState("");
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle'); const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>([]);
// Folder Creation State // Folder Creation State
const [showFolderInput, setShowFolderInput] = useState(false); const [showFolderInput, setShowFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState(""); const [newFolderName, setNewFolderName] = useState("");
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
// --- RESTORED FILE SIZE LIMITS ---
const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MiB Limit
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
if (!newFolderName.trim()) return; if (!newFolderName.trim()) return;
setIsCreatingFolder(true);
try { try {
// FIX: Pass the current parentId to the action so it nests correctly await createFolderAction(newFolderName);
const result = await createFolderAction(newFolderName, parentId);
if (result.success) {
setNewFolderName(""); setNewFolderName("");
setShowFolderInput(false); setShowFolderInput(false);
router.refresh(); } catch (err) {
alert("Error creating folder");
} }
} 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 () => { const handleUpload = async () => {
if (!file) return; if (!file) return;
// RESTORED: Client-side size validation
if (file.size > MAX_FILE_SIZE) {
alert(`File is too large! Maximum size allowed is ${formatFileSize(MAX_FILE_SIZE)}.`);
return;
}
setStatus('uploading'); setStatus('uploading');
const formData = new FormData(); const formData = new FormData();
@ -74,177 +60,138 @@ export default function UploadView({ user, folders = [] }: any) {
formData.append("description", description); formData.append("description", description);
formData.append("parentId", parentId); 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 { try {
const result = await uploadFileAction(formData); await uploadFileAction(formData);
if (result.success) {
setStatus('success'); setStatus('success');
setFile(null); setFile(null);
setDescription(""); setDescription("");
setCustomMetadata([]);
router.push("/dashboard");
router.refresh();
}
} catch (err) { } catch (err) {
alert("Upload failed."); console.error(err);
alert("Upload failed. Ensure file size is within limits and check server logs.");
setStatus('idle'); setStatus('idle');
} }
}; };
return ( return (
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto' }} elevation={3}> <Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center"> <Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
Add to Library Add to Library
</Typography> </Typography>
<Stack spacing={4} sx={{ mt: 4 }}> <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>
{/* Section 1: Folder Selection */}
<Box>
<Typography variant="subtitle2" gutterBottom fontWeight="bold">
1. Select Target Project / Folder
</Typography>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<TextField <TextField
id="project-destination-select"
select select
fullWidth fullWidth
label="Target Project / Folder" label="Choose Folder"
value={parentId} value={parentId}
onChange={(e) => setParentId(e.target.value)} onChange={(e) => setParentId(e.target.value)}
size="small" disabled={status === 'uploading'}
slotProps={{
select: { displayEmpty: true },
inputLabel: { shrink: true },
}}
> >
<MenuItem value=""><em>-- Root (Main Folder) --</em></MenuItem> <MenuItem value=""><em>None (Root)</em></MenuItem>
{folders.map((f: any) => ( {folders.map((f) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem> <MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
))} ))}
</TextField> </TextField>
<Tooltip title="Create New Folder">
<IconButton <IconButton
color="primary" color="primary"
onClick={() => setShowFolderInput(!showFolderInput)} onClick={() => setShowFolderInput(!showFolderInput)}
sx={{ border: '1px solid #ccc', borderRadius: 1 }} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }}
> >
<CreateNewFolderIcon /> <CreateNewFolderIcon />
</IconButton> </IconButton>
</Tooltip>
</Stack> </Stack>
{showFolderInput && ( {showFolderInput && (
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8f9fa', borderRadius: 2 }}> <Stack direction="row" spacing={1} sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{parentId ? `Create inside current selection` : `Create at Root`}
</Typography>
<Stack direction="row" spacing={1}>
<TextField <TextField
fullWidth size="small" placeholder="Folder Name (e.g. Project-2)" size="small"
fullWidth
placeholder="New folder name..."
value={newFolderName} value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)} onChange={(e) => setNewFolderName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreateFolder()} autoFocus
/> />
<Button <Button variant="contained" onClick={handleCreateFolder}>Create</Button>
variant="contained"
onClick={handleCreateFolder}
disabled={isCreatingFolder || !newFolderName}
>
{isCreatingFolder ? <CircularProgress size={24} /> : "Create"}
</Button>
</Stack> </Stack>
</Box>
)} )}
</Box> </Box>
{/* 2. Upload Area */} <Divider />
<Box>
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CloudUploadIcon color="primary" /> 2. Upload File
</Typography>
{/* Section 2: File Selection */}
<Box>
<Typography variant="subtitle2" gutterBottom fontWeight="bold">
2. Upload File
</Typography>
<Box sx={{ p: 4, border: '2px dashed #ccc', borderRadius: 2, textAlign: 'center', bgcolor: '#fcfcfc' }}>
<input <input
type="file" type="file" id="file-input" hidden
ref={fileInputRef} accept=".pdf,.epub,.mobi,.azw3,.txt,.png,.jpeg"
style={{ display: 'none' }} onChange={(e) => {
onChange={(e) => setFile(e.target.files?.[0] || null)} setFile(e.target.files?.[0] || null);
setStatus('idle');
}}
/> />
<label htmlFor="file-input">
<Button <Button variant="outlined" component="span" startIcon={<CloudUploadIcon />}>
variant="outlined" {file ? "Change File" : "Choose Book/Image"}
fullWidth </Button>
sx={{ p: 4, borderStyle: 'dashed', textTransform: 'none' }} </label>
onClick={() => fileInputRef.current?.click()} {file && (
> <Box mt={2}>
{file ? ( <Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Box> Selected: <strong>{file.name}</strong>
<Typography color="success.main" fontWeight="bold"> {file.name}</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" sx={{ color: file.size > MAX_FILE_SIZE ? 'error.main' : 'text.disabled' }}>
Click to change file ({(file.size / 1024 / 1024).toFixed(2)} MB) Size: {formatFileSize(file.size)} {file.size > MAX_FILE_SIZE && "(Too Large)"}
</Typography> </Typography>
</Box> </Box>
) : (
"Click to Select File"
)} )}
</Button> </Box>
</Box> </Box>
{/* 3. Custom Attributes */} {/* Section 3: Description */}
<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 <TextField
fullWidth size="small" placeholder="Key (e.g. Project-ID)" label="Notes / Description"
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 multiline rows={2} fullWidth
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
disabled={status === 'uploading'}
/> />
</Stack>
{/* Status & Action */}
{status === 'uploading' && (
<Box>
<LinearProgress sx={{ borderRadius: 5, height: 10 }} />
<Typography variant="caption" color="primary" sx={{ mt: 1, display: 'block', textAlign: 'center' }}>
Streaming to OneDrive storage...
</Typography>
</Box> </Box>
)}
<Button <Button
variant="contained" size="large" fullWidth variant="contained" size="large" fullWidth
disabled={!file || status === 'uploading'} disabled={!file || status === 'uploading' || file.size > MAX_FILE_SIZE}
onClick={handleUpload} onClick={handleUpload}
sx={{ py: 2, fontWeight: 'bold' }} sx={{ py: 2, fontWeight: 'bold' }}
> >
{status === 'uploading' ? 'Uploading to OneDrive...' : 'Start Upload'} {status === 'uploading' ? 'Uploading...' : 'Confirm Upload'}
</Button> </Button>
{status === 'success' && (
<Typography align="center" color="success.main" fontWeight="bold">
Successfully Added!
</Typography>
)}
</Stack> </Stack>
</Paper> </Paper>
); );

View file

@ -1,4 +1,3 @@
// src/auth.ts
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter"; import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@ -10,42 +9,25 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
...authConfig, ...authConfig,
callbacks: { callbacks: {
async jwt({ token, account, user }) { async jwt({ token, account, user }) {
// 1. Handle OAuth tokens (from first sign-in) // On the first sign in, 'account' contains the refresh_token
// This captures the tokens directly from the Microsoft Azure response
if (account) { if (account) {
token.accessToken = account.access_token; token.accessToken = account.access_token;
token.refreshToken = account.refresh_token; token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at; token.expiresAt = account.expires_at;
} }
// 2. Attach User ID and Role to the token
// This runs when the user first logs in
if (user) { if (user) {
token.sub = user.id; token.sub = user.id;
// @ts-ignore - 'role' is a custom field in your Postgres User table
token.role = user.role;
} }
return token; return token;
}, },
async session({ session, token }) { async session({ session, token }) {
// 3. Pass values from the JWT Token into the Client-facing Session if (session?.user && token.sub) {
// This makes the tokens and IDs available to your API routes and Components session.user.id = token.sub;
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; return session;
}, },
}, },
// Adding events can help debug if the account is actually linking
events: { events: {
async linkAccount({ account, user }) { async linkAccount({ account, user }) {
console.log("🔗 Account linked successfully for user:", user.id); console.log("🔗 Account linked successfully for user:", user.id);

View file

@ -10,18 +10,14 @@ 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 CloudUploadIcon from '@mui/icons-material/CloudUpload';
import DashboardIcon from '@mui/icons-material/Dashboard'; 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
}; };
} }
@ -29,9 +25,6 @@ export default function Navbar({ user }: NavbarProps) {
const [isDrawerOpen, setIsDrawerOpen] = useState(false); 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) => { 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')) { if (event.type === 'keydown' && ((event as React.KeyboardEvent).key === 'Tab' || (event as React.KeyboardEvent).key === 'Shift')) {
return; return;
@ -39,18 +32,12 @@ export default function Navbar({ user }: NavbarProps) {
setIsDrawerOpen(open); setIsDrawerOpen(open);
}; };
// Build the list dynamically based on permissions
const navItems = [ const navItems = [
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' }, { text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' }, { text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
{ text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' }, { 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' }}>
@ -80,9 +67,7 @@ export default function Navbar({ user }: NavbarProps) {
<> <>
<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}</Typography>
{user.email} {isAdmin && "(Admin)"}
</Typography>
</Box> </Box>
<Avatar src={user.image || ""} sx={{ width: 38, height: 38, border: '1px solid #eee' }}> <Avatar src={user.image || ""} sx={{ width: 38, height: 38, border: '1px solid #eee' }}>
{user.name?.charAt(0)} {user.name?.charAt(0)}
@ -119,18 +104,6 @@ export default function Navbar({ user }: NavbarProps) {
</ListItem> </ListItem>
))} ))}
</List> </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> </Box>
</Drawer> </Drawer>
</> </>

View file

@ -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;
}
}