From dc674e016d9aafeb529a1656da25973cfc1f04bb Mon Sep 17 00:00:00 2001 From: Raseel Bhagat Date: Thu, 25 Jun 2026 14:01:46 +0000 Subject: [PATCH 01/17] Fixed Bucket assignment --- src/app/api/buckets/route.ts | 22 +- src/context/BucketAssignmentContext.tsx | 143 ++++++------- src/context/BucketContext.tsx | 262 +++++++++++++----------- src/lib/buckets.ts | 174 +++++++++++++++- 4 files changed, 383 insertions(+), 218 deletions(-) diff --git a/src/app/api/buckets/route.ts b/src/app/api/buckets/route.ts index ee0734b..8bd01a3 100644 --- a/src/app/api/buckets/route.ts +++ b/src/app/api/buckets/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { validateSession } from '@/lib/auth'; -import { getAllBuckets, getBucketsByUserId, createBucket, getBucketCount } from '@/lib/buckets'; +import { getAllBuckets, getBucketsByUserId, getBucketsAssignedToUser, createBucket, getBucketCount } from '@/lib/buckets'; import { cookies } from 'next/headers'; // GET /api/buckets - Get all buckets for current user @@ -19,13 +19,21 @@ export async function GET(request: NextRequest) { } const isAdmin = user.role === 'admin'; - const buckets = isAdmin ? await getAllBuckets() : await getBucketsByUserId(user.id); - const count = isAdmin ? buckets.length : await getBucketCount(user.id); + let buckets; - return NextResponse.json({ - buckets, - count, - }); + if (isAdmin) { + const all = await getAllBuckets(); // includes owner_username via JOIN + buckets = all.map(b => ({ ...b, is_owned: b.user_id === user.id, permission: null })); + } else { + const owned = await getBucketsByUserId(user.id); + const assigned = await getBucketsAssignedToUser(user.id); + buckets = [ + ...owned.map(b => ({ ...b, is_owned: true, owner_username: user.username, permission: null })), + ...assigned, // already carries is_owned:false, owner_username, permission + ]; + } + + return NextResponse.json({ buckets, count: buckets.length }); } catch (error) { console.error('Get buckets error:', error); return NextResponse.json( diff --git a/src/context/BucketAssignmentContext.tsx b/src/context/BucketAssignmentContext.tsx index a8d6eb1..7c7e762 100644 --- a/src/context/BucketAssignmentContext.tsx +++ b/src/context/BucketAssignmentContext.tsx @@ -1,7 +1,8 @@ "use client"; -import React, { createContext, useState, useContext, useEffect } from 'react'; +import React, { createContext, useState, useContext, useEffect, useCallback } from 'react'; import { useToast } from '@/hooks/use-toast'; +import { recordBucketAssignment } from '@/actions/audit-record'; export type BucketPermission = 'read-only' | 'read-write'; @@ -26,106 +27,78 @@ const BucketAssignmentContext = createContext([]); - const [isLoaded, setIsLoaded] = useState(false); - - // Load assignments from localStorage - useEffect(() => { - if (typeof window !== 'undefined') { - try { - const storage = window.localStorage; - if (storage && typeof storage.getItem === 'function') { - const storedAssignments = storage.getItem('s3-bucket-assignments'); - if (storedAssignments) { - setAssignments(JSON.parse(storedAssignments)); - } - } - } catch (e) { - console.error("Failed to load bucket assignments from localStorage", e); - setAssignments([]); + + const refreshAssignments = useCallback(async () => { + try { + const res = await fetch('/api/bucket-assignments', { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + setAssignments( + (data.assignments ?? []).map((a: any) => ({ + bucketId: String(a.bucket_id), + username: a.username, + permission: a.permission as BucketPermission, + })) + ); } + // 401/403 = non-admin session → leave assignments empty, no error needed + } catch (e) { + console.error('BucketAssignmentContext: failed to load assignments', e); } - setIsLoaded(true); }, []); - // Save assignments to localStorage - useEffect(() => { - if (isLoaded && typeof window !== 'undefined') { - try { - const storage = window.localStorage; - if (storage && typeof storage.setItem === 'function') { - storage.setItem('s3-bucket-assignments', JSON.stringify(assignments)); - } - } catch (e) { - console.error("Failed to save bucket assignments to localStorage", e); - } - } - }, [assignments, isLoaded]); + useEffect(() => { refreshAssignments(); }, [refreshAssignments]); const assignUserToBucket = (bucketId: string, username: string, permission: BucketPermission) => { - // Check if assignment already exists - const existingIndex = assignments.findIndex( - a => a.bucketId === bucketId && a.username === username - ); - - if (existingIndex !== -1) { - // Update existing assignment - setAssignments(prev => prev.map((a, i) => - i === existingIndex ? { ...a, permission } : a - )); - toast({ - title: 'Updated', - description: `Permission for "${username}" updated to ${permission}.`, - duration: 500 + (async () => { + const res = await fetch('/api/bucket-assignments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ bucket_id: parseInt(bucketId, 10), username, permission }), }); - } else { - // Add new assignment - setAssignments(prev => [...prev, { bucketId, username, permission }]); - toast({ - title: 'Assigned', - description: `User "${username}" assigned to bucket with ${permission} permission.`, - duration: 500 - }); - } + if (res.ok) { + await refreshAssignments(); + await recordBucketAssignment(bucketId, bucketId, username, 'bucket.assigned'); + toast({ title: 'Assigned', description: `"${username}" assigned to bucket.`, duration: 2000 }); + } else { + const data = await res.json().catch(() => ({})); + toast({ variant: 'destructive', title: 'Error', description: data.error ?? 'Failed to assign user.' }); + } + })(); }; const removeUserFromBucket = (bucketId: string, username: string) => { - setAssignments(prev => prev.filter( - a => !(a.bucketId === bucketId && a.username === username) - )); - toast({ - title: 'Removed', - description: `User "${username}" removed from bucket.`, - duration: 500 - }); + (async () => { + const res = await fetch('/api/bucket-assignments', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ bucket_id: parseInt(bucketId, 10), username }), + }); + if (res.ok) { + await refreshAssignments(); + await recordBucketAssignment(bucketId, bucketId, username, 'bucket.unassigned'); + toast({ title: 'Removed', description: `"${username}" removed from bucket.`, duration: 2000 }); + } else { + toast({ variant: 'destructive', title: 'Error', description: 'Failed to remove assignment.' }); + } + })(); }; + // updateBucketPermission is a reassign with a new permission value. const updateBucketPermission = (bucketId: string, username: string, permission: BucketPermission) => { - setAssignments(prev => prev.map(a => - a.bucketId === bucketId && a.username === username - ? { ...a, permission } - : a - )); - toast({ - title: 'Updated', - description: `Permission updated to ${permission}.`, - duration: 500 - }); + assignUserToBucket(bucketId, username, permission); }; - const getBucketAssignments = (bucketId: string): BucketAssignment[] => { - return assignments.filter(a => a.bucketId === bucketId); - }; + const getBucketAssignments = (bucketId: string): BucketAssignment[] => + assignments.filter(a => a.bucketId === bucketId); - const getUserAssignments = (username: string): BucketAssignment[] => { - return assignments.filter(a => a.username === username); - }; + const getUserAssignments = (username: string): BucketAssignment[] => + assignments.filter(a => a.username === username); - const getUserBucketPermission = (bucketId: string, username: string): BucketPermission | null => { - const assignment = assignments.find( - a => a.bucketId === bucketId && a.username === username - ); - return assignment ? assignment.permission : null; - }; + const getUserBucketPermission = (bucketId: string, username: string): BucketPermission | null => + assignments.find(a => a.bucketId === bucketId && a.username === username)?.permission ?? null; return ( {children} diff --git a/src/context/BucketContext.tsx b/src/context/BucketContext.tsx index 9474c4f..9bfa3b3 100644 --- a/src/context/BucketContext.tsx +++ b/src/context/BucketContext.tsx @@ -1,9 +1,9 @@ "use client"; -import React, { createContext, useState, useContext, useEffect } from 'react'; +import React, { createContext, useState, useContext, useEffect, useCallback } from 'react'; import { useAuth } from './AuthContext'; -import { useBucketAssignment, type BucketPermission } from './BucketAssignmentContext'; import { recordBucketEvent } from '@/actions/audit-record'; +import type { BucketPermission } from './BucketAssignmentContext'; export interface Bucket { id: string; @@ -19,13 +19,13 @@ export interface Bucket { } export interface BucketWithPermission extends Bucket { - permission?: BucketPermission; // Permission for current user (if assigned) - isOwner: boolean; // True if current user is the owner + permission?: BucketPermission; + isOwner: boolean; } interface BucketContextType { buckets: BucketWithPermission[]; - allBuckets: Bucket[]; // For admin to see all buckets + allBuckets: Bucket[]; selectedBucket: BucketWithPermission | null; addBucket: (bucket: Omit) => void; updateBucket: (id: string, bucket: Omit) => void; @@ -36,155 +36,180 @@ interface BucketContextType { canEditBucket: (bucketId: string) => boolean; canDeleteBucket: (bucketId: string) => boolean; canUploadToBucket: (bucketId: string) => boolean; + refreshBuckets: () => Promise; } const BucketContext = createContext(undefined); +/** Map a raw API/DB bucket row to the context's Bucket shape. */ +function mapRow(row: any, statusMap: Record): BucketWithPermission { + const id = String(row.id); + return { + id, + name: row.alias, + bucket: row.bucket_name, + region: row.region, + accessKeyId: row.access_key_id, + secretAccessKey: row.secret_access_key, + sessionToken: row.session_token, + folder: row.root_folder, + owner: row.owner_username, + status: statusMap[id] ?? 'untested', + isOwner: row.is_owned ?? true, + permission: row.permission ?? undefined, + }; +} + export function BucketProvider({ children }: { children: React.ReactNode }) { const { user } = useAuth(); - const { getUserAssignments, getUserBucketPermission } = useBucketAssignment(); - const [allBucketsData, setAllBucketsData] = useState([]); + const [rawRows, setRawRows] = useState([]); + const [statusMap, setStatusMap] = useState>({}); const [selectedBucket, setSelectedBucket] = useState(null); - const [isLoaded, setIsLoaded] = useState(false); - useEffect(() => { - if (typeof window !== 'undefined') { - try { - const storage = window.localStorage; - if (storage && typeof storage.getItem === 'function') { - const storedBuckets = storage.getItem('s3-buckets'); - if (storedBuckets) { - setAllBucketsData(JSON.parse(storedBuckets)); - } - } - } catch (e) { - console.error("Failed to load buckets from localStorage", e); - setAllBucketsData([]); + const refreshBuckets = useCallback(async () => { + if (!user) { setRawRows([]); return; } + try { + const res = await fetch('/api/buckets', { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + setRawRows(data.buckets ?? []); + } else if (res.status === 401) { + setRawRows([]); } + } catch (e) { + console.error('BucketContext: failed to load buckets', e); } - setIsLoaded(true); - }, []); + }, [user?.username]); // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { - if (isLoaded && typeof window !== 'undefined') { - try { - const storage = window.localStorage; - if (storage && typeof storage.setItem === 'function') { - storage.setItem('s3-buckets', JSON.stringify(allBucketsData)); - } - } catch (e) { - console.error("Failed to save buckets to localStorage", e); - } - } - }, [allBucketsData, isLoaded]); - - // Calculate user-visible buckets with permissions - const userBuckets: BucketWithPermission[] = React.useMemo(() => { - if (!user) return []; - - const isAdmin = user.role === 'admin'; - const userAssignments = getUserAssignments(user.username); - const assignedBucketIds = new Set(userAssignments.map(a => a.bucketId)); - - return allBucketsData - .filter(b => { - // Admins see every bucket - if (isAdmin) return true; - // Users see buckets they own - if (b.owner === user.username) return true; - // Users see buckets assigned to them - if (assignedBucketIds.has(b.id)) return true; - return false; - }) - .map(b => ({ - ...b, - isOwner: b.owner === user.username, - permission: getUserBucketPermission(b.id, user.username) || undefined - })); - }, [allBucketsData, user, getUserAssignments, getUserBucketPermission]); + // Reload whenever the logged-in user changes. + useEffect(() => { refreshBuckets(); }, [refreshBuckets]); + + const userBuckets: BucketWithPermission[] = React.useMemo( + () => rawRows.map(r => mapRow(r, statusMap)), + [rawRows, statusMap] + ); + + // allBuckets exposes the same rows as plain Bucket[] (no isOwner/permission) + // used by admin-only pages such as bucket-assignments. + const allBuckets: Bucket[] = React.useMemo( + () => rawRows.map(r => ({ + id: String(r.id), + name: r.alias, + bucket: r.bucket_name, + region: r.region, + accessKeyId: r.access_key_id, + secretAccessKey: r.secret_access_key, + sessionToken: r.session_token, + folder: r.root_folder, + owner: r.owner_username, + status: statusMap[String(r.id)] ?? 'untested', + })), + [rawRows, statusMap] + ); const addBucket = (bucket: Omit) => { if (!user) return; - // Only bucket-creator and admin can create buckets - if (!['bucket-creator', 'admin'].includes(user.role ?? '')) { - console.warn('User does not have permission to create buckets'); - return; - } - const newBucket: Bucket = { - ...bucket, - id: crypto.randomUUID(), - owner: user.username - }; - setAllBucketsData(prev => [...prev, newBucket]); - recordBucketEvent('bucket.created', newBucket.id, { - name: newBucket.name, - bucket: newBucket.bucket, - region: newBucket.region, - }); + (async () => { + const res = await fetch('/api/buckets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + alias: bucket.name, + bucket_name: bucket.bucket, + region: bucket.region, + root_folder: bucket.folder, + access_key_id: bucket.accessKeyId, + secret_access_key: bucket.secretAccessKey, + session_token: bucket.sessionToken, + }), + }); + if (res.ok) { + const data = await res.json(); + await refreshBuckets(); + recordBucketEvent('bucket.created', String(data.bucket?.id ?? ''), { + name: bucket.name, + bucket: bucket.bucket, + region: bucket.region, + }); + } + })(); }; const updateBucket = (id: string, updatedBucket: Omit) => { - const existing = allBucketsData.find(b => b.id === id); - setAllBucketsData(prev => prev.map(b => - b.id === id ? { ...updatedBucket, id, owner: b.owner } : b - )); - recordBucketEvent('bucket.updated', id, { - name: updatedBucket.name, - bucket: updatedBucket.bucket, - region: updatedBucket.region, - previous_name: existing?.name, - }); + const existing = rawRows.find(r => String(r.id) === id); + (async () => { + const res = await fetch(`/api/buckets/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + alias: updatedBucket.name, + bucket_name: updatedBucket.bucket, + region: updatedBucket.region, + root_folder: updatedBucket.folder, + access_key_id: updatedBucket.accessKeyId, + secret_access_key: updatedBucket.secretAccessKey, + session_token: updatedBucket.sessionToken, + }), + }); + if (res.ok) { + await refreshBuckets(); + recordBucketEvent('bucket.updated', id, { + name: updatedBucket.name, + bucket: updatedBucket.bucket, + region: updatedBucket.region, + previous_name: existing?.alias, + }); + } + })(); }; const deleteBucket = (id: string) => { - const existing = allBucketsData.find(b => b.id === id); - setAllBucketsData(prev => prev.filter(b => b.id !== id)); - if (selectedBucket?.id === id) { - setSelectedBucket(null); - } - if (existing) { - recordBucketEvent('bucket.deleted', id, { - name: existing.name, - bucket: existing.bucket, - region: existing.region, + const existing = rawRows.find(r => String(r.id) === id); + (async () => { + const res = await fetch(`/api/buckets/${id}`, { + method: 'DELETE', + credentials: 'include', }); - } + if (res.ok) { + if (selectedBucket?.id === id) setSelectedBucket(null); + await refreshBuckets(); + if (existing) { + recordBucketEvent('bucket.deleted', id, { + name: existing.alias, + bucket: existing.bucket_name, + region: existing.region, + }); + } + } + })(); }; - const getBucketById = (id: string): BucketWithPermission | undefined => { - return userBuckets.find(b => b.id === id); - }; + const getBucketById = (id: string): BucketWithPermission | undefined => + userBuckets.find(b => b.id === id); - const setBucketStatus = (id: string, status: Bucket['status']) => { - setAllBucketsData(prev => prev.map(b => b.id === id ? { ...b, status } : b)); - }; + const setBucketStatus = (id: string, status: Bucket['status']) => + setStatusMap(prev => ({ ...prev, [id]: status })); const canEditBucket = (bucketId: string): boolean => { if (!user) return false; if (user.role === 'admin') return true; - const bucket = getBucketById(bucketId); - if (!bucket) return false; - return bucket.isOwner; + return getBucketById(bucketId)?.isOwner ?? false; }; const canDeleteBucket = (bucketId: string): boolean => { if (!user) return false; if (user.role === 'admin') return true; - const bucket = getBucketById(bucketId); - if (!bucket) return false; - return bucket.isOwner; + return getBucketById(bucketId)?.isOwner ?? false; }; const canUploadToBucket = (bucketId: string): boolean => { if (!user) return false; - const bucket = getBucketById(bucketId); - if (!bucket) return false; - // Role always wins — viewer can never upload - const role = user.role ?? 'viewer'; - return ['uploader', 'bucket-creator', 'admin'].includes(role); + return ['uploader', 'bucket-creator', 'admin'].includes(user.role ?? 'viewer'); }; - // Deselect if the current bucket is no longer in the user's list + // Drop selected bucket if it disappears from the user's list. useEffect(() => { if (selectedBucket && !userBuckets.some(b => b.id === selectedBucket.id)) { setSelectedBucket(null); @@ -192,9 +217,9 @@ export function BucketProvider({ children }: { children: React.ReactNode }) { }, [userBuckets, selectedBucket]); return ( - {children} diff --git a/src/lib/buckets.ts b/src/lib/buckets.ts index c355419..3d711e6 100644 --- a/src/lib/buckets.ts +++ b/src/lib/buckets.ts @@ -19,6 +19,21 @@ export interface Bucket { is_active: boolean; created_at: Date; updated_at: Date; + // Populated in API responses that carry ownership/assignment context + owner_username?: string; + is_owned?: boolean; + permission?: string | null; +} + +export interface BucketAssignmentRecord { + id: number; + bucket_id: number; + user_id: number; + username: string; + role?: string; + permission: string; + assigned_by?: number; + created_at: Date; } export interface CreateBucketInput { @@ -52,14 +67,16 @@ export interface UpdateBucketInput { export async function getAllBuckets(): Promise { const result = await query( `SELECT - id, user_id, alias, bucket_name, region, root_folder, - access_key_id as access_key_id_encrypted, - secret_access_key as secret_access_key_encrypted, - session_token as session_token_encrypted, - is_active, created_at, updated_at - FROM buckets - WHERE is_active = true - ORDER BY created_at DESC` + b.id, b.user_id, b.alias, b.bucket_name, b.region, b.root_folder, + b.access_key_id AS access_key_id_encrypted, + b.secret_access_key AS secret_access_key_encrypted, + b.session_token AS session_token_encrypted, + b.is_active, b.created_at, b.updated_at, + u.username AS owner_username + FROM buckets b + JOIN users u ON b.user_id = u.id + WHERE b.is_active = true + ORDER BY b.created_at DESC` ); return result.rows.map((row) => { @@ -82,6 +99,7 @@ export async function getAllBuckets(): Promise { is_active: row.is_active, created_at: row.created_at, updated_at: row.updated_at, + owner_username: row.owner_username, }; }); } @@ -504,3 +522,143 @@ export async function getBucketCount(userId: number): Promise { ); return parseInt(result.rows[0].count, 10); } + +/** + * Get all active buckets that have been explicitly assigned to a user + * (i.e. buckets the user does NOT own but has been granted access to). + * Returns decrypted credentials so the client can connect. + */ +export async function getBucketsAssignedToUser(userId: number): Promise { + const result = await query( + `SELECT + b.id, b.user_id, b.alias, b.bucket_name, b.region, b.root_folder, + b.access_key_id AS access_key_id_encrypted, + b.secret_access_key AS secret_access_key_encrypted, + b.session_token AS session_token_encrypted, + b.is_active, b.created_at, b.updated_at, + u.username AS owner_username, + ba.permission + FROM buckets b + JOIN bucket_assignments ba ON b.id = ba.bucket_id + JOIN users u ON b.user_id = u.id + WHERE ba.user_id = $1 AND b.is_active = true + ORDER BY b.created_at DESC`, + [userId] + ); + + return result.rows.map((row) => { + const decrypted = decryptCredentials({ + access_key_id_encrypted: row.access_key_id_encrypted, + secret_access_key_encrypted: row.secret_access_key_encrypted, + session_token_encrypted: row.session_token_encrypted, + }); + return { + id: row.id, + user_id: row.user_id, + alias: row.alias, + bucket_name: row.bucket_name, + region: row.region, + root_folder: row.root_folder, + access_key_id: decrypted.access_key_id, + secret_access_key: decrypted.secret_access_key, + session_token: decrypted.session_token, + is_active: row.is_active, + created_at: row.created_at, + updated_at: row.updated_at, + owner_username: row.owner_username, + is_owned: false, + permission: row.permission, + }; + }); +} + +/** + * Upsert a user's access to a bucket into bucket_assignments. + */ +export async function assignBucketToUser( + bucketId: number, + userId: number, + assignedByUserId: number, + permission: string +): Promise { + try { + await query( + `INSERT INTO bucket_assignments (bucket_id, user_id, permission, assigned_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT (bucket_id, user_id) + DO UPDATE SET permission = EXCLUDED.permission, assigned_by = EXCLUDED.assigned_by`, + [bucketId, userId, permission, assignedByUserId] + ); + return true; + } catch (error) { + console.error('assignBucketToUser error:', error); + return false; + } +} + +/** + * Remove a user's assignment from a bucket. + */ +export async function removeBucketAssignment( + bucketId: number, + userId: number +): Promise { + try { + await query( + 'DELETE FROM bucket_assignments WHERE bucket_id = $1 AND user_id = $2', + [bucketId, userId] + ); + return true; + } catch (error) { + console.error('removeBucketAssignment error:', error); + return false; + } +} + +/** + * Get all user assignments for a single bucket (with username + role). + */ +export async function getAssignmentsByBucketId(bucketId: number): Promise { + const result = await query( + `SELECT ba.id, ba.bucket_id, ba.user_id, ba.permission, ba.assigned_by, ba.created_at, + u.username, u.role + FROM bucket_assignments ba + JOIN users u ON ba.user_id = u.id + WHERE ba.bucket_id = $1 + ORDER BY ba.created_at ASC`, + [bucketId] + ); + return result.rows.map((row) => ({ + id: row.id, + bucket_id: row.bucket_id, + user_id: row.user_id, + username: row.username, + role: row.role, + permission: row.permission, + assigned_by: row.assigned_by, + created_at: row.created_at, + })); +} + +/** + * Get every bucket assignment across all buckets (admin use). + */ +export async function getAllBucketAssignments(): Promise { + const result = await query( + `SELECT ba.id, ba.bucket_id, ba.user_id, ba.permission, ba.assigned_by, ba.created_at, + u.username, u.role + FROM bucket_assignments ba + JOIN users u ON ba.user_id = u.id + ORDER BY ba.bucket_id, ba.created_at ASC` + ); + return result.rows.map((row) => ({ + id: row.id, + bucket_id: row.bucket_id, + user_id: row.user_id, + username: row.username, + role: row.role, + permission: row.permission, + assigned_by: row.assigned_by, + created_at: row.created_at, + })); +} From dd276cb080a2b6b7fde41502c9222fce27a36710 Mon Sep 17 00:00:00 2001 From: Raseel Bhagat Date: Thu, 25 Jun 2026 14:03:50 +0000 Subject: [PATCH 02/17] Fixed Bucket Assignment --- src/app/api/bucket-assignments/route.ts | 105 ++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/app/api/bucket-assignments/route.ts diff --git a/src/app/api/bucket-assignments/route.ts b/src/app/api/bucket-assignments/route.ts new file mode 100644 index 0000000..6c3d278 --- /dev/null +++ b/src/app/api/bucket-assignments/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateSession } from '@/lib/auth'; +import { + getAllBucketAssignments, + assignBucketToUser, + removeBucketAssignment, +} from '@/lib/buckets'; +import { getUserByUsername } from '@/lib/users'; +import { cookies } from 'next/headers'; + +async function requireAdmin(request: NextRequest) { + const cookieStore = await cookies(); + const sessionToken = cookieStore.get('session_token')?.value; + if (!sessionToken) return null; + const user = await validateSession(sessionToken); + if (!user || user.role !== 'admin') return null; + return user; +} + +// GET /api/bucket-assignments — list all assignments (admin only) +export async function GET(request: NextRequest) { + try { + const user = await requireAdmin(request); + if (!user) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + const assignments = await getAllBucketAssignments(); + return NextResponse.json({ assignments }); + } catch (error) { + console.error('GET /api/bucket-assignments error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// POST /api/bucket-assignments — assign a user to a bucket (admin only) +// Body: { bucket_id: number, username: string, permission: string } +export async function POST(request: NextRequest) { + try { + const user = await requireAdmin(request); + if (!user) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + + const body = await request.json(); + const { bucket_id, username, permission } = body; + + if (!bucket_id || !username || !permission) { + return NextResponse.json( + { error: 'bucket_id, username, and permission are required' }, + { status: 400 } + ); + } + + const targetUser = await getUserByUsername(username); + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + const ok = await assignBucketToUser(Number(bucket_id), targetUser.id, user.id, permission); + if (!ok) { + return NextResponse.json({ error: 'Failed to assign user to bucket' }, { status: 500 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('POST /api/bucket-assignments error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// DELETE /api/bucket-assignments — remove a user from a bucket (admin only) +// Body: { bucket_id: number, username: string } +export async function DELETE(request: NextRequest) { + try { + const user = await requireAdmin(request); + if (!user) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + + const body = await request.json(); + const { bucket_id, username } = body; + + if (!bucket_id || !username) { + return NextResponse.json( + { error: 'bucket_id and username are required' }, + { status: 400 } + ); + } + + const targetUser = await getUserByUsername(username); + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + const ok = await removeBucketAssignment(Number(bucket_id), targetUser.id); + if (!ok) { + return NextResponse.json({ error: 'Failed to remove assignment' }, { status: 500 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('DELETE /api/bucket-assignments error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} From a35c74805140bf3a9a80a0bc083a5ef3face8ade Mon Sep 17 00:00:00 2001 From: Raseel Bhagat Date: Fri, 26 Jun 2026 09:03:50 +0000 Subject: [PATCH 03/17] Increase file upload size limit --- next.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/next.config.ts b/next.config.ts index f22a66d..531aca1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,11 @@ import type {NextConfig} from 'next'; const nextConfig: NextConfig = { + experimental: { + serverActions: { + bodySizeLimit: '50mb', // Acceptable formats: '10mb', '500kb', or bytes as a number + }, + }, /* config options here */ output: 'standalone', typescript: { @@ -60,6 +65,7 @@ const nextConfig: NextConfig = { }, ] }, + }; export default nextConfig; From c4a4726332ed21ff0ff3439751b35c78967a0afe Mon Sep 17 00:00:00 2001 From: Raseel Bhagat Date: Fri, 26 Jun 2026 09:32:28 +0000 Subject: [PATCH 04/17] Fixing bucket assignment issue --- src/app/api/buckets/route.ts | 8 +++++++- src/context/BucketContext.tsx | 8 ++++++++ src/instrumentation.ts | 37 +++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/instrumentation.ts diff --git a/src/app/api/buckets/route.ts b/src/app/api/buckets/route.ts index 8bd01a3..81965b2 100644 --- a/src/app/api/buckets/route.ts +++ b/src/app/api/buckets/route.ts @@ -26,7 +26,13 @@ export async function GET(request: NextRequest) { buckets = all.map(b => ({ ...b, is_owned: b.user_id === user.id, permission: null })); } else { const owned = await getBucketsByUserId(user.id); - const assigned = await getBucketsAssignedToUser(user.id); + let assigned: Awaited> = []; + try { + assigned = await getBucketsAssignedToUser(user.id); + } catch (assignErr) { + // Table may not exist yet — owned buckets still render; run `npm run db:migrate` to fix + console.error('getBucketsAssignedToUser failed:', assignErr); + } buckets = [ ...owned.map(b => ({ ...b, is_owned: true, owner_username: user.username, permission: null })), ...assigned, // already carries is_owned:false, owner_username, permission diff --git a/src/context/BucketContext.tsx b/src/context/BucketContext.tsx index 9bfa3b3..2e860a1 100644 --- a/src/context/BucketContext.tsx +++ b/src/context/BucketContext.tsx @@ -84,6 +84,14 @@ export function BucketProvider({ children }: { children: React.ReactNode }) { // Reload whenever the logged-in user changes. useEffect(() => { refreshBuckets(); }, [refreshBuckets]); + // Reload when the browser tab regains focus so newly-assigned buckets appear + // without the user needing to manually refresh the page. + useEffect(() => { + const onVisible = () => { if (document.visibilityState === 'visible') refreshBuckets(); }; + document.addEventListener('visibilitychange', onVisible); + return () => document.removeEventListener('visibilitychange', onVisible); + }, [refreshBuckets]); + const userBuckets: BucketWithPermission[] = React.useMemo( () => rawRows.map(r => mapRow(r, statusMap)), [rawRows, statusMap] diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..6406e6b --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,37 @@ +/** + * Next.js instrumentation hook — runs once when the server starts. + * Ensures required DB tables exist without requiring a manual migration step. + */ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + try { + const { query } = await import('./lib/db'); + + await query(` + CREATE TABLE IF NOT EXISTS bucket_assignments ( + id SERIAL PRIMARY KEY, + bucket_id INTEGER NOT NULL REFERENCES buckets(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission VARCHAR(20) NOT NULL DEFAULT 'read', + assigned_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(bucket_id, user_id) + ) + `); + + await query(` + CREATE INDEX IF NOT EXISTS idx_bucket_assignments_bucket_id + ON bucket_assignments(bucket_id) + `); + + await query(` + CREATE INDEX IF NOT EXISTS idx_bucket_assignments_user_id + ON bucket_assignments(user_id) + `); + + console.log('[DB] bucket_assignments schema ready'); + } catch (e) { + console.error('[DB] Schema migration warning (bucket_assignments):', e); + } + } +} From 27ba1452c10c42f2c1972fdf55ceca9f59e9b351 Mon Sep 17 00:00:00 2001 From: Raseel Bhagat Date: Fri, 26 Jun 2026 10:34:09 +0000 Subject: [PATCH 05/17] DB migration for bucket assignments --- package.json | 1 + scripts/migrate-bucket-assignments.js | 80 +++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 scripts/migrate-bucket-assignments.js diff --git a/package.json b/package.json index 207a4dc..612da45 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "lint": "next lint", "typecheck": "tsc --noEmit", "db:migrate": "node scripts/migrate.js", + "db:migrate:assignments": "node scripts/migrate-bucket-assignments.js", "db:seed": "node scripts/seed.js", "db:reset": "node scripts/reset.js", "generate-keys": "node scripts/generate-keys.js" diff --git a/scripts/migrate-bucket-assignments.js b/scripts/migrate-bucket-assignments.js new file mode 100644 index 0000000..1b5303f --- /dev/null +++ b/scripts/migrate-bucket-assignments.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +/** + * Targeted migration: ensure bucket_assignments table and its indexes exist. + * + * Safe to run on an existing database — uses CREATE TABLE IF NOT EXISTS + * and CREATE INDEX IF NOT EXISTS so no existing data is modified. + * + * Usage: + * node scripts/migrate-bucket-assignments.js + * # or + * npm run db:migrate:assignments + */ + +const { Client } = require('pg'); +require('dotenv').config(); + +async function migrate() { + if (!process.env.DATABASE_URL) { + console.error('❌ DATABASE_URL is not set.'); + console.error(' Copy .env.example to .env and fill in your database details.'); + process.exit(1); + } + + const client = new Client({ connectionString: process.env.DATABASE_URL }); + + try { + console.log('🔌 Connecting to PostgreSQL…'); + await client.connect(); + console.log('✅ Connected'); + + // --- 1. Create bucket_assignments table -------------------------------- + console.log('\n📦 Creating bucket_assignments table (if not exists)…'); + await client.query(` + CREATE TABLE IF NOT EXISTS bucket_assignments ( + id SERIAL PRIMARY KEY, + bucket_id INTEGER NOT NULL REFERENCES buckets(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission VARCHAR(20) NOT NULL DEFAULT 'read', + assigned_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(bucket_id, user_id) + ) + `); + console.log(' ✅ bucket_assignments table ready'); + + // --- 2. Create indexes -------------------------------------------------- + console.log('\n🔍 Creating indexes (if not exists)…'); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_bucket_assignments_bucket_id + ON bucket_assignments(bucket_id) + `); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_bucket_assignments_user_id + ON bucket_assignments(user_id) + `); + console.log(' ✅ Indexes ready'); + + // --- 3. Verify ---------------------------------------------------------- + const check = await client.query(` + SELECT COUNT(*) AS rows FROM bucket_assignments + `); + console.log(`\n📊 bucket_assignments rows: ${check.rows[0].rows}`); + + console.log('\n🎉 Migration completed successfully!\n'); + } catch (err) { + console.error('\n❌ Migration failed:', err.message); + if (err.code === '42P01') { + console.error( + ' Hint: the "buckets" or "users" table is missing. ' + + 'Run `npm run db:migrate` first to apply the full schema.' + ); + } + process.exit(1); + } finally { + await client.end(); + } +} + +migrate(); From 685e766c31e7c8ef25ece84db8bb8eecd2acfa35 Mon Sep 17 00:00:00 2001 From: Raseel Bhagat Date: Sat, 4 Jul 2026 08:18:59 +0000 Subject: [PATCH 06/17] Fixing bucket assignment --- src/app/layout.tsx | 17 ++--- src/context/AuthContext.tsx | 51 ++++++-------- src/context/UserContext.tsx | 129 +----------------------------------- 3 files changed, 29 insertions(+), 168 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 895944b..5024dc7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,7 +3,6 @@ import { Toaster } from "@/components/ui/toaster" import './globals.css'; import { AuthProvider } from '@/context/AuthContext'; import { BucketProvider } from '@/context/BucketContext'; -import { UserProvider } from '@/context/UserContext'; import { BucketAssignmentProvider } from '@/context/BucketAssignmentContext'; import fs from 'fs'; import path from 'path'; @@ -27,15 +26,13 @@ export default function RootLayout({ - - - - - {children} - - - - + + + + {children} + + +
{version} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index d6ce484..45f68d9 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,9 +1,13 @@ - "use client"; import React, { createContext, useState, useContext, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { useUser, type User, type UserRole } from './UserContext'; +import type { UserRole } from './UserContext'; + +export interface User { + username: string; + role: UserRole; +} interface AuthContextType { isAuthenticated: boolean; @@ -11,29 +15,33 @@ interface AuthContextType { user: User | null; isAdmin: boolean; role: UserRole | null; - login: (username: string, pass: string) => boolean; logout: () => void; } const AuthContext = createContext(undefined); +// Keys written by the old localStorage-based implementation — cleared on first load. +const STALE_LS_KEYS = ['s3-user', 's3-users', 's3-buckets', 's3-bucket-assignments']; + export function AuthProvider({ children }: { children: React.ReactNode }) { const [isAuthenticated, setIsAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); const [user, setUser] = useState(null); const router = useRouter(); - const { validateUser, users } = useUser(); - // Restore session from server-side cookie on mount useEffect(() => { let mounted = true; + // One-time cleanup: purge stale keys written by the old localStorage implementation. + try { + if (typeof window !== 'undefined' && window.localStorage) { + STALE_LS_KEYS.forEach(key => window.localStorage.removeItem(key)); + } + } catch { /* storage blocked — ignore */ } + const restoreSession = async () => { try { - const response = await fetch('/api/auth/session', { - credentials: 'include', - }); - + const response = await fetch('/api/auth/session', { credentials: 'include' }); if (mounted) { if (response.ok) { const data = await response.json(); @@ -51,32 +59,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }; restoreSession(); - return () => { mounted = false; }; - }, []); // Only run once on mount - - const login = (username: string, pass: string) => { - if (validateUser(username, pass)) { - const freshUser = users.find(u => u.username === username); - const userToStore = freshUser ?? { username, password: pass, role: 'viewer' as UserRole }; - if (typeof window !== 'undefined' && window.localStorage && typeof window.localStorage.setItem === 'function') { - window.localStorage.setItem('s3-user', JSON.stringify(userToStore)); - } - setUser(userToStore); - setIsAuthenticated(true); - return true; - } - return false; - }; + }, []); const logout = () => { - // Destroy server-side session (fire-and-forget) fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch( - (error) => console.error('Logout API call failed:', error) + error => console.error('Logout API call failed:', error) ); - if (typeof window !== 'undefined' && window.localStorage && typeof window.localStorage.removeItem === 'function') { - window.localStorage.removeItem('s3-user'); - } setIsAuthenticated(false); setUser(null); router.push('/login'); @@ -86,7 +75,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const isAdmin = role === 'admin'; return ( - + {children} ); diff --git a/src/context/UserContext.tsx b/src/context/UserContext.tsx index bd2d996..4836ec7 100644 --- a/src/context/UserContext.tsx +++ b/src/context/UserContext.tsx @@ -1,135 +1,10 @@ - "use client"; -import React, { createContext, useState, useContext, useEffect } from 'react'; -import { useToast } from '@/hooks/use-toast'; - +// All user management is handled by the server-side API (/api/users). +// This file exists only to export shared types used across the app. export type UserRole = 'viewer' | 'uploader' | 'bucket-creator' | 'admin'; export interface User { username: string; - password?: string; // Should be hashed in a real app role: UserRole; } - -interface UserContextType { - users: User[]; - addUser: (username: string, pass: string, role: UserRole) => boolean; - updateUser: (username: string, pass: string) => void; - updateUserRole: (username: string, role: UserRole) => void; - deleteUser: (username: string) => void; - validateUser: (username: string, pass: string) => boolean; -} - -const UserContext = createContext(undefined); - -const defaultUsers: User[] = [ - { username: 'admin', password: 's3brows3r', role: 'admin' } -]; - -export function UserProvider({ children }: { children: React.ReactNode }) { - const { toast } = useToast(); - const [users, setUsers] = useState([]); - const [isLoaded, setIsLoaded] = useState(false); - - useEffect(() => { - if (typeof window !== 'undefined') { - try { - const storage = window.localStorage; - if (storage && typeof storage.getItem === 'function') { - const storedUsers = storage.getItem('s3-users'); - if (storedUsers) { - const parsedUsers = JSON.parse(storedUsers); - // Migrate users without role to 'viewer', admin to 'admin' - const migratedUsers = parsedUsers.map((u: User) => ({ - ...u, - role: u.role ?? (u.username === 'admin' ? 'admin' : 'viewer'), - })); - // Ensure admin user always exists - const adminExists = migratedUsers.some((u: User) => u.username === 'admin'); - if (!adminExists) { - setUsers([...defaultUsers, ...migratedUsers.filter((u: User) => u.username !== 'admin')]); - } else { - setUsers(migratedUsers); - } - } else { - setUsers(defaultUsers); - } - } else { - setUsers(defaultUsers); - } - } catch (e) { - console.error("Failed to load users from localStorage", e); - setUsers(defaultUsers); - } - } else { - setUsers(defaultUsers); - } - setIsLoaded(true); - }, []); - - useEffect(() => { - if (isLoaded && typeof window !== 'undefined') { - try { - const storage = window.localStorage; - if (storage && typeof storage.setItem === 'function') { - storage.setItem('s3-users', JSON.stringify(users)); - } - } catch (e) { - console.error("Failed to save users to localStorage", e); - } - } - }, [users, isLoaded]); - - const addUser = (username: string, pass: string, role: UserRole = 'viewer') => { - if (users.some(u => u.username === username)) { - toast({ variant: 'destructive', title: 'Error', description: 'User already exists.' }); - return false; - } - setUsers(prev => [...prev, { username, password: pass, role }]); - toast({ title: 'Success', description: `User "${username}" added.`, duration: 500 }); - return true; - }; - - const updateUser = (username: string, pass: string) => { - setUsers(prev => prev.map(u => u.username === username ? { ...u, password: pass } : u)); - toast({ title: 'Success', description: `Password for "${username}" updated.`, duration: 500 }); - }; - - const updateUserRole = (username: string, role: UserRole) => { - if (username === 'admin') { - toast({ variant: 'destructive', title: 'Error', description: 'Cannot change admin role.' }); - return; - } - setUsers(prev => prev.map(u => u.username === username ? { ...u, role } : u)); - toast({ title: 'Success', description: `Role for "${username}" updated to ${role}.`, duration: 500 }); - }; - - const deleteUser = (username: string) => { - if (username === 'admin') { - toast({ variant: 'destructive', title: 'Error', description: 'Cannot delete admin user.' }); - return; - } - setUsers(prev => prev.filter(u => u.username !== username)); - toast({ title: 'Success', description: `User "${username}" deleted.`, duration: 500 }); - }; - - const validateUser = (username: string, pass: string) => { - const user = users.find(u => u.username === username); - return !!user && user.password === pass; - }; - - return ( - - {children} - - ); -} - -export function useUser() { - const context = useContext(UserContext); - if (context === undefined) { - throw new Error('useUser must be used within a UserProvider'); - } - return context; -} From 59f54d0ce0f3a07b1df86377b66b28665f950d8f Mon Sep 17 00:00:00 2001 From: Raseel Bhagat Date: Sat, 4 Jul 2026 09:27:29 +0000 Subject: [PATCH 07/17] Added public folder to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4c3b2b9..ab4ff23 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ backup_*.sql # firebase firebase-debug.log firestore-debug.loglogs/ + +# public +/public From 5ed662a20639b76b180bc298029d04feec9bf3a2 Mon Sep 17 00:00:00 2001 From: Raseel Bhagat Date: Sat, 4 Jul 2026 10:01:30 +0000 Subject: [PATCH 08/17] E2E testing --- .gitignore | 4 ++ e2e/bucket-management.spec.ts | 125 ++++++++++++++++++++++++++++++++++ e2e/db.ts | 64 +++++++++++++++++ e2e/fixtures.ts | 23 +++++++ e2e/global-setup.ts | 43 ++++++++++++ e2e/helpers.ts | 18 +++++ package-lock.json | 64 +++++++++++++++++ package.json | 1 + playwright.config.ts | 35 ++++++++++ public/uploads/logo-meta.json | 1 + public/uploads/logo.jpeg | Bin 0 -> 17137 bytes 11 files changed, 378 insertions(+) create mode 100644 e2e/bucket-management.spec.ts create mode 100644 e2e/db.ts create mode 100644 e2e/fixtures.ts create mode 100644 e2e/global-setup.ts create mode 100644 e2e/helpers.ts create mode 100644 playwright.config.ts create mode 100644 public/uploads/logo-meta.json create mode 100644 public/uploads/logo.jpeg diff --git a/.gitignore b/.gitignore index 4c3b2b9..3007c5d 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ backup_*.sql # firebase firebase-debug.log firestore-debug.loglogs/ + +# Playwright +test-results/ +playwright-report/ diff --git a/e2e/bucket-management.spec.ts b/e2e/bucket-management.spec.ts new file mode 100644 index 0000000..16be1eb --- /dev/null +++ b/e2e/bucket-management.spec.ts @@ -0,0 +1,125 @@ +import { test, expect } from '@playwright/test'; +import { login, logout } from './helpers'; +import { ADMIN, VIEWER, TEST_BUCKET, TEST_BUCKET_ALIAS } from './fixtures'; +import { + getBucketRowsByAlias, + getUserByUsername, + getAssignment, + closePool, +} from './db'; + +/** + * End-to-end: admin creates a bucket, assigns it to a non-admin (viewer), + * and the viewer can see the assigned bucket. + * + * Each step asserts BOTH the UI and the database row, so the suite directly + * covers the two reported production bugs: + * 1. deleting a bucket didn't remove it from the DB -> confirmed as an + * intentional soft-delete (is_active=false); asserted in the last test. + * 2. creating a bucket didn't persist to the DB (EC2) -> asserted by reading + * the `buckets` table right after the UI create. + * + * Serial: the tests share one bucket row and must run in order. + */ +test.describe.serial('Bucket create / assign / visibility', () => { + test.afterAll(async () => { + await closePool(); + }); + + test('admin can create a bucket and it persists in the database', async ({ page }) => { + await login(page, ADMIN); + + await page.getByRole('button', { name: 'Add S3 Bucket' }).first().click(); + + // Fill the credentials form (placeholders are unique per field). + await page.getByPlaceholder('My Work Bucket').fill(TEST_BUCKET.alias); + await page.getByPlaceholder('my-awesome-bucket').fill(TEST_BUCKET.bucketName); + await page.getByPlaceholder('us-east-1').fill(TEST_BUCKET.region); + await page.getByPlaceholder('AKIA...').fill(TEST_BUCKET.accessKeyId); + await page.getByPlaceholder('Your secret key').fill(TEST_BUCKET.secretAccessKey); + + await page.getByRole('button', { name: 'Add Bucket' }).click(); + + // UI: the new bucket appears in the list. + await expect(page.getByText(TEST_BUCKET_ALIAS, { exact: true })).toBeVisible({ timeout: 15000 }); + + // DB: exactly one active row was created with the right owner. + await expect + .poll(async () => (await getBucketRowsByAlias(TEST_BUCKET_ALIAS)).length, { timeout: 10000 }) + .toBe(1); + + const [row] = await getBucketRowsByAlias(TEST_BUCKET_ALIAS); + expect(row.is_active).toBe(true); + expect(row.bucket_name).toBe(TEST_BUCKET.bucketName); + expect(row.region).toBe(TEST_BUCKET.region); + + const adminUser = await getUserByUsername(ADMIN.username); + expect(row.user_id).toBe(adminUser!.id); + }); + + test('admin can assign the bucket to a non-admin user', async ({ page }) => { + await login(page, ADMIN); + await page.goto('/bucket-assignments'); + + // Select the bucket. + await page.getByRole('combobox').filter({ hasText: 'Choose bucket' }).click(); + await page.getByRole('option', { name: TEST_BUCKET_ALIAS }).click(); + + // Select the viewer user. + await page.getByRole('combobox').filter({ hasText: 'Choose user' }).click(); + await page.getByRole('option', { name: new RegExp(VIEWER.username) }).click(); + + await page.getByRole('button', { name: 'Assign User' }).click(); + + // UI: the viewer now shows up under the bucket's current assignments. + await expect(page.getByText(VIEWER.username).first()).toBeVisible({ timeout: 15000 }); + + // DB: the assignment row exists. + const [bucket] = await getBucketRowsByAlias(TEST_BUCKET_ALIAS); + const viewer = await getUserByUsername(VIEWER.username); + await expect + .poll(async () => !!(await getAssignment(bucket.id, viewer!.id)), { timeout: 10000 }) + .toBe(true); + }); + + test('non-admin user can see the bucket assigned to them', async ({ page }) => { + await logout(page); + await login(page, VIEWER); + + // Substring match (not exact): the viewer's card title also contains an + // "R/W" permission badge alongside the alias. + const bucket = page.getByText(TEST_BUCKET_ALIAS).first(); + + // The bucket list is driven by AuthContext's own session check, which can + // race the just-set login cookie. A reload deterministically re-runs it. + if (!(await bucket.isVisible().catch(() => false))) { + await page.reload(); + } + + // The assigned bucket is visible to the viewer... + await expect(bucket).toBeVisible({ timeout: 15000 }); + // ...and marked as shared (not owned by the viewer). + await expect(page.getByText(/Shared by/i).first()).toBeVisible(); + }); + + test('deleting a bucket as admin is a soft delete (row stays, is_active=false)', async ({ page }) => { + await login(page, ADMIN); + + await expect(page.getByText(TEST_BUCKET_ALIAS, { exact: true })).toBeVisible({ timeout: 15000 }); + + // The delete (trash) button is an icon-only button with the destructive + // class inside the bucket's card — target it via its lucide-trash icon. + await page.locator('button.text-destructive:has(svg.lucide-trash)').first().click(); + // Confirm in the AlertDialog. + await page.getByRole('button', { name: 'Delete' }).click(); + + // UI: the bucket disappears from the admin's list. + await expect(page.getByText(TEST_BUCKET_ALIAS, { exact: true })).toHaveCount(0, { timeout: 15000 }); + + // DB: the row is NOT hard-deleted — it remains with is_active = false. + // This documents the reported "not deleted from DB" behaviour as intended. + const rows = await getBucketRowsByAlias(TEST_BUCKET_ALIAS); + expect(rows.length).toBe(1); + expect(rows[0].is_active).toBe(false); + }); +}); diff --git a/e2e/db.ts b/e2e/db.ts new file mode 100644 index 0000000..01f5daf --- /dev/null +++ b/e2e/db.ts @@ -0,0 +1,64 @@ +/** + * Test DB helper — connects to the same PostgreSQL the app uses so specs can + * assert what actually landed in the `buckets` / `bucket_assignments` tables. + * + * This is what makes these tests catch the reported bugs: + * - "bucket not deleted from DB" -> we assert is_active flips (soft delete) + * - "bucket not created on EC2" -> we assert a row exists after create + */ +import { Pool } from 'pg'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +function loadDatabaseUrl(): string { + if (process.env.DATABASE_URL) return process.env.DATABASE_URL; + // Fall back to parsing .env (tests may run without Next's env loading). + try { + const envFile = readFileSync(join(process.cwd(), '.env'), 'utf8'); + for (const line of envFile.split('\n')) { + const m = line.match(/^\s*DATABASE_URL\s*=\s*"?([^"\n]+)"?\s*$/); + if (m) return m[1]; + } + } catch { + /* ignore */ + } + throw new Error('DATABASE_URL not found in env or .env'); +} + +export const pool = new Pool({ connectionString: loadDatabaseUrl() }); + +export async function closePool() { + await pool.end(); +} + +/** All rows for a bucket alias, regardless of is_active (so we can see soft-deletes). */ +export async function getBucketRowsByAlias(alias: string) { + const { rows } = await pool.query( + 'SELECT id, alias, bucket_name, region, user_id, is_active FROM buckets WHERE alias = $1 ORDER BY id', + [alias] + ); + return rows as Array<{ + id: number; + alias: string; + bucket_name: string; + region: string; + user_id: number; + is_active: boolean; + }>; +} + +export async function getUserByUsername(username: string) { + const { rows } = await pool.query( + 'SELECT id, username, role, is_active FROM users WHERE username = $1', + [username] + ); + return rows[0] as { id: number; username: string; role: string; is_active: boolean } | undefined; +} + +export async function getAssignment(bucketId: number, userId: number) { + const { rows } = await pool.query( + 'SELECT id, bucket_id, user_id, permission FROM bucket_assignments WHERE bucket_id = $1 AND user_id = $2', + [bucketId, userId] + ); + return rows[0] as { id: number; bucket_id: number; user_id: number; permission: string } | undefined; +} diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..661a276 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,23 @@ +/** Shared test fixtures / constants. */ + +export const ADMIN = { + username: 'admin', + password: 'AdminPass123', +}; + +export const VIEWER = { + username: 'e2e_viewer', + password: 'ViewerPass123', +}; + +export const TEST_BUCKET_ALIAS = 'E2E Test Bucket'; + +export const TEST_BUCKET = { + alias: TEST_BUCKET_ALIAS, + bucketName: 'e2e-test-bucket', + region: 'us-east-1', + // Dummy credentials — we never actually connect to S3; we only verify + // the config is persisted and visible in the UI / DB. + accessKeyId: 'AKIAE2ETESTKEY000000', + secretAccessKey: 'e2eSecretKeyDoNotUse0000000000000000000000', +}; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..649b622 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,43 @@ +/** + * Global setup: provision deterministic test accounts so specs can log in + * without going through the one-time forced-password-change flow (that flow + * is not what these tests exercise). + * + * - admin : reset to a known password, must_change_password = false + * - E2E_VIEWER : created/reset as a `viewer` (non-admin), password known, + * must_change_password = false + * + * It also clears any leftover test buckets/assignments from prior runs so the + * suite is idempotent. + */ +import bcrypt from 'bcrypt'; +import { pool, closePool } from './db'; +import { ADMIN, VIEWER, TEST_BUCKET_ALIAS } from './fixtures'; + +const SALT_ROUNDS = 10; // matches src/lib/auth.ts + +async function upsertUser(username: string, password: string, role: string) { + const hash = await bcrypt.hash(password, SALT_ROUNDS); + await pool.query( + `INSERT INTO users (username, password_hash, role, is_active, must_change_password) + VALUES ($1, $2, $3, true, false) + ON CONFLICT (username) + DO UPDATE SET password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role, + is_active = true, + must_change_password = false`, + [username, hash, role] + ); +} + +async function globalSetup() { + // Clean up test buckets (and their assignments cascade) from previous runs. + await pool.query('DELETE FROM buckets WHERE alias = $1', [TEST_BUCKET_ALIAS]); + + await upsertUser(ADMIN.username, ADMIN.password, 'admin'); + await upsertUser(VIEWER.username, VIEWER.password, 'viewer'); + + await closePool(); +} + +export default globalSetup; diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..b1e2828 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,18 @@ +import { expect, type Page } from '@playwright/test'; + +/** Log in through the real login form and wait for the dashboard. */ +export async function login(page: Page, user: { username: string; password: string }) { + await page.goto('/login'); + await page.getByPlaceholder('Enter username').fill(user.username); + await page.getByPlaceholder('Enter password').fill(user.password); + await page.getByRole('button', { name: 'Sign In' }).click(); + + // login page redirects via window.location after a ~1s toast delay + await page.waitForURL('**/', { timeout: 15000 }); + await expect(page.getByRole('heading', { name: 'Bucket List' })).toBeVisible({ timeout: 15000 }); +} + +/** Log out by clearing the session cookie (HttpOnly — cleared at context level). */ +export async function logout(page: Page) { + await page.context().clearCookies(); +} diff --git a/package-lock.json b/package-lock.json index bd02c87..188b883 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/bcrypt": "^5.0.2", "@types/jszip": "^3.4.1", "@types/node": "^20", @@ -3442,6 +3443,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -10090,6 +10107,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.2", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", diff --git a/package.json b/package.json index 612da45..f26a608 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/bcrypt": "^5.0.2", "@types/jszip": "^3.4.1", "@types/node": "^20", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b88d7c0 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright config for S3 Browser e2e tests. + * + * - Uses the already-installed Chromium (no Google Chrome channel required). + * - Reuses a dev server on :5000 if one is already running, otherwise starts one. + * - Serial (1 worker) because the specs share database state. + */ +export default defineConfig({ + testDir: './e2e', + globalSetup: './e2e/global-setup.ts', + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [['list']], + timeout: 60_000, + use: { + baseURL: 'http://localhost:5000', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], channel: undefined }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5000', + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/public/uploads/logo-meta.json b/public/uploads/logo-meta.json new file mode 100644 index 0000000..b94f106 --- /dev/null +++ b/public/uploads/logo-meta.json @@ -0,0 +1 @@ +{"url":"/uploads/logo.jpeg","updatedAt":"2026-07-04T08:27:56.304Z"} \ No newline at end of file diff --git a/public/uploads/logo.jpeg b/public/uploads/logo.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..c5963ff49ec024da115e4f35b7b980ac145a1a6b GIT binary patch literal 17137 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 vGi~FM0a@HX#Z}Q83! Date: Sat, 4 Jul 2026 10:04:14 +0000 Subject: [PATCH 09/17] Remove the logo file since it is ignore in .gitignore --- public/uploads/logo.jpeg | Bin 17137 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 public/uploads/logo.jpeg diff --git a/public/uploads/logo.jpeg b/public/uploads/logo.jpeg deleted file mode 100644 index c5963ff49ec024da115e4f35b7b980ac145a1a6b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17137 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 vGi~FM0a@HX#Z}Q83! Date: Sat, 4 Jul 2026 10:22:07 +0000 Subject: [PATCH 10/17] Fix AuthContext session race; gate bucket list on single source of truth The home page ran its own /api/auth/session check to decide whether to render, while the bucket list is driven by BucketContext -> AuthContext.user (a separate check). When the two resolved out of sync the page rendered with an empty bucket list, and BucketContext had no loading state so an unpopulated list was indistinguishable from a genuinely empty one and never retried. - page.tsx: consume useAuth() instead of a bespoke session fetch, so page gating and data loading share one session resolution (removes a redundant network call). - BucketContext: add isLoading (true until the authenticated user's buckets have been fetched); home page shows a spinner until then instead of flashing an empty list. - e2e: drop the reload() workaround that masked the bug. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/bucket-management.spec.ts | 10 ++--- src/app/page.tsx | 72 ++++++++++++----------------------- src/context/BucketContext.tsx | 15 +++++++- 3 files changed, 41 insertions(+), 56 deletions(-) diff --git a/e2e/bucket-management.spec.ts b/e2e/bucket-management.spec.ts index 16be1eb..d10d20e 100644 --- a/e2e/bucket-management.spec.ts +++ b/e2e/bucket-management.spec.ts @@ -90,13 +90,9 @@ test.describe.serial('Bucket create / assign / visibility', () => { // "R/W" permission badge alongside the alias. const bucket = page.getByText(TEST_BUCKET_ALIAS).first(); - // The bucket list is driven by AuthContext's own session check, which can - // race the just-set login cookie. A reload deterministically re-runs it. - if (!(await bucket.isVisible().catch(() => false))) { - await page.reload(); - } - - // The assigned bucket is visible to the viewer... + // No reload workaround: the page now gates on AuthContext + bucket loading + // state (single source of truth), so the assigned bucket must appear on + // first load. await expect(bucket).toBeVisible({ timeout: 15000 }); // ...and marked as shared (not owned by the viewer). await expect(page.getByText(/Shared by/i).first()).toBeVisible(); diff --git a/src/app/page.tsx b/src/app/page.tsx index 5931524..b808dd3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -22,61 +22,28 @@ import { AppSidebar } from '@/components/app-sidebar'; type ViewType = 'card' | 'list'; export default function HomePage() { - const { user, isAuthenticated, isLoading } = useAuth(); + // Auth state comes from AuthContext — the single source of truth. Gating the + // page on the SAME session resolution that drives BucketContext prevents the + // "logged in but empty bucket list" race that a separate session check caused. + const { isAuthenticated, isLoading: authLoading } = useAuth(); const { canCreateBucket } = usePermission(); - const { buckets, addBucket, updateBucket, deleteBucket, setBucketStatus, canEditBucket, canDeleteBucket } = useBucket(); + const { buckets, addBucket, updateBucket, deleteBucket, setBucketStatus, canEditBucket, canDeleteBucket, isLoading: bucketsLoading } = useBucket(); const router = useRouter(); const { toast } = useToast(); const [isFormOpen, setIsFormOpen] = useState(false); const [editingBucket, setEditingBucket] = useState(undefined); const [testingConnectionId, setTestingConnectionId] = useState(null); const [view, setView] = useState('card'); - const [sessionChecked, setSessionChecked] = useState(false); - const [hasValidSession, setHasValidSession] = useState(false); + // Redirect unauthenticated users once the session check has resolved. useEffect(() => { - // Check session validity via API (only once on mount) - // Note: Cannot check document.cookie because session_token is HttpOnly - let mounted = true; - - const checkSession = async () => { - console.log("[HOME] Checking session with API..."); - - try { - // Validate session with API - cookie is sent automatically - const response = await fetch('/api/auth/session', { - credentials: 'include', // Required to send cookies - }); - const isValid = response.ok; - console.log("[HOME] Session validation result:", isValid); - - if (mounted) { - setHasValidSession(isValid); - setSessionChecked(true); - - if (!isValid) { - console.log("[HOME] Invalid session, redirecting to login"); - router.push('/login'); - } - } - } catch (error) { - console.error("[HOME] Session check failed:", error); - if (mounted) { - setSessionChecked(true); - setHasValidSession(false); - router.push('/login'); - } - } - }; - - checkSession(); - - return () => { - mounted = false; - }; - }, [router]); + if (!authLoading && !isAuthenticated) { + router.push('/login'); + } + }, [authLoading, isAuthenticated, router]); - if (!sessionChecked) { + // While the session is being restored, show a spinner. + if (authLoading) { return (
@@ -84,11 +51,20 @@ export default function HomePage() { ); } - if (!hasValidSession) { - console.log("[HOME] No valid session, rendering login page"); + // Session resolved and there is no valid session — render login. + if (!isAuthenticated) { return ; } - + + // Authenticated, but buckets haven't finished loading from the database yet. + if (bucketsLoading) { + return ( +
+ +
+ ); + } + const handleAddClick = () => { setEditingBucket(undefined); setIsFormOpen(true); diff --git a/src/context/BucketContext.tsx b/src/context/BucketContext.tsx index 2e860a1..6d96165 100644 --- a/src/context/BucketContext.tsx +++ b/src/context/BucketContext.tsx @@ -37,6 +37,8 @@ interface BucketContextType { canDeleteBucket: (bucketId: string) => boolean; canUploadToBucket: (bucketId: string) => boolean; refreshBuckets: () => Promise; + /** True while the authenticated user's buckets have not yet been fetched. */ + isLoading: boolean; } const BucketContext = createContext(undefined); @@ -65,9 +67,14 @@ export function BucketProvider({ children }: { children: React.ReactNode }) { const [rawRows, setRawRows] = useState([]); const [statusMap, setStatusMap] = useState>({}); const [selectedBucket, setSelectedBucket] = useState(null); + // Username the current rawRows were fetched for. Buckets are considered + // "loading" whenever there is an authenticated user whose buckets have not + // yet been fetched — this lets the UI show a spinner instead of a + // misleading empty list while the fetch is in flight. + const [fetchedFor, setFetchedFor] = useState(null); const refreshBuckets = useCallback(async () => { - if (!user) { setRawRows([]); return; } + if (!user) { setRawRows([]); setFetchedFor(null); return; } try { const res = await fetch('/api/buckets', { credentials: 'include' }); if (res.ok) { @@ -78,9 +85,14 @@ export function BucketProvider({ children }: { children: React.ReactNode }) { } } catch (e) { console.error('BucketContext: failed to load buckets', e); + } finally { + setFetchedFor(user.username); } }, [user?.username]); // eslint-disable-line react-hooks/exhaustive-deps + // Loading = there is a user but rawRows haven't been fetched for them yet. + const isLoading = !!user && fetchedFor !== user.username; + // Reload whenever the logged-in user changes. useEffect(() => { refreshBuckets(); }, [refreshBuckets]); @@ -239,6 +251,7 @@ export function BucketProvider({ children }: { children: React.ReactNode }) { canDeleteBucket, canUploadToBucket, refreshBuckets, + isLoading, }}> {children} From a633f333f01fc869ba1fbe8d3e73cc71f05aea70 Mon Sep 17 00:00:00 2001 From: Raseel Bhagat Date: Sun, 5 Jul 2026 17:11:49 +0000 Subject: [PATCH 11/17] Update the READMEs with the build step --- QUICKSTART.md | 5 +++++ README.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/QUICKSTART.md b/QUICKSTART.md index 640a66f..202217b 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -41,6 +41,11 @@ Wait for: "🎉 Database setup completed!" ```bash npm run dev ``` +Or if you want to build +```bash +npm run build +pm2 start npm --name "s3-browser" -- run start +``` ### 6️⃣ Login Open http://localhost:5000 diff --git a/README.md b/README.md index 74bbd55..d932972 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ cp .env.example .env # 5. Start application npm run dev ``` +Or if you want to Build and run +```bash +npm run build +pm2 start npm --name "s3-browser" -- run start +``` Open [http://localhost:5000](http://localhost:5000) From 2c5c40c6399a0e0ff8c465cf4f2a80b36c5deb43 Mon Sep 17 00:00:00 2001 From: Raseel Bhagat Date: Sun, 12 Jul 2026 18:43:22 +0000 Subject: [PATCH 12/17] Added Claude.md file --- CLAUDE.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0a5cd0c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +S3 Navigator is a self-hosted, database-backed S3 bucket browser built with Next.js 15 (App Router) + PostgreSQL. Users browse/upload/download files across multiple S3-compatible buckets. All AWS SDK calls run server-side (Server Actions) — credentials are never sent to the browser. + +> Note: The README's "Architecture" and "Limitations" sections describe an older **localStorage** design. The current codebase is **PostgreSQL-backed** (users, sessions, encrypted bucket credentials, audit logs). Trust the code in `src/lib/` and `scripts/schema.sql` over the README when they conflict. + +## Commands + +```bash +npm run dev # dev server on http://localhost:5000 (port 5000, not 3000) +npm run build # production build +npm run start # start production build (README uses: pm2 start npm --name s3-browser -- run start) +npm run lint # next lint +npm run typecheck # tsc --noEmit + +npm run generate-keys # generate ENCRYPTION_KEY / secrets for .env + +# Database (wraps docker-compose.db.yml: Postgres 16 + pgAdmin) +./db.sh setup # start db + run migrations + seed (first-time setup) +./db.sh start|stop|status|logs|backup|restore|reset +npm run db:migrate # scripts/migrate.js (applies scripts/schema.sql) +npm run db:migrate:assignments # bucket_assignments migration +npm run db:seed # seed default admin (admin/admin) +npm run db:reset # drop + recreate + +# E2E tests (Playwright, Chromium, serial — specs share DB state) +npx playwright test # all specs (auto-starts dev server on :5000) +npx playwright test e2e/bucket-management.spec.ts # single spec +npx playwright test -g "test name" # single test by title +``` + +There is no unit-test runner configured — only Playwright e2e tests in `e2e/`. + +## Environment + +Required in `.env` (see `.env.example`): `DATABASE_URL`, `ENCRYPTION_KEY` (base64, exactly 32 bytes decoded), `NEXTAUTH_SECRET`. Losing `ENCRYPTION_KEY` makes stored AWS credentials unrecoverable. Optional `LOGO_S3_*` vars enable S3-hosted app logo. + +Ensure every new code changes, like a Bug fix or a new feature implementation, is done on a new branch which is forked from `master` + +## Architecture + +**Auth & session flow** (single source of truth is the DB, not the client): +- `src/middleware.ts` — only checks that a `session_token` cookie *exists*; it does NOT validate. Public routes: `/login`, `/api/auth/login`. Auth-check routes (bypass validation): `/api/auth/session`, `/api/auth/change-password`, `/change-password`. Real validation happens downstream. +- `src/lib/auth.ts` — bcrypt hashing, `authenticate()`, `validateSession()`, sessions table (24h expiry). Enforces `must_change_password`. +- `src/lib/session.ts` — server-side helpers `getCurrentUser()` (redirects to `/login`) and `getCurrentUserOptional()` (returns null). Use these in Server Components and API routes to get the authenticated user. +- `src/context/AuthContext.tsx` — client-side auth state, hydrated from `/api/auth/session`. Client permission gating via `src/hooks/use-permission.ts`. **Client permission checks are UX-only; always re-check authorization server-side.** + +**RBAC roles** (ascending): `viewer` → `uploader` → `bucket-creator` → `admin`. +- `canDownload`: everyone. `canUpload`: uploader+. `canCreateBucket`: bucket-creator+. `canManageUsers`: admin only. + +**S3 operations** — `src/actions/s3.ts` (`'use server'`). All list/download/upload go through Server Actions using `@aws-sdk/client-s3`. Downloads (single/multi/folder-ZIP via `jszip`) are buffered in server memory and returned base64 — large files can time out. Supports optional AWS session tokens (STS/SSO temporary creds). Credentials come decrypted from the DB, never from the client. + +**Credential encryption** — `src/lib/encryption.ts`, AES-256-GCM. Bucket AWS keys are encrypted at rest; `src/lib/buckets.ts` encrypts on write / decrypts on read. + +**Bucket access model** — a bucket is owned by its creator (`buckets.user_id`) but can be shared with other users via `bucket_assignments` (with a `permission` level). API responses populate `owner_username` / `is_owned` / `permission` on `Bucket`. Context: `src/context/BucketContext.tsx` and `BucketAssignmentContext.tsx`. + +**Audit logging** — `src/lib/audit.ts` + `src/actions/audit-record.ts`. All significant operations (auth, bucket CRUD, S3 access) write to `audit_logs`. Viewable at `/admin/audit`. + +**Data layer** — `src/lib/db.ts` exposes `query()` and `transaction()` over a shared `pg` Pool. Domain modules `src/lib/{users,buckets,audit,auth}.ts` wrap it. Schema in `scripts/schema.sql` (tables: `users`, `buckets`, `bucket_assignments`, `audit_logs`, `app_settings`, `sessions`). + +**Routing** — App Router. Pages under `src/app/*` (e.g. `buckets/[id]`, `admin/audit`, `users`, `bucket-assignments`). REST endpoints under `src/app/api/**/route.ts`. + +**AI** — `src/ai/` uses Genkit (`@genkit-ai/googleai`); run with `npm run genkit:dev`. Peripheral to the core app. + +## Conventions + +- Path alias `@/*` → `./src/*`. +- UI: Tailwind + shadcn/ui components live in `src/components/ui/` (`components.json` config). `lucide-react` icons. +- Forms: `react-hook-form` + `zod` (with `@hookform/resolvers`). Zod also validates Server Action inputs (see `S3ConfigSchema` in `src/actions/s3.ts`). +- App version is read from the `VERSION` file at runtime in `src/app/layout.tsx`. +- File upload cap: 100MB per file. From eae4e21b6a7eddb4941c8552af3345ca92ba7b24 Mon Sep 17 00:00:00 2001 From: Raseel Bhagat Date: Thu, 23 Jul 2026 18:15:29 +0000 Subject: [PATCH 13/17] Fixed UI, made Title configurable --- .claude/settings.json | 5 +- public/uploads/logo-meta.json | 2 +- src/actions/settings.ts | 44 +++++ src/app/admin/audit/page.tsx | 2 +- src/app/admin/page.tsx | 4 +- src/app/bucket-assignments/page.tsx | 10 +- src/app/buckets/[id]/page.tsx | 12 +- src/app/globals.css | 249 ++++++++++++++---------- src/app/layout.tsx | 18 +- src/app/login/page.tsx | 71 +++---- src/app/page.tsx | 78 +++++--- src/app/profile/page.tsx | 2 +- src/app/users/page.tsx | 2 +- src/components/app-sidebar.tsx | 281 ++++++++++++++++----------- src/components/branding-settings.tsx | 100 ++++++++++ src/components/s3-browser.tsx | 38 ++-- src/components/theme-toggle.tsx | 41 ++++ src/components/upload-dialog.tsx | 33 ++-- src/context/BucketContext.tsx | 11 ++ src/lib/settings.ts | 60 ++++++ tailwind.config.ts | 13 ++ 21 files changed, 739 insertions(+), 337 deletions(-) create mode 100644 src/actions/settings.ts create mode 100644 src/components/branding-settings.tsx create mode 100644 src/components/theme-toggle.tsx create mode 100644 src/lib/settings.ts diff --git a/.claude/settings.json b/.claude/settings.json index 71bad7d..959ae08 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,10 @@ { "permissions": { "allow": [ - "Bash(ls /home/raseel/code/s3-browser/graphify-out/ 2>/dev/null || mkdir -p /home/raseel/code/s3-browser/graphify-out)" + "Bash(ls /home/raseel/code/s3-browser/graphify-out/ 2>/dev/null || mkdir -p /home/raseel/code/s3-browser/graphify-out)", + "WebFetch(domain:www.avanse.com)", + "WebSearch", + "Bash(curl -sL --max-time 25 -A \"Mozilla/5.0\" https://www.avanse.com/ -o avanse.html)" ], "additionalDirectories": [ "/home/raseel/code/s3-browser/graphify-out" diff --git a/public/uploads/logo-meta.json b/public/uploads/logo-meta.json index b94f106..9a44071 100644 --- a/public/uploads/logo-meta.json +++ b/public/uploads/logo-meta.json @@ -1 +1 @@ -{"url":"/uploads/logo.jpeg","updatedAt":"2026-07-04T08:27:56.304Z"} \ No newline at end of file +{"url":"/uploads/logo.jpeg","updatedAt":"2026-07-23T18:12:32.480Z"} \ No newline at end of file diff --git a/src/actions/settings.ts b/src/actions/settings.ts new file mode 100644 index 0000000..179d6e4 --- /dev/null +++ b/src/actions/settings.ts @@ -0,0 +1,44 @@ +'use server'; + +import { getCurrentUserOptional } from '@/lib/session'; +import { createAuditLog } from '@/lib/audit'; +import { getBranding, setBranding, DEFAULT_BRANDING, type BrandingSettings } from '@/lib/settings'; + +/** + * Public read — used by the login page and headers. Never throws. + */ +export async function getBrandingSettings(): Promise { + try { + return await getBranding(); + } catch { + return DEFAULT_BRANDING; + } +} + +/** + * Admin-only write. Re-checks authorization server-side (client gating is UX-only). + */ +export async function updateBrandingSettings( + branding: BrandingSettings +): Promise<{ success: boolean; branding?: BrandingSettings; error?: string }> { + const user = await getCurrentUserOptional(); + if (!user || user.role !== 'admin') { + return { success: false, error: 'Not authorized' }; + } + + try { + const saved = await setBranding(branding, user.id); + await createAuditLog({ + user_id: user.id, + username: user.username, + action: 'settings.branding_update', + resource_type: 'settings', + details: saved, + status: 'success', + }); + return { success: true, branding: saved }; + } catch (err) { + console.error('[Settings] Branding update failed:', err); + return { success: false, error: 'Failed to save branding settings' }; + } +} diff --git a/src/app/admin/audit/page.tsx b/src/app/admin/audit/page.tsx index e5ab825..0972c66 100644 --- a/src/app/admin/audit/page.tsx +++ b/src/app/admin/audit/page.tsx @@ -244,7 +244,7 @@ export default function AuditLogPage() { } return ( -
+
} />
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index fc6491a..e3545c8 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; import { Loader2, Settings } from 'lucide-react'; import { LogoUpload } from '@/components/logo-upload'; +import { BrandingSettings } from '@/components/branding-settings'; import { AppSidebar } from '@/components/app-sidebar'; export default function AdminSettingsPage() { @@ -24,11 +25,12 @@ export default function AdminSettingsPage() { } return ( -
+
} />
+
); diff --git a/src/app/bucket-assignments/page.tsx b/src/app/bucket-assignments/page.tsx index 67e6776..ce4add3 100644 --- a/src/app/bucket-assignments/page.tsx +++ b/src/app/bucket-assignments/page.tsx @@ -97,7 +97,7 @@ export default function BucketAssignmentsPage() { }; return ( -
+
} />
@@ -111,9 +111,9 @@ export default function BucketAssignmentsPage() {
- + - + diff --git a/src/app/buckets/[id]/page.tsx b/src/app/buckets/[id]/page.tsx index 4a6a369..e6d572b 100644 --- a/src/app/buckets/[id]/page.tsx +++ b/src/app/buckets/[id]/page.tsx @@ -4,8 +4,9 @@ import { useBucket } from "@/context/BucketContext"; import { useAuth } from "@/context/AuthContext"; import { useRouter, useParams } from "next/navigation"; import S3Browser from "@/components/s3-browser"; +import { AppSidebar } from "@/components/app-sidebar"; import { useEffect, useState } from "react"; -import { Loader2 } from "lucide-react"; +import { Loader2, HardDrive } from "lucide-react"; export default function BucketBrowserPage() { const router = useRouter(); @@ -46,8 +47,11 @@ export default function BucketBrowserPage() { } return ( -
- -
+
+ } /> +
+ +
+
); } diff --git a/src/app/globals.css b/src/app/globals.css index 38a748f..894bfd6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,50 +6,95 @@ body { font-family: 'Inter', sans-serif; } +/* + * S3 Navigator — corporate theme. + * Brand palette sourced from avanse.com: + * Primary teal #198694 hsl(187 71% 34%) + * Accent orange #ec7426 hsl(24 84% 54%) + * Highlight gold #ffc20e hsl(45 100% 53%) + * Flat, clean surfaces — no skeuomorphism. + */ @layer base { :root { - --background: 220 20% 92%; - --foreground: 210 25% 18%; + --background: 210 20% 98%; + --foreground: 200 25% 16%; --card: 0 0% 100%; - --card-foreground: 210 25% 18%; + --card-foreground: 200 25% 16%; --popover: 0 0% 100%; - --popover-foreground: 210 25% 18%; - --primary: 204 60% 45%; + --popover-foreground: 200 25% 16%; + --primary: 187 71% 34%; --primary-foreground: 0 0% 100%; - --secondary: 220 14% 85%; - --secondary-foreground: 210 25% 18%; - --muted: 220 14% 88%; - --muted-foreground: 210 9% 45%; - --accent: 204 60% 38%; + --secondary: 200 20% 94%; + --secondary-foreground: 200 25% 20%; + --muted: 200 20% 95%; + --muted-foreground: 200 12% 42%; + --accent: 24 84% 54%; --accent-foreground: 0 0% 100%; --destructive: 0 72% 51%; --destructive-foreground: 0 0% 98%; - --border: 220 15% 78%; - --input: 220 15% 88%; - --ring: 204 60% 45%; - --radius: 0.6rem; + --success: 145 63% 32%; + --success-foreground: 0 0% 100%; + --warning: 38 92% 45%; + --warning-foreground: 0 0% 100%; + --border: 200 18% 88%; + --input: 200 18% 88%; + --ring: 187 71% 34%; + --radius: 0.5rem; + + /* Brand accents for direct use */ + --brand-teal: 187 71% 34%; + --brand-teal-dark: 185 84% 27%; + --brand-orange: 24 84% 54%; + --brand-gold: 45 100% 53%; + + --sidebar-background: 0 0% 100%; + --sidebar-foreground: 200 25% 20%; + --sidebar-primary: 187 71% 34%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 200 20% 95%; + --sidebar-accent-foreground: 200 25% 16%; + --sidebar-border: 200 18% 88%; + --sidebar-ring: 187 71% 34%; } .dark { - --background: 210 18% 12%; + --background: 200 30% 9%; --foreground: 0 0% 95%; - --card: 210 18% 16%; + --card: 200 26% 13%; --card-foreground: 0 0% 95%; - --popover: 210 18% 16%; + --popover: 200 26% 13%; --popover-foreground: 0 0% 95%; - --primary: 204 60% 55%; + --primary: 187 60% 45%; --primary-foreground: 0 0% 100%; - --secondary: 220 13% 22%; + --secondary: 200 18% 20%; --secondary-foreground: 0 0% 95%; - --muted: 220 13% 20%; - --muted-foreground: 210 9% 60%; - --accent: 195 53% 65%; - --accent-foreground: 210 18% 12%; - --destructive: 0 55% 40%; + --muted: 200 18% 18%; + --muted-foreground: 200 10% 62%; + --accent: 24 80% 56%; + --accent-foreground: 0 0% 100%; + --destructive: 0 62% 45%; --destructive-foreground: 0 0% 98%; - --border: 220 13% 28%; - --input: 220 13% 24%; - --ring: 204 60% 55%; + --success: 145 55% 45%; + --success-foreground: 0 0% 100%; + --warning: 38 90% 55%; + --warning-foreground: 0 0% 12%; + --border: 200 16% 26%; + --input: 200 16% 24%; + --ring: 187 60% 45%; + + --brand-teal: 187 60% 45%; + --brand-teal-dark: 187 60% 38%; + --brand-orange: 24 80% 56%; + --brand-gold: 45 90% 55%; + + --sidebar-background: 200 26% 13%; + --sidebar-foreground: 0 0% 92%; + --sidebar-primary: 187 60% 45%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 200 18% 20%; + --sidebar-accent-foreground: 0 0% 95%; + --sidebar-border: 200 16% 26%; + --sidebar-ring: 187 60% 45%; } } @@ -62,115 +107,105 @@ body { } } -/* ── Skeuomorphism Design System ── */ +/* + * Corporate surface system. + * These class names are kept from the previous skeuomorphic theme so existing + * markup transforms automatically — but every rule below is now a flat, + * professional style. + */ @layer components { - /* Page background — brushed linen texture feel */ + /* Page background — clean, flat app canvas */ .skeu-bg { - background-color: #dde3ec; - background-image: - linear-gradient(135deg, #e8edf5 25%, transparent 25%), - linear-gradient(225deg, #dde3ec 25%, transparent 25%), - linear-gradient(45deg, #dde3ec 25%, transparent 25%), - linear-gradient(315deg, #e8edf5 25%, #dde3ec 25%); - background-size: 4px 4px; + background-color: hsl(var(--background)); } - /* Card — raised, beveled look */ + /* Card — flat panel with hairline border + soft elevation */ .skeu-card, .skeu-card.border { - background: linear-gradient(160deg, #ffffff 0%, #f0f4fa 100%); - border: 1px solid #b8c4d8; - border-top-color: #d8e2f0; - border-left-color: #d8e2f0; - border-bottom-color: #8fa0bc; - border-right-color: #8fa0bc; - box-shadow: - 0 1px 0 0 rgba(255,255,255,0.8) inset, - 0 -1px 0 0 rgba(0,0,0,0.08) inset, - 0 4px 12px rgba(0,0,0,0.12), - 0 1px 3px rgba(0,0,0,0.08); - border-radius: 12px; - } - - /* Header bar — brushed metal */ + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + box-shadow: 0 1px 2px rgba(16, 40, 55, 0.04), 0 1px 3px rgba(16, 40, 55, 0.06); + border-radius: var(--radius); + } + + /* Page content offset for the fixed desktop rail. Width follows the + sidebar's collapsed state via the data attribute on . */ + .app-content { + transition: padding-left 0.3s ease; + } + @media (min-width: 768px) { + .app-content { + padding-left: 16rem; + } + html[data-sidebar-collapsed='true'] .app-content { + padding-left: 4rem; + } + } + + /* Header bar — white app chrome with a brand top accent */ .skeu-header { - background: linear-gradient(180deg, #e8eef8 0%, #d0daea 50%, #c8d2e6 100%); - border-bottom: 1px solid #a0aec0; - box-shadow: - 0 1px 0 rgba(255,255,255,0.7) inset, - 0 2px 6px rgba(0,0,0,0.12); + background: hsl(var(--card)); + border-bottom: 1px solid hsl(var(--border)); + box-shadow: inset 0 3px 0 hsl(var(--brand-teal)), 0 1px 2px rgba(16, 40, 55, 0.05); } - /* Button — tactile, pressable */ + /* Button — solid brand fill, flat */ .skeu-btn { - background: linear-gradient(180deg, #5b9bd5 0%, #3b7ec8 50%, #2c6db8 100%); - border: 1px solid #2059a0; - border-bottom: 2px solid #1a4a8a; - color: white; - text-shadow: 0 1px 1px rgba(0,0,0,0.3); - box-shadow: - 0 1px 0 rgba(255,255,255,0.25) inset, - 0 3px 6px rgba(0,0,0,0.2); - border-radius: 8px; - transition: all 0.1s ease; + background: hsl(var(--brand-teal)); + border: 1px solid hsl(var(--brand-teal-dark)); + color: hsl(var(--primary-foreground)); + box-shadow: 0 1px 2px rgba(16, 40, 55, 0.12); + border-radius: var(--radius); + transition: background-color 0.15s ease; } .skeu-btn:hover { - background: linear-gradient(180deg, #6baae0 0%, #4b8ed8 50%, #3c7dc8 100%); + background: hsl(var(--brand-teal-dark)); } .skeu-btn:active { - transform: translateY(1px); - box-shadow: 0 1px 3px rgba(0,0,0,0.2); + background: hsl(var(--brand-teal-dark)); } - /* Input fields — inset/recessed look */ + /* Input fields — flat, bordered */ .skeu-input input, .skeu-input { - background: linear-gradient(180deg, #e8edf5 0%, #f5f7fb 100%); - border: 1px solid #9ab0cc; - border-top-color: #7090b0; - box-shadow: - 0 2px 4px rgba(0,0,0,0.1) inset, - 0 1px 0 rgba(255,255,255,0.6); - border-radius: 6px; + background: hsl(var(--card)); + border: 1px solid hsl(var(--input)); + box-shadow: none; + border-radius: calc(var(--radius) - 2px); } - /* Table — ledger paper feel */ + /* Table — clean data grid */ .skeu-table thead tr { - background: linear-gradient(180deg, #dde5f2 0%, #ccd5e8 100%); - border-bottom: 2px solid #a0b0cc; + background: hsl(var(--muted)); + border-bottom: 1px solid hsl(var(--border)); } - .skeu-table tbody tr:nth-child(even) { - background: rgba(255,255,255,0.5); + .skeu-table thead th { + color: hsl(var(--muted-foreground)); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + font-size: 0.72rem; } - .skeu-table tbody tr:nth-child(odd) { - background: rgba(210,220,235,0.3); + .skeu-table tbody tr { + border-bottom: 1px solid hsl(var(--border)); } .skeu-table tbody tr:hover { - background: rgba(180,200,230,0.4); + background: hsl(var(--secondary)); } - /* Login card — thick, embossed */ + /* Login card — flat, moderately elevated */ .skeu-login-card { - background: linear-gradient(160deg, #ffffff 0%, #edf2fa 100%); - border: 1px solid #b0c4dc; - border-top-color: #e0eaf8; - border-left-color: #e0eaf8; - border-bottom-color: #7898bc; - border-right-color: #7898bc; - box-shadow: - 0 2px 0 rgba(255,255,255,0.9) inset, - 0 -1px 0 rgba(0,0,0,0.08) inset, - 0 8px 24px rgba(0,0,0,0.18), - 0 2px 6px rgba(0,0,0,0.1); - border-radius: 16px; - } - - /* Badge pills — embossed */ + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + box-shadow: 0 10px 30px rgba(16, 40, 55, 0.10), 0 2px 6px rgba(16, 40, 55, 0.06); + border-radius: calc(var(--radius) + 4px); + } + + /* Badge pills — flat */ .skeu-badge { - box-shadow: - 0 1px 0 rgba(255,255,255,0.6) inset, - 0 1px 3px rgba(0,0,0,0.15); - border: 1px solid rgba(0,0,0,0.1); + box-shadow: none; + border: 1px solid transparent; + font-weight: 600; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5024dc7..542bbff 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,11 +6,15 @@ import { BucketProvider } from '@/context/BucketContext'; import { BucketAssignmentProvider } from '@/context/BucketAssignmentContext'; import fs from 'fs'; import path from 'path'; +import { getBranding } from '@/lib/settings'; -export const metadata: Metadata = { - title: 'S3 Navigator', - description: 'A simple AWS S3 bucket browser app.', -}; +export async function generateMetadata(): Promise { + const branding = await getBranding(); + return { + title: branding.title, + description: branding.subtitle, + }; +} export default function RootLayout({ children, @@ -21,6 +25,12 @@ export default function RootLayout({ return ( +