Working Version-Where we are up to 8/1/2026

This commit is contained in:
stephen 2026-01-08 16:41:31 +11:00
parent 390620a749
commit 59444e9c7c
29 changed files with 2556 additions and 105 deletions

21
.env
View file

@ -1,6 +1,17 @@
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"
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
# 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

6
.env-bak Normal file
View file

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

View file

@ -1,4 +1,5 @@
DATABASE_URL="postgresql://stephen:Web2025%24%24@192.168.1.210:5432/webcalibre2"
# values generated by Gemini
# Generated for security
AUTH_SECRET="7cz3Z4kUI2kB3mdAPo58iioUDLSRJ92X8+boEixvO8k="

2
.gitignore vendored
View file

@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

View file

@ -4,6 +4,7 @@
- [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.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)
- [3. App Registrations](#3-app-registrations)
- [3.1. Permissions Needed](#31-permissions-needed)
@ -14,6 +15,15 @@
- [4.4. Test nextjs](#44-test-nextjs)
- [4.5. Check Authentication](#45-check-authentication)
- [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)
# 1. Reference
@ -37,6 +47,11 @@
[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
The following is the desire structure
@ -230,3 +245,99 @@ created .env.local
```text
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

Binary file not shown.

1343
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,26 +6,37 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"db:migrate": "prisma migrate dev",
"db:generate": "prisma generate",
"db:studio": "prisma studio"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.6",
"@mui/material": "^7.3.6",
"@mui/material-nextjs": "^7.3.6",
"@mui/x-data-grid": "^8.23.0",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"next": "16.1.1",
"next-auth": "^5.0.0-beta.30",
"pg": "^8.16.3",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv-cli": "^11.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"prisma": "^7.2.0",
"typescript": "^5"
}
}

12
prisma.config.ts Normal file
View file

@ -0,0 +1,12 @@
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,
},
});

View file

@ -0,0 +1,54 @@
-- 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;

View file

@ -0,0 +1,52 @@
/*
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;

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

76
prisma/schema.prisma Normal file
View file

@ -0,0 +1,76 @@
datasource db {
provider = "postgresql"
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(uuid())
name String? // Renamed from displayName for NextAuth compatibility
email String @unique
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 @default(uuid())
name String
size BigInt?
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?
parent FileNode? @relation("TreeHierarchy", fields: [parentId], references: [id])
children FileNode[] @relation("TreeHierarchy")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ownerId, path])
@@index([parentId])
@@index([orderIndex])
}

View file

@ -0,0 +1,25 @@
'use server';
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
export async function getFileNodes() {
const session = await auth();
if (!session?.user?.id) {
return []; // Return empty if not logged in
}
const nodes = await prisma.fileNode.findMany({
where: {
ownerId: session.user.id, // Only get THIS user's files
},
orderBy: {
orderIndex: 'asc',
},
});
return nodes.map(node => ({
...node,
size: node.size ? Number(node.size) : null,
}));
}

View file

@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
import { Button, CircularProgress } from "@mui/material";
import SyncIcon from "@mui/icons-material/Sync";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { syncOneDrive } from "./sync-actions";
import { useRouter } from "next/navigation";
interface DashboardViewProps {
initialFiles: any[];
}
export default function DashboardView({ initialFiles }: DashboardViewProps) {
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSync = async () => {
setLoading(true);
try {
await syncOneDrive();
// This tells Next.js to re-run the Server Component (page.tsx)
// and fetch the fresh data from Postgres
router.refresh();
} catch (error) {
console.error("Sync failed:", error);
alert("Failed to sync OneDrive");
} finally {
setLoading(false);
}
};
const columns: GridColDef[] = [
{ field: "name", headerName: "File Name", width: 300 },
{
field: "metadata",
headerName: "Type",
width: 120,
valueGetter: (params) => params?.type || "Unknown"
},
{
field: "size",
headerName: "Size (MB)",
width: 120,
valueGetter: (value) => value ? (Number(value) / 1024 / 1024).toFixed(2) : "0"
},
{ field: "updatedAt", headerName: "Last Synced", width: 200 },
];
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button
variant="contained"
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
onClick={handleSync}
disabled={loading}
>
{loading ? "Syncing..." : "Sync OneDrive"}
</Button>
</div>
<div style={{ height: 600, width: "100%" }}>
<DataGrid
rows={initialFiles}
columns={columns}
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: { paginationModel: { pageSize: 10 } },
}}
/>
</div>
</div>
);
}

View file

@ -1,9 +1,25 @@
import React from 'react'
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { getFileNodes } from "./actions";
import DashboardView from "./dashboard-view"
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();
function Dashboard() {
return (
<div>Dashboard</div>
)
}
<main className="p-8">
<h1 className="text-2xl font-bold mb-6">My OneDrive Library</h1>
export default Dashboard
{/* Pass the data to the interactive Client Component */}
<DashboardView initialFiles={initialFiles} />
</main>
);
}

View file

@ -0,0 +1,52 @@
'use server';
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function syncOneDrive() {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
// 1. Get the OAuth token from your PostgreSQL 'Account' table
const account = await prisma.account.findFirst({
where: { userId: session.user.id },
});
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();
// 3. The "Sync Loop": Map OneDrive items to your Postgres FileNode table
for (const item of data.value) {
const extension = item.name.split('.').pop()?.toUpperCase() || (item.folder ? 'FOLDER' : 'UNKNOWN');
await prisma.fileNode.upsert({
where: { oneDriveId: item.id },
update: {
name: item.name,
size: item.size,
metadata: { type: extension }, // Store the "Type" in your JSONB field
},
create: {
oneDriveId: item.id,
name: item.name,
size: item.size,
isFolder: !!item.folder,
path: item.parentReference.path + '/' + item.name,
ownerId: session.user.id,
metadata: { type: extension },
}
});
}
// Refresh the dashboard UI to show new data
revalidatePath('/dashboard');
return { success: true, count: data.value.length };
}

View file

@ -0,0 +1,68 @@
'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 };
}

View file

@ -0,0 +1,65 @@
"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>
);
}

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

112
src/app/upload/_actions.ts Normal file
View file

@ -0,0 +1,112 @@
'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;
if (!file) throw new Error("No file selected");
const accessToken = await getFreshAccessToken(session.user.id);
const folderName = "WebCalibre";
// --- 1. CHECK/CREATE THE WEBCALIBRE FOLDER ---
// We check if the folder exists at the root of the user's OneDrive
const folderCheckUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}`;
const folderCheck = await fetch(folderCheckUrl, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (folderCheck.status === 404) {
console.log(`📂 Folder '${folderName}' not found. Creating it...`);
const createFolderRes = 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: {}, // Empty object tells Graph to create a folder
"@microsoft.graph.conflictBehavior": "fail"
})
});
if (!createFolderRes.ok) {
const errorData = await createFolderRes.json();
console.error("❌ Folder Creation Error:", errorData);
throw new Error("Could not create WebCalibre folder on OneDrive.");
}
}
// --- 2. CREATE UPLOAD SESSION ---
// encodeURIComponent is vital for filenames with spaces or special characters
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/root:/${folderName}/${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": "rename", // If file exists, name it "Book 1.pdf"
name: file.name
}
})
});
const sessionData = await sessionRes.json();
if (!sessionRes.ok) {
console.error("❌ Session Error:", sessionData);
throw new Error(sessionData.error?.message || "OneDrive session failed");
}
const { uploadUrl } = sessionData;
// --- 3. UPLOAD THE DATA BYTES ---
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
});
if (!uploadRes.ok) {
const uploadError = await uploadRes.json();
console.error("❌ Upload Error:", uploadError);
throw new Error("Chunk upload failed");
}
const driveItem = await uploadRes.json();
// --- 4. RECORD IN POSTGRESQL (PRISMA) ---
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() || "UNKNOWN",
mimeType: file.type
}
}
});
// Revalidate ensures the dashboard list updates immediately
revalidatePath("/dashboard");
return { success: true };
}

17
src/app/upload/page.tsx Normal file
View file

@ -0,0 +1,17 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import UploadView from "./upload-view";
import { Container } from "@mui/material";
export default async function UploadPage() {
const session = await auth();
// Guard: Must be logged in to upload
if (!session) redirect("/");
return (
<Container maxWidth="md" sx={{ py: 8 }}>
<UploadView user={session.user} />
</Container>
);
}

View file

@ -0,0 +1,122 @@
'use client';
import { useState } from "react";
import { Box, Button, Typography, Paper, LinearProgress, Stack } from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { uploadFileAction } from "./_actions";
/**
* Helper function to convert raw bytes into a human-readable string.
* This helps the user understand exactly how large their e-book is.
*/
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
// Returns something like "1.45 MB" or "850 KB"
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
export default function UploadView({ user }: { user: any }) {
const [file, setFile] = useState<File | null>(null);
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB Limit
const handleUpload = async () => {
if (!file) return;
if (file.size > MAX_FILE_SIZE) {
alert(`File is too large! Maximum size allowed is ${formatFileSize(MAX_FILE_SIZE)}.`);
return;
}
setStatus('uploading');
const formData = new FormData();
formData.append("file", file);
try {
await uploadFileAction(formData);
setStatus('success');
setFile(null);
} catch (err) {
// If the server action fails, it usually prints details in the terminal
alert("Upload failed. Ensure the 'WebCalibre' folder can be created and OneDrive has space.");
setStatus('idle');
}
};
return (
<Paper sx={{ p: 6, textAlign: 'center', borderRadius: 4 }} elevation={3}>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>
Upload to WebCalibre
</Typography>
<Typography variant="body1" color="text.secondary" mb={4}>
Adding books as <strong>{user.name}</strong>
</Typography>
<Stack spacing={3} alignItems="center">
{/* Dropzone/Selection Area */}
<Box sx={{ width: '100%', p: 5, border: '2px dashed #ccc', borderRadius: 2, bgcolor: '#f9f9f9' }}>
<input
type="file"
id="book-upload"
hidden
// Only allow common book formats
accept=".pdf,.epub,.mobi,.azw3,.txt"
onChange={(e) => {
setFile(e.target.files?.[0] || null);
setStatus('idle');
}}
/>
<label htmlFor="book-upload">
<Button variant="outlined" component="span" startIcon={<CloudUploadIcon />}>
{file ? "Select Different File" : "Choose File (PDF, EPUB, MOBI)"}
</Button>
</label>
{/* New: Enhanced File Info Display */}
{file && (
<Box mt={3}>
<Typography variant="subtitle2" color="primary.main" fontWeight="bold">
Selected: {file.name}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
File Size: {formatFileSize(file.size)}
</Typography>
</Box>
)}
</Box>
{/* Progress Indicator */}
{status === 'uploading' && (
<Box sx={{ width: '100%' }}>
<Typography variant="caption" display="block" gutterBottom sx={{ color: 'primary.main', fontWeight: 600 }}>
Connecting to OneDrive & Uploading...
</Typography>
<LinearProgress />
</Box>
)}
{/* Action Button */}
<Button
variant="contained"
size="large"
fullWidth
disabled={!file || status === 'uploading'}
onClick={handleUpload}
sx={{ py: 1.5, fontSize: '1.1rem', fontWeight: 700 }}
>
{status === 'uploading' ? 'Please Wait...' : 'Confirm Upload'}
</Button>
{/* Success Feedback */}
{status === 'success' && (
<Typography color="success.main" fontWeight="bold" sx={{ mt: 2 }}>
Successfully uploaded to your library!
</Typography>
)}
</Stack>
</Paper>
);
}

28
src/auth.config.ts Normal file
View file

@ -0,0 +1,28 @@
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;

View file

@ -1,21 +1,39 @@
import NextAuth from "next-auth"
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import authConfig from "./auth.config";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
MicrosoftEntraID({
clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID,
clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
// Pass tenantId as 'common' to support multi-tenant + personal accounts
tenantId: process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID,
authorization: {
params: {
// Explicitly ask for these scopes for OneDrive access later
scope: "openid profile email offline_access User.Read Files.ReadWrite",
},
},
})
],
// Required for Next.js 15/16 and Nginx production environments
trustHost: true,
})
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
...authConfig,
callbacks: {
async jwt({ token, account, user }) {
// On the first sign in, 'account' contains the refresh_token
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
}
if (user) {
token.sub = user.id;
}
return token;
},
async session({ session, token }) {
if (session?.user && token.sub) {
session.user.id = token.sub;
}
return session;
},
},
// Adding events can help debug if the account is actually linking
events: {
async linkAccount({ account, user }) {
console.log("🔗 Account linked successfully for user:", user.id);
if (!account.refresh_token) {
console.warn("⚠️ WARNING: No refresh_token received in linkAccount event!");
}
}
}
});

View file

@ -1,9 +1,15 @@
'use client';
import React from 'react';
import { AppBar, Toolbar, Typography, Button, Box, Container, Avatar, IconButton } from '@mui/material';
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
import React, { useState } from 'react';
import {
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 CloudUploadIcon from '@mui/icons-material/CloudUpload';
import DashboardIcon from '@mui/icons-material/Dashboard';
import Link from 'next/link';
import { signIn, signOut } from "next-auth/react";
@ -16,72 +22,90 @@ interface NavbarProps {
}
export default function Navbar({ user }: NavbarProps) {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const isLoggedIn = !!user;
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);
};
const navItems = [
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
{ text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' },
];
return (
<AppBar position="sticky" elevation={0} sx={{ backgroundColor: 'white', color: 'text.primary', borderBottom: '1px solid #e0e0e0' }}>
<Container maxWidth="lg">
<Toolbar disableGutters>
{/* Menu Icon for the future AppDrawer */}
<IconButton edge="start" color="inherit" aria-label="menu" sx={{ mr: 2 }}>
<MenuIcon />
</IconButton>
<>
<AppBar position="sticky" elevation={0} sx={{ backgroundColor: 'white', color: 'text.primary', borderBottom: '1px solid #e0e0e0' }}>
<Container maxWidth="lg">
<Toolbar disableGutters>
<IconButton
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
onClick={toggleDrawer(true)}
>
<MenuIcon />
</IconButton>
<Typography
variant="h6"
component={Link}
href="/"
sx={{ fontWeight: 700, color: 'primary.main', textDecoration: 'none', flexGrow: 1 }}
>
WebCalibre 2
</Typography>
<Typography
variant="h6"
component={Link}
href="/"
sx={{ fontWeight: 700, color: 'primary.main', textDecoration: 'none', flexGrow: 1 }}
>
WebCalibre 2
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{isLoggedIn ? (
<>
{/* User Info: Name and Small Email */}
<Box sx={{ textAlign: 'right', display: { xs: 'none', sm: 'block' } }}>
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>
{user.name}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
{user.email}
</Typography>
</Box>
{/* Profile Picture */}
<Avatar
src={user.image || ""}
sx={{ width: 38, height: 38, border: '1px solid #eee' }}
>
{user.name?.charAt(0)}
</Avatar>
<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
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{isLoggedIn ? (
<>
<Box sx={{ textAlign: 'right', display: { xs: 'none', sm: 'block' } }}>
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>{user.name}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>{user.email}</Typography>
</Box>
<Avatar src={user.image || ""} sx={{ width: 38, height: 38, border: '1px solid #eee' }}>
{user.name?.charAt(0)}
</Avatar>
<Button variant="outlined" color="inherit" size="small" onClick={() => signOut({ callbackUrl: '/' })} sx={{ textTransform: 'none' }}>
Logout
</Button>
</>
) : (
<Button variant="contained" disableElevation onClick={() => signIn("microsoft-entra-id", { callbackUrl: "/dashboard" })} sx={{ textTransform: 'none', px: 3 }}>
Login
</Button>
</>
) : (
// 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
</Button>
)}
)}
</Box>
</Toolbar>
</Container>
</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>
</Toolbar>
</Container>
</AppBar>
<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>
</Box>
</Drawer>
</>
);
}

55
src/lib/auth-utils.ts Normal file
View file

@ -0,0 +1,55 @@
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");
}
}

37
src/lib/prisma.ts Normal file
View file

@ -0,0 +1,37 @@
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);
};
}

View file

@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {auth} from "@/auth";
const protectedRoutes = ["/dashboard", "/profile"];
const apiAuthPrefix = "/api/auth";