Functionality of creating folder and uploading files works

This commit is contained in:
stephen 2026-01-12 00:41:54 +11:00
parent e961f60b01
commit 455dc3bea3
18 changed files with 963 additions and 171 deletions

3
.env
View file

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

@ -24,6 +24,7 @@
- [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
@ -341,3 +342,13 @@ 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

Binary file not shown.

View file

@ -6,10 +6,17 @@ 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? // Renamed from displayName for NextAuth compatibility name String?
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
@ -50,9 +57,9 @@ model Session {
} }
model FileNode { model FileNode {
id String @id // Removed @default(uuid()) to allow manual assignment id String @id
name String name String
size BigInt? size BigInt? // Preserved your BigInt size column
isFolder Boolean @default(false) isFolder Boolean @default(false)
oneDriveId String? @unique oneDriveId String? @unique
path String path String
@ -64,7 +71,8 @@ model FileNode {
owner User @relation(fields: [ownerId], references: [id]) owner User @relation(fields: [ownerId], references: [id])
parentId String? 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") children FileNode[] @relation("TreeHierarchy")
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View file

@ -1,25 +1,116 @@
'use server'; 'use server';
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth"; 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() { export async function getFileNodes() {
const session = await auth(); try {
const nodes = await prisma.fileNode.findMany({
if (!session?.user?.id) { orderBy: {
return []; // Return empty if not logged in updatedAt: 'desc',
},
});
return nodes;
} catch (error) {
console.error("Error fetching file nodes:", error);
return [];
} }
}
const nodes = await prisma.fileNode.findMany({ /**
where: { * 2. DELETE: Remove from OneDrive and Database
ownerId: session.user.id, // Only get THIS user's files * Improved to ensure DB removal even if OneDrive API returns errors.
}, */
orderBy: { export async function deleteFileAction(fileId: string) {
orderIndex: 'asc', 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 => ({ if (!node) {
...node, // If not in DB, trigger a refresh anyway to clear the UI
size: node.size ? Number(node.size) : null, 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.");
}
} }

View file

@ -1,26 +1,52 @@
"use client"; "use client";
import { useState } from "react"; 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 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 { DataGrid, GridColDef } from "@mui/x-data-grid";
import { syncOneDrive } from "./sync-actions"; import { syncOneDrive } from "./sync-actions";
import { deleteFileAction } from "./actions"; // Un-commented and assumed active
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
interface DashboardViewProps { interface DashboardViewProps {
initialFiles: any[]; 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 [loading, setLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const router = useRouter(); 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 () => { 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);
@ -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[] = [ const columns: GridColDef[] = [
{ field: "name", headerName: "File Name", width: 300 },
{ {
field: "metadata", field: "name",
headerName: "Name",
flex: 1.5,
minWidth: 250,
renderCell: (params) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{params.row.isFolder ? (
<FolderIcon sx={{ color: '#FFB020' }} /> // Folder yellow
) : (
<InsertDriveFileIcon color="action" />
)}
<Typography variant="body2" sx={{ fontWeight: params.row.isFolder ? 600 : 400 }}>
{params.value}
</Typography>
</Box>
)
},
{
field: "parentId",
headerName: "Location (Project)",
flex: 1,
minWidth: 200,
renderCell: (params) => {
const path = getVirtualPath(params.value);
return (
<Tooltip title={path}>
<Chip
label={path}
size="small"
variant="outlined"
color={path === "WebCalibre" ? "default" : "primary"}
sx={{ maxWidth: '100%' }}
/>
</Tooltip>
);
}
},
{
field: "type",
headerName: "Type", headerName: "Type",
width: 120, width: 100,
valueGetter: (params) => params?.type || "Unknown" 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) => (
<Typography variant="caption" sx={{ fontWeight: 'bold', color: 'text.secondary' }}>
{params.value}
</Typography>
)
}, },
{ {
field: "size", field: "size",
headerName: "Size (MB)", headerName: "Size",
width: 120, 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 (
<IconButton
size="small"
color="error"
onClick={(e) => {
e.stopPropagation();
handleDelete(params.row.id, params.row.name);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
);
}
return null;
}
}
]; ];
return ( return (
<div className="space-y-4"> <Box className="space-y-4">
<div className="flex justify-end"> <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mb: 2 }}>
<Button
variant="outlined"
startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />}
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? "Refreshing..." : "Refresh List"}
</Button>
<Button <Button
variant="contained" variant="contained"
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />} startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
@ -58,18 +191,39 @@ export default function DashboardView({ initialFiles }: DashboardViewProps) {
> >
{loading ? "Syncing..." : "Sync OneDrive"} {loading ? "Syncing..." : "Sync OneDrive"}
</Button> </Button>
</div> </Box>
<div style={{ height: 600, width: "100%" }}> <Box sx={{
height: 700,
width: "100%",
bgcolor: 'background.paper',
borderRadius: 3,
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
overflow: 'hidden'
}}>
<DataGrid <DataGrid
rows={initialFiles} rows={initialFiles}
columns={columns} columns={columns}
pageSizeOptions={[10, 25, 50]} pageSizeOptions={[10, 25, 50]}
initialState={{ initialState={{
pagination: { paginationModel: { pageSize: 10 } }, pagination: { paginationModel: { pageSize: 10 } },
sorting: {
sortModel: [{ field: 'name', sort: 'asc' }],
},
}}
disableRowSelectionOnClick
sx={{
border: 'none',
'& .MuiDataGrid-columnHeaders': {
bgcolor: '#f8f9fa',
borderBottom: '1px solid #eee',
},
'& .MuiDataGrid-cell': {
borderBottom: '1px solid #f0f0f0',
},
}} }}
/> />
</div> </Box>
</div> </Box>
); );
} }

View file

@ -1,7 +1,9 @@
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();
@ -14,12 +16,41 @@ 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">
<h1 className="text-2xl font-bold mb-6">My OneDrive Library</h1> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 6 }}>
<Box>
<Typography variant="h4" fontWeight={800} sx={{ color: 'text.primary' }}>
My OneDrive Library
</Typography>
<Typography variant="body1" color="text.secondary">
Manage your synchronized files and project folders.
</Typography>
</Box>
{/* Pass the data to the interactive Client Component */} {isAdmin && (
<DashboardView initialFiles={initialFiles} /> <Chip
icon={<AdminPanelSettingsIcon />}
label="Admin Access"
color="primary"
variant="outlined"
sx={{ fontWeight: 600 }}
/>
)}
</Box>
{/* Pass both initialFiles AND the user object.
The DashboardView will use user.role and user.id to
decide who can see the 'Delete' button.
*/}
<DashboardView
initialFiles={initialFiles}
user={session.user}
/>
</main> </main>
); );
} }

View file

@ -3,50 +3,93 @@
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");
// 1. Get the OAuth token from your PostgreSQL 'Account' table try {
const account = await prisma.account.findFirst({ const accessToken = await getFreshAccessToken(session.user.id);
where: { userId: 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:/WebCalibre:/children", {
const response = await fetch("https://graph.microsoft.com/v1.0/me/drive/root/children", { headers: {
headers: { Authorization: `Bearer ${account.access_token}` }, Authorization: `Bearer ${accessToken}`,
}); "Content-Type": "application/json"
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 if (!response.ok) {
revalidatePath('/dashboard'); const errorData = await response.json();
return { success: true, count: data.value.length }; 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.");
}
} }

View file

@ -12,6 +12,17 @@ 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>
@ -19,8 +30,10 @@ 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 the user object to the Navbar so it can show the profile pic */} {/* We pass navbarUser (which includes role and isBootstrap)
<Navbar user={session?.user} /> so the Drawer knows whether to show the Settings link.
*/}
<Navbar user={navbarUser} />
<main style={{ minHeight: '100vh' }}> <main style={{ minHeight: '100vh' }}>
{children} {children}

View file

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

123
src/app/settings/page.tsx Normal file
View file

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

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

@ -7,12 +7,14 @@ import { revalidatePath } from "next/cache";
/** /**
* Creates a virtual folder in the database. * 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) { 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");
// We generate a manual UUID because the schema requires 'id' but has no default generator
const internalId = crypto.randomUUID(); const internalId = crypto.randomUUID();
await prisma.fileNode.create({ await prisma.fileNode.create({
@ -23,6 +25,10 @@ export async function createFolderAction(name: string, parentId: string | null =
path: `/virtual/${name}`, path: `/virtual/${name}`,
ownerId: session.user.id, ownerId: session.user.id,
parentId: parentId || null, 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 * Uploads a file to OneDrive into a unique UUID folder
* and links it to a virtual parent in the DB. * and links it to a virtual parent (Project/Folder) in the DB.
*/ */
export async function uploadFileAction(formData: FormData) { export async function uploadFileAction(formData: FormData) {
const session = await auth(); const session = await auth();
@ -45,46 +51,66 @@ export async function uploadFileAction(formData: FormData) {
if (!file) throw new Error("No file selected"); 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 accessToken = await getFreshAccessToken(session.user.id);
const rootFolder = "WebCalibre"; const rootFolder = "WebCalibre";
const internalId = crypto.randomUUID(); const internalId = crypto.randomUUID();
// 1. Ensure WebCalibre exists (Simplified for brevity) // 1. Create the unique UUID folder on OneDrive inside WebCalibre
// ... (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: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, headers: {
body: JSON.stringify({ name: internalId, folder: {}, "@microsoft.graph.conflictBehavior": "fail" }) 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(); 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 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",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } }) body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
}); });
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. Perform the actual upload
const uploadRes = await fetch(uploadUrl, { const uploadRes = await fetch(uploadUrl, {
method: "PUT", 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 body: buffer
}); });
if (!uploadRes.ok) throw new Error("OneDrive stream failed"); if (!uploadRes.ok) throw new Error("OneDrive stream failed");
const driveItem = await uploadRes.json(); 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({ await prisma.fileNode.create({
data: { data: {
id: internalId, id: internalId, // Matches the folder name on OneDrive
oneDriveId: driveItem.id, oneDriveId: driveItem.id,
name: file.name, name: file.name,
description: description, description: description,
@ -92,9 +118,9 @@ export async function uploadFileAction(formData: FormData) {
isFolder: false, isFolder: false,
path: `/${rootFolder}/${internalId}/${file.name}`, path: `/${rootFolder}/${internalId}/${file.name}`,
ownerId: session.user.id, ownerId: session.user.id,
parentId: parentId || null, // VIRTUAL HIERARCHY parentId: parentId || null, // Virtual link to the Project folder
metadata: { metadata: {
type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN", type: extension,
mimeType: file.type mimeType: file.type
} }
} }

View file

@ -2,16 +2,24 @@ 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 }}>
<UploadView user={session.user} /> {/* Pass folders to the view */}
<UploadView user={session.user} folders={folders} />
</Container> </Container>
); );
} }

View file

@ -3,10 +3,14 @@
import { useState } from "react"; import { useState } from "react";
import { import {
Box, Button, Typography, Paper, LinearProgress, Stack, Box, Button, Typography, Paper, LinearProgress, Stack,
TextField, MenuItem, IconButton, Tooltip, Divider TextField, MenuItem, IconButton, Tooltip, Divider,
InputAdornment
} from "@mui/material"; } from "@mui/material";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; 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"; import { uploadFileAction, createFolderAction } from "./_actions";
/** /**
@ -20,7 +24,21 @@ const formatFileSize = (bytes: number) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 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<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("");
@ -29,81 +47,90 @@ export default function UploadView({ user, folders = [] }: { user: any, folders?
// 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; // 150MB Limit
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 {
await createFolderAction(newFolderName); // Creates folder nested under current selection if parentId exists
await createFolderAction(newFolderName, parentId || null);
setNewFolderName(""); setNewFolderName("");
setShowFolderInput(false); setShowFolderInput(false);
router.refresh();
} catch (err) { } catch (err) {
alert("Error creating folder"); alert("Error creating folder");
} finally {
setIsCreatingFolder(false);
} }
}; };
const handleUpload = async () => { const handleUpload = async () => {
if (!file) return; if (!file) return;
// RESTORED: Client-side size validation
if (file.size > MAX_FILE_SIZE) { 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; return;
} }
setStatus('uploading'); setStatus('uploading');
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("description", description); formData.append("description", description);
formData.append("parentId", parentId); formData.append("parentId", parentId);
try { try {
await uploadFileAction(formData); const result = await uploadFileAction(formData);
setStatus('success'); if (result.success) {
setFile(null); setStatus('success');
setDescription(""); setFile(null);
setDescription("");
router.refresh();
}
} catch (err) { } catch (err) {
console.error(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'); setStatus('idle');
} }
}; };
return ( return (
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}> <Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto' }} elevation={3}>
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center"> <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 }}>
{/* Section 1: Folder Selection */} {/* Section 1: Destination Folder */}
<Box> <Box>
<Typography variant="subtitle2" gutterBottom fontWeight="bold"> <Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
1. Select Target Project / Folder <FolderIcon color="primary" /> 1. Destination
</Typography> </Typography>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<TextField <TextField
select select
fullWidth fullWidth
label="Choose Folder" label="Target Project / Folder"
value={parentId} value={parentId}
onChange={(e) => setParentId(e.target.value)} onChange={(e) => setParentId(e.target.value)}
disabled={status === 'uploading'} disabled={status === 'uploading'}
helperText="Files will be virtually organized into this folder."
> >
<MenuItem value=""><em>None (Root)</em></MenuItem> <MenuItem value=""><em>-- Root (Main Library) --</em></MenuItem>
{folders.map((f) => ( {folders.map((f) => (
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem> <MenuItem key={f.id} value={f.id} sx={{ pl: f.parentId ? 4 : 2 }}>
{f.parentId ? `${f.name}` : f.name}
</MenuItem>
))} ))}
</TextField> </TextField>
<Tooltip title="Create New Folder"> <Tooltip title="Create New Folder Inside Selected">
<IconButton <IconButton
color="primary" color="primary"
onClick={() => setShowFolderInput(!showFolderInput)} onClick={() => setShowFolderInput(!showFolderInput)}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1, width: 56, height: 56 }}
> >
<CreateNewFolderIcon /> <CreateNewFolderIcon />
</IconButton> </IconButton>
@ -111,7 +138,7 @@ export default function UploadView({ user, folders = [] }: { user: any, folders?
</Stack> </Stack>
{showFolderInput && ( {showFolderInput && (
<Stack direction="row" spacing={1} sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}> <Stack direction="row" spacing={1} sx={{ mt: 2, p: 2, bgcolor: 'action.hover', borderRadius: 2 }}>
<TextField <TextField
size="small" size="small"
fullWidth fullWidth
@ -119,79 +146,112 @@ export default function UploadView({ user, folders = [] }: { user: any, folders?
value={newFolderName} value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)} onChange={(e) => setNewFolderName(e.target.value)}
autoFocus autoFocus
disabled={isCreatingFolder}
/> />
<Button variant="contained" onClick={handleCreateFolder}>Create</Button> <Button
variant="contained"
onClick={handleCreateFolder}
disabled={isCreatingFolder || !newFolderName.trim()}
>
{isCreatingFolder ? "..." : "Create"}
</Button>
</Stack> </Stack>
)} )}
</Box> </Box>
<Divider /> <Divider />
{/* Section 2: File Selection */} {/* Section 2: File Upload Area */}
<Box> <Box>
<Typography variant="subtitle2" gutterBottom fontWeight="bold"> <Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
2. Upload File <CloudUploadIcon color="primary" /> 2. Upload File
</Typography> </Typography>
<Box sx={{ p: 4, border: '2px dashed #ccc', borderRadius: 2, textAlign: 'center', bgcolor: '#fcfcfc' }}>
<Box
sx={{
p: 5,
mt: 1,
border: '2px dashed',
borderColor: file ? 'primary.main' : 'divider',
borderRadius: 3,
textAlign: 'center',
bgcolor: file ? 'rgba(25, 118, 210, 0.04)' : '#fafafa',
'&:hover': { bgcolor: 'rgba(0, 0, 0, 0.02)' }
}}
>
<input <input
type="file" id="file-input" hidden type="file" id="file-input" hidden
accept=".pdf,.epub,.mobi,.azw3,.txt,.png,.jpeg"
onChange={(e) => { onChange={(e) => {
setFile(e.target.files?.[0] || null); setFile(e.target.files?.[0] || null);
setStatus('idle'); setStatus('idle');
}} }}
/> />
<label htmlFor="file-input"> <label htmlFor="file-input">
<Button variant="outlined" component="span" startIcon={<CloudUploadIcon />}> <Button
{file ? "Change File" : "Choose Book/Image"} variant={file ? "outlined" : "contained"}
component="span"
startIcon={<CloudUploadIcon />}
size="large"
disabled={status === 'uploading'}
>
{file ? "Change File" : "Choose Document"}
</Button> </Button>
</label> </label>
{file && ( {file && (
<Box mt={2}> <Box mt={2}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="subtitle2" color="primary.main" fontWeight="bold">
Selected: <strong>{file.name}</strong> {file.name}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: file.size > MAX_FILE_SIZE ? 'error.main' : 'text.disabled' }}> <Typography variant="caption" color="text.secondary">
Size: {formatFileSize(file.size)} {file.size > MAX_FILE_SIZE && "(Too Large)"} {formatFileSize(file.size)}
</Typography> </Typography>
</Box> </Box>
)} )}
</Box> </Box>
</Box> </Box>
{/* Section 3: Description */} {/* Section 3: Notes */}
<TextField <Box>
label="Notes / Description" <Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
multiline rows={2} fullWidth <AssignmentIcon color="primary" /> 3. Metadata
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={status === 'uploading'}
/>
{/* 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>
)}
<Button
variant="contained" size="large" fullWidth
disabled={!file || status === 'uploading' || file.size > MAX_FILE_SIZE}
onClick={handleUpload}
sx={{ py: 2, fontWeight: 'bold' }}
>
{status === 'uploading' ? 'Uploading...' : 'Confirm Upload'}
</Button>
{status === 'success' && (
<Typography align="center" color="success.main" fontWeight="bold">
Successfully Added!
</Typography> </Typography>
)} <TextField
label="Description / Notes"
multiline rows={3} fullWidth
placeholder="Add keywords or a brief summary..."
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={status === 'uploading'}
/>
</Box>
{/* Progress & Actions */}
<Box>
{status === 'uploading' && (
<Box mb={2}>
<LinearProgress sx={{ borderRadius: 5, height: 10 }} />
<Typography variant="caption" color="primary" sx={{ mt: 1, display: 'block', textAlign: 'center' }}>
Transferring to OneDrive and updating Library...
</Typography>
</Box>
)}
<Button
variant="contained" size="large" fullWidth
disabled={!file || status === 'uploading'}
onClick={handleUpload}
sx={{ py: 2, fontWeight: 'bold' }}
>
{status === 'uploading' ? 'Uploading...' : 'Add to Library'}
</Button>
{status === 'success' && (
<Typography align="center" color="success.main" fontWeight="bold" sx={{ mt: 2 }}>
File processed and assigned to project!
</Typography>
)}
</Box>
</Stack> </Stack>
</Paper> </Paper>
); );

View file

@ -9,25 +9,35 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
...authConfig, ...authConfig,
callbacks: { callbacks: {
async jwt({ token, account, user }) { 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) { 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
// When 'user' exists, it's the first time we've fetched this user from the DB during login
if (user) { if (user) {
token.sub = user.id; token.sub = user.id;
// @ts-ignore - 'role' exists on our custom User model
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) { if (session?.user && token.sub) {
session.user.id = 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; 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,14 +10,18 @@ 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
}; };
} }
@ -25,6 +29,9 @@ 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;
@ -32,12 +39,18 @@ 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' }}>
@ -67,7 +80,9 @@ 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' }}>{user.email}</Typography> <Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
{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)}
@ -104,6 +119,18 @@ 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>
</> </>