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 ( -
-
+ + + + -
+ -
+ -
-
+ + ); } \ 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 ? ( + + + + + + ) : ( + + )} + + + ); +} \ 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." > - None (Root) + -- Root (Main Library) -- {folders.map((f) => ( - {f.name} + + {f.parentId ? `↳ ${f.name}` : f.name} + ))} - + 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 && ( + <> + + + + + + + + + )}