diff --git a/.env b/.env
index 667e6ce..ca0d499 100644
--- a/.env
+++ b/.env
@@ -14,4 +14,7 @@ AUTH_MICROSOFT_ENTRA_ID_ID="b549931a-f491-436b-b7bc-d37d8ca3c17e"
AUTH_MICROSOFT_ENTRA_ID_SECRET="O3p8Q~oMph-0kSLwkvzEJzJdx_iHGOjJjrr5Pa1A"
# Required to tell Auth.js to trust your localhost/proxy URL
-AUTH_TRUST_HOST=true
\ No newline at end of file
+AUTH_TRUST_HOST=true
+
+# added initial admin user
+INITIAL_ADMIN_EMAIL="slohning@live.com.au"
\ No newline at end of file
diff --git a/.env.local b/.env.local
index 667e6ce..ca0d499 100644
--- a/.env.local
+++ b/.env.local
@@ -14,4 +14,7 @@ AUTH_MICROSOFT_ENTRA_ID_ID="b549931a-f491-436b-b7bc-d37d8ca3c17e"
AUTH_MICROSOFT_ENTRA_ID_SECRET="O3p8Q~oMph-0kSLwkvzEJzJdx_iHGOjJjrr5Pa1A"
# Required to tell Auth.js to trust your localhost/proxy URL
-AUTH_TRUST_HOST=true
\ No newline at end of file
+AUTH_TRUST_HOST=true
+
+# added initial admin user
+INITIAL_ADMIN_EMAIL="slohning@live.com.au"
\ No newline at end of file
diff --git a/docs/notes.md b/docs/notes.md
index 8409b5c..f75b471 100644
--- a/docs/notes.md
+++ b/docs/notes.md
@@ -24,6 +24,7 @@
- [7. Run this whenever you change your schema.prisma file](#7-run-this-whenever-you-change-your-schemaprisma-file)
- [7.1. Pro-Tip: Update your package.json](#71-pro-tip-update-your-packagejson)
- [8. Where we are up to 8/1/2026](#8-where-we-are-up-to-812026)
+- [9. Testing Creating And Uploading Folders and Files](#9-testing-creating-and-uploading-folders-and-files)
# 1. Reference
@@ -340,4 +341,14 @@ 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.
-4. The Upload file Page needs to be able to create folder as well select a parent , add a description of either the file
\ No newline at end of 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
diff --git a/docs/notes.pdf b/docs/notes.pdf
index 3b6ec2c..fb9012d 100644
Binary files a/docs/notes.pdf and b/docs/notes.pdf differ
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b55b756..383c356 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -6,10 +6,17 @@ generator client {
provider = "prisma-client-js"
}
+// 1. Define the possible roles
+enum Role {
+ USER
+ ADMIN
+}
+
model User {
id String @id @default(uuid())
- name String? // Renamed from displayName for NextAuth compatibility
+ name String?
email String @unique
+ role Role @default(USER) // 2. Add this line (Defaults to USER)
emailVerified DateTime?
image String?
azureAdUserId String? @unique
@@ -50,9 +57,9 @@ model Session {
}
model FileNode {
- id String @id // Removed @default(uuid()) to allow manual assignment
+ id String @id
name String
- size BigInt?
+ size BigInt? // Preserved your BigInt size column
isFolder Boolean @default(false)
oneDriveId String? @unique
path String
@@ -64,7 +71,8 @@ model FileNode {
owner User @relation(fields: [ownerId], references: [id])
parentId String?
- parent FileNode? @relation("TreeHierarchy", fields: [parentId], references: [id])
+ // Added onDelete: Cascade here to allow deleting folders and their children automatically
+ parent FileNode? @relation("TreeHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children FileNode[] @relation("TreeHierarchy")
createdAt DateTime @default(now())
diff --git a/src/app/dashboard/actions.ts b/src/app/dashboard/actions.ts
index def547a..9edb37e 100644
--- a/src/app/dashboard/actions.ts
+++ b/src/app/dashboard/actions.ts
@@ -1,25 +1,116 @@
'use server';
-import { prisma } from "@/lib/prisma";
+
import { auth } from "@/auth";
+import { prisma } from "@/lib/prisma";
+import { revalidatePath } from "next/cache";
+import { getFreshAccessToken } from "@/lib/auth-utils";
+/**
+ * 1. FETCH: Get all file nodes
+ */
export async function getFileNodes() {
- const session = await auth();
-
- if (!session?.user?.id) {
- return []; // Return empty if not logged in
+ try {
+ const nodes = await prisma.fileNode.findMany({
+ orderBy: {
+ updatedAt: 'desc',
+ },
+ });
+ return nodes;
+ } catch (error) {
+ console.error("Error fetching file nodes:", error);
+ return [];
}
+}
- const nodes = await prisma.fileNode.findMany({
- where: {
- ownerId: session.user.id, // Only get THIS user's files
- },
- orderBy: {
- orderIndex: 'asc',
- },
+/**
+ * 2. DELETE: Remove from OneDrive and Database
+ * Improved to ensure DB removal even if OneDrive API returns errors.
+ */
+export async function deleteFileAction(fileId: string) {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Unauthorized");
+
+ // 1. Find the node to check permissions
+ const node = await prisma.fileNode.findUnique({
+ where: { id: fileId },
});
- return nodes.map(node => ({
- ...node,
- size: node.size ? Number(node.size) : null,
- }));
+ if (!node) {
+ // If not in DB, trigger a refresh anyway to clear the UI
+ revalidatePath("/dashboard");
+ return { success: true, message: "Item already removed from database" };
+ }
+
+ // @ts-ignore - role added via auth callbacks
+ const isAdmin = session.user.role === "ADMIN";
+ const isOwner = node.ownerId === session.user.id;
+
+ if (!isAdmin && !isOwner) {
+ throw new Error("Permission Denied: You do not have authority to delete this item.");
+ }
+
+ if (node.isFolder && !isAdmin) {
+ throw new Error("Security Restriction: Only Administrators can delete folders.");
+ }
+
+ // 2. OneDrive Deletion Attempt
+ try {
+ const accessToken = await getFreshAccessToken(session.user.id);
+
+ if (accessToken) {
+ const rootFolder = "WebCalibre";
+ // We target the folder on OneDrive by the ID stored in our DB
+ const onedrivePath = `https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}/${node.id}`;
+
+ const onedriveRes = await fetch(onedrivePath, {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+
+ if (!onedriveRes.ok && onedriveRes.status !== 404) {
+ const errData = await onedriveRes.json().catch(() => ({}));
+ console.warn("OneDrive Deletion Warning (Cloud record might still exist):", errData);
+ }
+ }
+ } catch (cloudError) {
+ // We catch cloud errors but DO NOT throw them, so we can proceed to delete the DB record
+ console.error("Cloud communication failed, proceeding with DB cleanup:", cloudError);
+ }
+
+ // 3. Database Deletion (The Source of Truth for your UI)
+ try {
+ await prisma.fileNode.delete({
+ where: { id: fileId }
+ });
+
+ revalidatePath("/dashboard");
+ revalidatePath("/upload");
+
+ return { success: true };
+ } catch (dbError: any) {
+ console.error("Database deletion failed:", dbError);
+ throw new Error("Failed to remove the record from the database.");
+ }
+}
+
+/**
+ * 3. MOVE: Assign file to folder or folder to another folder
+ */
+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.");
+ }
}
\ No newline at end of file
diff --git a/src/app/dashboard/dashboard-view.tsx b/src/app/dashboard/dashboard-view.tsx
index 915bee1..62f0c09 100644
--- a/src/app/dashboard/dashboard-view.tsx
+++ b/src/app/dashboard/dashboard-view.tsx
@@ -1,26 +1,52 @@
"use client";
import { useState } from "react";
-import { Button, CircularProgress } from "@mui/material";
+import {
+ Button, CircularProgress, Box, Chip, IconButton, Tooltip, Typography
+} from "@mui/material";
import SyncIcon from "@mui/icons-material/Sync";
+import RefreshIcon from "@mui/icons-material/Refresh";
+import FolderIcon from "@mui/icons-material/Folder";
+import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
+import DeleteIcon from "@mui/icons-material/Delete";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { syncOneDrive } from "./sync-actions";
+import { deleteFileAction } from "./actions"; // Un-commented and assumed active
import { useRouter } from "next/navigation";
interface DashboardViewProps {
initialFiles: any[];
+ user?: {
+ id?: string;
+ role?: string;
+ };
}
-export default function DashboardView({ initialFiles }: DashboardViewProps) {
+export default function DashboardView({ initialFiles, user }: DashboardViewProps) {
const [loading, setLoading] = useState(false);
+ const [isRefreshing, setIsRefreshing] = useState(false);
const router = useRouter();
+ const isAdmin = user?.role === "ADMIN";
+
+ /**
+ * Recursive function to build the virtual path for the "Location" column.
+ * Ensures we see "Projects / Project-1" instead of UUIDs or "ROOT".
+ */
+ const getVirtualPath = (parentId: string | null): string => {
+ if (!parentId) return "WebCalibre";
+ const parent = initialFiles.find((f) => f.id === parentId);
+ if (!parent) return "WebCalibre";
+
+ // Recursive lookup for breadcrumbs
+ const prefix = parent.parentId ? `${getVirtualPath(parent.parentId)} / ` : "";
+ return `${prefix}${parent.name}`;
+ };
+
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);
@@ -30,26 +56,133 @@ export default function DashboardView({ initialFiles }: DashboardViewProps) {
}
};
+ const handleRefresh = () => {
+ setIsRefreshing(true);
+ router.refresh();
+ // Visual feedback for the refresh action
+ 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");
+ }
+ };
+
const columns: GridColDef[] = [
- { field: "name", headerName: "File Name", width: 300 },
{
- field: "metadata",
+ field: "name",
+ headerName: "Name",
+ flex: 1.5,
+ minWidth: 250,
+ renderCell: (params) => (
+
+ {params.row.isFolder ? (
+ // Folder yellow
+ ) : (
+
+ )}
+
+ {params.value}
+
+
+ )
+ },
+ {
+ field: "parentId",
+ headerName: "Location (Project)",
+ flex: 1,
+ minWidth: 200,
+ renderCell: (params) => {
+ const path = getVirtualPath(params.value);
+ return (
+
+
+
+ );
+ }
+ },
+ {
+ field: "type",
headerName: "Type",
- width: 120,
- valueGetter: (params) => params?.type || "Unknown"
+ width: 100,
+ valueGetter: (value, row) => {
+ // Priority: Metadata -> isFolder check -> fallback
+ const type = row.metadata?.type || (row.isFolder ? "FOLDER" : "FILE");
+ // Ensure we don't display a UUID here if the sync was messy
+ return type.length > 10 && row.isFolder ? "FOLDER" : type;
+ },
+ renderCell: (params) => (
+
+ {params.value}
+
+ )
},
{
field: "size",
- headerName: "Size (MB)",
+ headerName: "Size",
width: 120,
- valueGetter: (value) => value ? (Number(value) / 1024 / 1024).toFixed(2) : "0"
+ valueGetter: (value, row) => {
+ if (row.isFolder) return null;
+ return value;
+ },
+ renderCell: (params) => {
+ if (params.value === null) return "--";
+ const mb = (Number(params.value) / 1024 / 1024).toFixed(2);
+ return `${mb} MB`;
+ }
},
- { field: "updatedAt", headerName: "Last Synced", width: 200 },
+ {
+ field: "actions",
+ headerName: "Actions",
+ width: 100,
+ sortable: false,
+ align: 'right',
+ headerAlign: 'right',
+ renderCell: (params) => {
+ const isOwner = params.row.ownerId === user?.id;
+ if (isAdmin || isOwner) {
+ return (
+ {
+ e.stopPropagation();
+ handleDelete(params.row.id, params.row.name);
+ }}
+ >
+
+
+ );
+ }
+ return null;
+ }
+ }
];
return (
-
-
+
+
+ : }
+ onClick={handleRefresh}
+ disabled={isRefreshing}
+ >
+ {isRefreshing ? "Refreshing..." : "Refresh List"}
+
+
: }
@@ -58,18 +191,39 @@ export default function DashboardView({ initialFiles }: DashboardViewProps) {
>
{loading ? "Syncing..." : "Sync OneDrive"}
-
+
-
+
-
-
+
+
);
}
\ No newline at end of file
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index d2f58cd..b45a344 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -1,7 +1,9 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
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() {
const session = await auth();
@@ -14,12 +16,41 @@ export default async function DashboardPage() {
// Fetch initial files from PostgreSQL
const initialFiles = await getFileNodes();
+ // Determine admin status for the header display
+ // @ts-ignore
+ const isAdmin = session.user.role === "ADMIN";
+
return (
- My OneDrive Library
+
+
+
+ My OneDrive Library
+
+
+ Manage your synchronized files and project folders.
+
+
+
+ {isAdmin && (
+ }
+ label="Admin Access"
+ color="primary"
+ variant="outlined"
+ sx={{ fontWeight: 600 }}
+ />
+ )}
+
- {/* Pass the data to the interactive Client Component */}
-
+ {/* Pass both initialFiles AND the user object.
+ The DashboardView will use user.role and user.id to
+ decide who can see the 'Delete' button.
+ */}
+
);
}
\ No newline at end of file
diff --git a/src/app/dashboard/sync-actions.ts b/src/app/dashboard/sync-actions.ts
index 18ab0ab..9a4e2e2 100644
--- a/src/app/dashboard/sync-actions.ts
+++ b/src/app/dashboard/sync-actions.ts
@@ -3,50 +3,93 @@
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
+import { getFreshAccessToken } from "@/lib/auth-utils";
export async function syncOneDrive() {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
- // 1. Get the OAuth token from your PostgreSQL 'Account' table
- const account = await prisma.account.findFirst({
- where: { userId: session.user.id },
- });
+ try {
+ const accessToken = await getFreshAccessToken(session.user.id);
- if (!account?.access_token) throw new Error("Microsoft account not linked properly");
+ if (!accessToken) {
+ throw new Error("Microsoft account not linked properly or session expired.");
+ }
- // 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
+ const response = await fetch("https://graph.microsoft.com/v1.0/me/drive/root:/WebCalibre:/children", {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json"
},
- 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 },
- }
});
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ if (response.status === 404) {
+ return { success: true, count: 0 };
+ }
+ throw new Error(errorData.error?.message || "Failed to fetch data from OneDrive");
+ }
+
+ const data = await response.json();
+ let syncedCount = 0;
+
+ // Regular expression to identify if a string is a UUID
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+ for (const item of data.value) {
+ const isFolder = !!item.folder;
+
+ /**
+ * FILTER: If the item is a folder and the name is a UUID, skip it.
+ * These are storage containers created by the upload action, not user-facing folders.
+ */
+ if (isFolder && uuidRegex.test(item.name)) {
+ continue;
+ }
+
+ const extension = isFolder
+ ? 'FOLDER'
+ : (item.name.split('.').pop()?.toUpperCase() || 'UNKNOWN');
+
+ const fileSize = item.size ? BigInt(item.size) : BigInt(0);
+
+ await prisma.fileNode.upsert({
+ where: { oneDriveId: item.id },
+ update: {
+ name: item.name,
+ size: fileSize,
+ isFolder: isFolder,
+ metadata: {
+ type: extension,
+ mimeType: item.file?.mimeType || null
+ },
+ path: item.parentReference?.path + '/' + item.name,
+ updatedAt: new Date(),
+ },
+ create: {
+ id: crypto.randomUUID(),
+ oneDriveId: item.id,
+ name: item.name,
+ size: fileSize,
+ isFolder: isFolder,
+ path: item.parentReference?.path + '/' + item.name,
+ ownerId: session.user.id,
+ metadata: {
+ type: extension,
+ mimeType: item.file?.mimeType || null
+ },
+ }
+ });
+ syncedCount++;
+ }
+
+ revalidatePath('/dashboard');
+ return { success: true, count: syncedCount };
+
+ } catch (error: any) {
+ console.error("Sync Process Failure:", error.message);
+ throw new Error(error.message || "An unexpected error occurred during sync.");
}
-
- // Refresh the dashboard UI to show new data
- revalidatePath('/dashboard');
- return { success: true, count: data.value.length };
}
\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 62560da..6c6275a 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -12,6 +12,17 @@ export default async function RootLayout({ children }: { children: React.ReactNo
// Fetch the session server-side to prevent UI flickering
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 (
@@ -19,8 +30,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
{/* ThemeRegistry handles the MUI Theme and Cache Provider */}
- {/* We pass the user object to the Navbar so it can show the profile pic */}
-
+ {/* We pass navbarUser (which includes role and isBootstrap)
+ so the Drawer knows whether to show the Settings link.
+ */}
+
{children}
diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts
new file mode 100644
index 0000000..cfc6115
--- /dev/null
+++ b/src/app/settings/actions.ts
@@ -0,0 +1,68 @@
+'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}`
+ };
+}
\ No newline at end of file
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
new file mode 100644
index 0000000..060c61d
--- /dev/null
+++ b/src/app/settings/page.tsx
@@ -0,0 +1,123 @@
+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 (
+
+ {/* Breadcrumbs for easier navigation */}
+ }
+ aria-label="breadcrumb"
+ sx={{ mb: 3 }}
+ >
+
+ Dashboard
+
+ Settings
+
+
+
+
+
+
+ User Management
+
+
+ Assign Administrative privileges and manage user access.
+
+
+
+
+ {/* 3. Bootstrap Warning Box */}
+ {isBootstrap && (
+
+ You are authenticated via INITIAL_ADMIN_EMAIL.
+ This provides permanent access to this page regardless of database settings.
+
+ )}
+
+ {/* 4. User Management Table */}
+
+
+
+
+ User Identity
+ Current Role
+ Actions
+
+
+
+ {allUsers.length > 0 ? (
+ allUsers.map((user) => (
+
+ ))
+ ) : (
+
+
+ No users found in database.
+
+
+ )}
+
+
+
+
+
+
+ Note: Users must log out and log back in for role changes to take effect in their active session.
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/settings/user-row.tsx b/src/app/settings/user-row.tsx
new file mode 100644
index 0000000..ef03e78
--- /dev/null
+++ b/src/app/settings/user-row.tsx
@@ -0,0 +1,113 @@
+'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 (
+
+
+
+ {user.email}
+
+ {isSelf && (
+
+ Current Session
+
+ )}
+
+
+
+ : }
+ label={isAdmin ? "ADMIN" : "USER"}
+ color={isAdmin ? "primary" : "default"}
+ variant={isAdmin ? "filled" : "outlined"}
+ size="small"
+ sx={{ fontWeight: 600, px: 1 }}
+ />
+
+
+
+ {cannotDemote ? (
+
+
+ }
+ sx={{ minWidth: 120, textTransform: 'none' }}
+ >
+ Primary Admin
+
+
+
+ ) : (
+ : null}
+ >
+ {isAdmin ? "Demote to User" : "Promote to Admin"}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/upload/_actions.ts b/src/app/upload/_actions.ts
index 9210685..7fd4013 100644
--- a/src/app/upload/_actions.ts
+++ b/src/app/upload/_actions.ts
@@ -7,12 +7,14 @@ import { revalidatePath } from "next/cache";
/**
* Creates a virtual folder in the database.
- * No physical folder is created on OneDrive to keep storage flat and fast.
+ * We explicitly set the metadata type to "FOLDER" so the Dashboard icon
+ * and Location logic work immediately.
*/
export async function createFolderAction(name: string, parentId: string | null = null) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
+ // We generate a manual UUID because the schema requires 'id' but has no default generator
const internalId = crypto.randomUUID();
await prisma.fileNode.create({
@@ -23,6 +25,10 @@ export async function createFolderAction(name: string, parentId: string | null =
path: `/virtual/${name}`,
ownerId: session.user.id,
parentId: parentId || null,
+ metadata: {
+ type: "FOLDER",
+ mimeType: "inode/directory"
+ }
}
});
@@ -32,8 +38,8 @@ export async function createFolderAction(name: string, parentId: string | null =
}
/**
- * Uploads a file to a unique UUID folder on OneDrive
- * and links it to a virtual parent in the DB.
+ * Uploads a file to OneDrive into a unique UUID folder
+ * and links it to a virtual parent (Project/Folder) in the DB.
*/
export async function uploadFileAction(formData: FormData) {
const session = await auth();
@@ -45,46 +51,66 @@ export async function uploadFileAction(formData: FormData) {
if (!file) throw new Error("No file selected");
+ // Use the utility to ensure we have a valid JWT (fixing the "no dots" error)
const accessToken = await getFreshAccessToken(session.user.id);
const rootFolder = "WebCalibre";
const internalId = crypto.randomUUID();
- // 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
+ // 1. Create the unique UUID folder on OneDrive inside WebCalibre
const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, {
method: "POST",
- headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
- body: JSON.stringify({ name: internalId, folder: {}, "@microsoft.graph.conflictBehavior": "fail" })
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ name: internalId,
+ folder: {},
+ "@microsoft.graph.conflictBehavior": "fail"
+ })
});
- if (!createSubFolderRes.ok) throw new Error("Storage directory creation failed");
+ if (!createSubFolderRes.ok) {
+ const err = await createSubFolderRes.json();
+ console.error("OneDrive Folder Creation Error:", err);
+ throw new Error("Storage directory creation failed");
+ }
+
const subFolderData = await createSubFolderRes.json();
- // 3. Create Upload Session & Upload
+ // 2. Create Upload Session for the file
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`;
const sessionRes = await fetch(sessionUrl, {
method: "POST",
- headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json"
+ },
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
});
const { uploadUrl } = await sessionRes.json();
const buffer = Buffer.from(await file.arrayBuffer());
+
+ // 3. Perform the actual upload
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
- headers: { "Content-Length": `${file.size}`, "Content-Range": `bytes 0-${file.size - 1}/${file.size}` },
+ headers: {
+ "Content-Length": `${file.size}`,
+ "Content-Range": `bytes 0-${file.size - 1}/${file.size}`
+ },
body: buffer
});
if (!uploadRes.ok) throw new Error("OneDrive stream failed");
const driveItem = await uploadRes.json();
- // 4. Record in DB
+ // 4. Record in Database with full metadata and virtual hierarchy
+ const extension = file.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
+
await prisma.fileNode.create({
data: {
- id: internalId,
+ id: internalId, // Matches the folder name on OneDrive
oneDriveId: driveItem.id,
name: file.name,
description: description,
@@ -92,9 +118,9 @@ export async function uploadFileAction(formData: FormData) {
isFolder: false,
path: `/${rootFolder}/${internalId}/${file.name}`,
ownerId: session.user.id,
- parentId: parentId || null, // VIRTUAL HIERARCHY
+ parentId: parentId || null, // Virtual link to the Project folder
metadata: {
- type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
+ type: extension,
mimeType: file.type
}
}
diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx
index 120865d..fdb7096 100644
--- a/src/app/upload/page.tsx
+++ b/src/app/upload/page.tsx
@@ -2,16 +2,24 @@ import { auth } from "@/auth";
import { redirect } from "next/navigation";
import UploadView from "./upload-view";
import { Container } from "@mui/material";
+import { prisma } from "@/lib/prisma";
export default async function UploadPage() {
const session = await auth();
- // Guard: Must be logged in to upload
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 (
-
+ {/* Pass folders to the view */}
+
);
}
\ No newline at end of file
diff --git a/src/app/upload/upload-view.tsx b/src/app/upload/upload-view.tsx
index 3c1dbc2..66c6c8f 100644
--- a/src/app/upload/upload-view.tsx
+++ b/src/app/upload/upload-view.tsx
@@ -3,10 +3,14 @@
import { useState } from "react";
import {
Box, Button, Typography, Paper, LinearProgress, Stack,
- TextField, MenuItem, IconButton, Tooltip, Divider
+ TextField, MenuItem, IconButton, Tooltip, Divider,
+ InputAdornment
} from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
+import FolderIcon from "@mui/icons-material/Folder";
+import AssignmentIcon from '@mui/icons-material/Assignment';
+import { useRouter } from "next/navigation";
import { uploadFileAction, createFolderAction } from "./_actions";
/**
@@ -20,7 +24,21 @@ const formatFileSize = (bytes: number) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
-export default function UploadView({ user, folders = [] }: { user: any, folders?: any[] }) {
+interface Folder {
+ id: string;
+ name: string;
+ parentId: string | null;
+}
+
+interface UploadViewProps {
+ user: any;
+ folders?: Folder[];
+}
+
+export default function UploadView({ user, folders = [] }: UploadViewProps) {
+ const router = useRouter();
+
+ // Form State
const [file, setFile] = useState(null);
const [description, setDescription] = useState("");
const [parentId, setParentId] = useState("");
@@ -29,81 +47,90 @@ export default function UploadView({ user, folders = [] }: { user: any, folders?
// Folder Creation State
const [showFolderInput, setShowFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
+ const [isCreatingFolder, setIsCreatingFolder] = useState(false);
- // --- RESTORED FILE SIZE LIMITS ---
- const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MiB Limit
+ const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MB Limit
const handleCreateFolder = async () => {
if (!newFolderName.trim()) return;
+ setIsCreatingFolder(true);
try {
- await createFolderAction(newFolderName);
+ // Creates folder nested under current selection if parentId exists
+ await createFolderAction(newFolderName, parentId || null);
setNewFolderName("");
setShowFolderInput(false);
+ router.refresh();
} catch (err) {
alert("Error creating folder");
+ } finally {
+ setIsCreatingFolder(false);
}
};
const handleUpload = async () => {
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)}.`);
+ alert(`File is too large! Max allowed: ${formatFileSize(MAX_FILE_SIZE)}.`);
return;
}
setStatus('uploading');
-
const formData = new FormData();
formData.append("file", file);
formData.append("description", description);
formData.append("parentId", parentId);
try {
- await uploadFileAction(formData);
- setStatus('success');
- setFile(null);
- setDescription("");
+ const result = await uploadFileAction(formData);
+ if (result.success) {
+ setStatus('success');
+ setFile(null);
+ setDescription("");
+ router.refresh();
+ }
} catch (err) {
console.error(err);
- alert("Upload failed. Ensure file size is within limits and check server logs.");
+ alert("Upload failed. Check server logs.");
setStatus('idle');
}
};
return (
-
+
Add to Library
- {/* Section 1: Folder Selection */}
+ {/* Section 1: Destination Folder */}
-
- 1. Select Target Project / Folder
+
+ 1. Destination
+
setParentId(e.target.value)}
disabled={status === 'uploading'}
+ helperText="Files will be virtually organized into this folder."
>
-
+
{folders.map((f) => (
-
+
))}
-
+
setShowFolderInput(!showFolderInput)}
- sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }}
+ sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1, width: 56, height: 56 }}
>
@@ -111,7 +138,7 @@ export default function UploadView({ user, folders = [] }: { user: any, folders?
{showFolderInput && (
-
+
setNewFolderName(e.target.value)}
autoFocus
+ disabled={isCreatingFolder}
/>
-
+
)}
- {/* Section 2: File Selection */}
+ {/* Section 2: File Upload Area */}
-
- 2. Upload File
+
+ 2. Upload File
-
+
+
{
setFile(e.target.files?.[0] || null);
setStatus('idle');
}}
/>
+
{file && (
-
- Selected: {file.name}
+
+ {file.name}
- MAX_FILE_SIZE ? 'error.main' : 'text.disabled' }}>
- Size: {formatFileSize(file.size)} {file.size > MAX_FILE_SIZE && "(Too Large)"}
+
+ {formatFileSize(file.size)}
)}
- {/* Section 3: Description */}
- setDescription(e.target.value)}
- disabled={status === 'uploading'}
- />
-
- {/* Status & Action */}
- {status === 'uploading' && (
-
-
-
- Streaming to OneDrive storage...
-
-
- )}
-
-
-
- {status === 'success' && (
-
- ✅ Successfully Added!
+ {/* Section 3: Notes */}
+
+
+ 3. Metadata
- )}
+ setDescription(e.target.value)}
+ disabled={status === 'uploading'}
+ />
+
+
+ {/* Progress & Actions */}
+
+ {status === 'uploading' && (
+
+
+
+ Transferring to OneDrive and updating Library...
+
+
+ )}
+
+
+
+ {status === 'success' && (
+
+ ✅ File processed and assigned to project!
+
+ )}
+
);
diff --git a/src/auth.ts b/src/auth.ts
index 8e547bf..4538d2c 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -9,25 +9,35 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
...authConfig,
callbacks: {
async jwt({ token, account, user }) {
- // On the first sign in, 'account' contains the refresh_token
+ // 1. Handle OAuth tokens (from first sign-in)
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
}
+
+ // 2. Attach User ID and Role to the token
+ // When 'user' exists, it's the first time we've fetched this user from the DB during login
if (user) {
token.sub = user.id;
+ // @ts-ignore - 'role' exists on our custom User model
+ token.role = user.role;
}
+
return token;
},
+
async session({ session, token }) {
+ // 3. Pass values from the JWT Token into the Client-facing Session
if (session?.user && token.sub) {
session.user.id = token.sub;
+ // @ts-ignore - Attaching the role so the Navbar and Settings page can see it
+ session.user.role = token.role;
}
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);
diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx
index d8038e3..bb6c80a 100644
--- a/src/components/layout/Navbar.tsx
+++ b/src/components/layout/Navbar.tsx
@@ -10,14 +10,18 @@ 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 SettingsIcon from '@mui/icons-material/Settings';
import Link from 'next/link';
import { signIn, signOut } from "next-auth/react";
+// Updated Interface to include Role and Bootstrap status
interface NavbarProps {
user?: {
name?: string | null;
email?: string | null;
image?: string | null;
+ role?: string; // Added
+ isBootstrap?: boolean; // Added
};
}
@@ -25,6 +29,9 @@ export default function Navbar({ user }: NavbarProps) {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const isLoggedIn = !!user;
+ // Logic: Is this user an Admin?
+ const isAdmin = user?.role === 'ADMIN' || user?.isBootstrap === true;
+
const toggleDrawer = (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
if (event.type === 'keydown' && ((event as React.KeyboardEvent).key === 'Tab' || (event as React.KeyboardEvent).key === 'Shift')) {
return;
@@ -32,12 +39,18 @@ export default function Navbar({ user }: NavbarProps) {
setIsDrawerOpen(open);
};
+ // Build the list dynamically based on permissions
const navItems = [
{ text: 'Dashboard', icon: , href: '/dashboard' },
{ text: 'Library', icon: , href: '/library' },
{ text: 'Upload File', icon: , href: '/upload' },
];
+ // Only push Settings if the user has Admin rights
+ if (isAdmin) {
+ navItems.push({ text: 'Settings', icon: , href: '/settings' });
+ }
+
return (
<>
@@ -67,7 +80,9 @@ export default function Navbar({ user }: NavbarProps) {
<>
{user.name}
- {user.email}
+
+ {user.email} {isAdmin && "(Admin)"}
+
{user.name?.charAt(0)}
@@ -104,6 +119,18 @@ export default function Navbar({ user }: NavbarProps) {
))}
+ {/* Visual indicator for non-admins if you want it greyed out instead of hidden */}
+ {!isAdmin && isLoggedIn && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
>