diff --git a/app/(app)/error.tsx b/app/(app)/error.tsx new file mode 100644 index 0000000..6dcc1c2 --- /dev/null +++ b/app/(app)/error.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useEffect } from 'react' +import { AlertTriangle, RefreshCw, ArrowLeft } from 'lucide-react' +import Link from 'next/link' + +export default function AppError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error('[App Section Error]', error) + }, [error]) + + return ( +
+
+
+
+ +
+ +

+ Something went wrong +

+ +

+ We encountered an error while loading this page. +

+ + {error.digest && ( +

+ {error.digest} +

+ )} + +
+ + + + + Dashboard + +
+
+
+
+ ) +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index e4849d5..eabb738 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -41,7 +41,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod return (
diff --git a/app/(app)/loading.tsx b/app/(app)/loading.tsx new file mode 100644 index 0000000..9ad3dff --- /dev/null +++ b/app/(app)/loading.tsx @@ -0,0 +1,12 @@ +import { Loader2 } from 'lucide-react' + +export default function AppLoading() { + return ( +
+
+ +

Loading...

+
+
+ ) +} diff --git a/app/(app)/ops/page.tsx b/app/(app)/ops/page.tsx index 8c8a2c8..236d8ce 100644 --- a/app/(app)/ops/page.tsx +++ b/app/(app)/ops/page.tsx @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { OpsClient } from './ops-client' +import type { Tables } from '@/types/database' export default async function OpsPage() { const supabase = await createClient() @@ -16,17 +17,17 @@ export default async function OpsPage() { supabase.from('knowledge_base').select('*').eq('space_id', member.space_id).order('is_pinned', { ascending: false }).order('created_at', { ascending: false }), supabase.from('area_leads').select('*').eq('space_id', member.space_id).order('area_name'), canSeeSecrets - ? supabase.from('secrets').select('id, title, area, created_at').eq('space_id', member.space_id) - : Promise.resolve({ data: [] }), + ? supabase.from('secrets').select('id, title, area, created_at, label, value, description, icon, space_id, created_by, updated_at, category, notes').eq('space_id', member.space_id) + : Promise.resolve({ data: [] as Tables<'secrets'>[] }), ]) return ( } spaceId={member.space_id} kbEntries={kbEntries ?? []} areaLeads={areaLeads ?? []} - secrets={(secretsResult as any)?.data ?? []} + secrets={secretsResult.data ?? []} canSeeSecrets={canSeeSecrets} /> ) diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..51da99f --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,60 @@ +'use client' + +import { useEffect } from 'react' +import { AlertTriangle, RefreshCw, Home } from 'lucide-react' +import Link from 'next/link' + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error('[App Error]', error) + }, [error]) + + return ( +
+
+
+ +
+ +

+ Something went wrong +

+ +

+ An unexpected error occurred. Please try again or contact support if the problem persists. +

+ + {error.digest && ( +

+ Error ID: {error.digest} +

+ )} + +
+ + + + + Go to Dashboard + +
+
+
+ ) +} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..e9df911 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useEffect } from 'react' + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error('[Global Error]', error) + }, [error]) + + return ( + + +
+
+
+ + + +
+ +

+ Critical Error +

+ +

+ A critical error occurred. Please refresh the page or try again later. +

+ + {error.digest && ( +

+ Error ID: {error.digest} +

+ )} + + +
+
+ + + ) +} diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..08d57e9 --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,18 @@ +export default function Loading() { + return ( +
+
+
+ + {'{'}hs{'}'} + +
+
+
+
+
+
+
+
+ ) +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 5076feb..4762c04 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -11,6 +11,7 @@ export default function LoginPage() { const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [oauthLoading, setOauthLoading] = useState<'github' | 'google' | null>(null) async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -27,6 +28,22 @@ export default function LoginPage() { } } + async function handleOAuthLogin(provider: 'github' | 'google') { + setOauthLoading(provider) + setError('') + const supabase = createClient() + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${window.location.origin}/auth/callback`, + }, + }) + if (error) { + setError(error.message) + setOauthLoading(null) + } + } + return (
{/* Grid background */} @@ -102,20 +119,30 @@ export default function LoginPage() {
- -
diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..4cb2ec2 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,42 @@ +import Link from 'next/link' +import { FileQuestion, Home, ArrowLeft } from 'lucide-react' + +export default function NotFound() { + return ( +
+
+
+ +
+ +

404

+ +

+ Page not found +

+ +

+ The page you're looking for doesn't exist or has been moved. +

+ +
+ + + Go to Dashboard + + + + + Back to Home + +
+
+
+ ) +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index a663213..95daa3b 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -5,7 +5,10 @@ import { usePathname } from 'next/navigation' import { useState } from 'react' import { cn } from '@/lib/utils' import { signOut } from '@/lib/auth-actions' -import type { SpaceMember, Space } from '@/lib/types' +import type { Tables } from '@/types/database' + +type SpaceMember = Tables<'space_members'> +type Space = Tables<'spaces'> import { Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, MessageSquare, Users, CreditCard, BookUser, Download, LogOut, @@ -46,7 +49,7 @@ function NavLink({ href, label, icon: Icon, badge, active, onClick }: NavLinkPro } interface AppSidebarProps { - member: SpaceMember & { spaces: Space } + member: SpaceMember & { spaces: Space | null } taskBadge?: number commsBadge?: number paymentBadge?: number diff --git a/lib/actions.ts b/lib/actions.ts index 362ffa8..5831da0 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -5,6 +5,25 @@ import { redirect } from 'next/navigation' import { revalidatePath } from 'next/cache' import type { SupabaseClient } from '@supabase/supabase-js' import type { Database } from '@/types/database' +import { + createTaskSchema, + taskIdSchema, + createProjectSchema, + updateProjectStatusSchema, + addMemberSchema, + updateMemberSchema, + createContactSchema, + updateContactSchema, + createKbEntrySchema, + updateKbEntrySchema, + createSecretSchema, + logCashPaymentSchema, + linkPaymentSchema, + updateSpaceSettingsSchema, + saveIntegrationSchema, + upsertAreaLeadSchema, + uuidSchema, +} from '@/lib/validations' // ─── Auth ──────────────────────────────────────────────────────────────────── @@ -61,6 +80,13 @@ export async function createTask(formData: { recurrence?: string due_date?: string }) { + // Validate input + const parsed = createTaskSchema.safeParse(formData) + if (!parsed.success) { + return { error: parsed.error.errors[0]?.message || 'Invalid input' } + } + const input = parsed.data + const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) return { error: 'Not authenticated' } @@ -70,12 +96,12 @@ export async function createTask(formData: { const { data, error } = await supabase.from('tasks').insert({ space_id: member.space_id, - title: formData.title, - description: formData.description, - task_type: formData.type || 'task', - area: formData.area, - recurrence: formData.recurrence || 'none', - due_date: formData.due_date || null, + title: input.title, + description: input.description, + task_type: input.type || 'task', + area: input.area, + recurrence: input.recurrence || 'none', + due_date: input.due_date || null, status: 'open', requested_by: user.id, requested_by_name: member.display_name, @@ -100,6 +126,9 @@ export async function createTask(formData: { } export async function claimTask(taskId: string) { + const idResult = taskIdSchema.safeParse(taskId) + if (!idResult.success) return { error: 'Invalid task ID' } + const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) return { error: 'Not authenticated' } @@ -111,7 +140,7 @@ export async function claimTask(taskId: string) { claimed_by: user.id, claimed_by_name: member.display_name, status: 'claimed', - }).eq('id', taskId).eq('space_id', member.space_id) + }).eq('id', idResult.data).eq('space_id', member.space_id) if (error) return { error: error.message } diff --git a/lib/auth-actions.ts b/lib/auth-actions.ts index 3eae4ae..44991b6 100644 --- a/lib/auth-actions.ts +++ b/lib/auth-actions.ts @@ -3,6 +3,7 @@ import { createClient } from '@/lib/supabase/server' import { createAdminClient } from '@/lib/supabase/admin' import { redirect } from 'next/navigation' +import { checkRateLimit, sanitizeString, sanitizeSlug } from '@/lib/security' function generateInviteCode() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' @@ -24,6 +25,22 @@ export async function createSpace(formData: { const { data: { user }, error: userErr } = await supabase.auth.getUser() if (userErr || !user) return { error: 'Not authenticated' } + // Rate limit space creation + const rateLimit = checkRateLimit(`createspace:${user.id}`, 3, 3600000) // 3 per hour + if (!rateLimit.allowed) { + return { error: 'Too many space creation attempts. Please try again later.' } + } + + // Sanitize inputs + const spaceName = sanitizeString(formData.spaceName).slice(0, 100) + const spaceSlug = sanitizeSlug(formData.spaceSlug) + const spaceCity = formData.spaceCity ? sanitizeString(formData.spaceCity).slice(0, 100) : null + const displayName = sanitizeString(formData.displayName).slice(0, 100) + + if (!spaceName || !spaceSlug || !displayName) { + return { error: 'Invalid input. Please check your entries.' } + } + // Use admin client for inserts (bypasses RLS) const admin = createAdminClient() @@ -31,7 +48,7 @@ export async function createSpace(formData: { const { data: existingSpace } = await admin .from('spaces') .select('id') - .eq('slug', formData.spaceSlug.toLowerCase().trim()) + .eq('slug', spaceSlug) .maybeSingle() if (existingSpace) { @@ -42,9 +59,9 @@ export async function createSpace(formData: { const { data: space, error: spaceErr } = await admin .from('spaces') .insert({ - name: formData.spaceName, - slug: formData.spaceSlug.toLowerCase().trim(), - city: formData.spaceCity || null, + name: spaceName, + slug: spaceSlug, + city: spaceCity, invite_code: generateInviteCode(), }) .select('id') @@ -63,7 +80,7 @@ export async function createSpace(formData: { .insert({ space_id: space.id, user_id: user.id, - display_name: formData.displayName || user.email, + display_name: displayName || user.email, email: user.email, role: 'admin', tier: 'plus', @@ -117,6 +134,12 @@ export async function joinSpace(formData: { } export async function signIn(email: string, password: string) { + // Rate limit by email to prevent brute force + const rateLimit = checkRateLimit(`signin:${email.toLowerCase()}`, 5, 60000) + if (!rateLimit.allowed) { + return { error: 'Too many login attempts. Please try again later.' } + } + const supabase = await createClient() const { data, error } = await supabase.auth.signInWithPassword({ diff --git a/lib/security.ts b/lib/security.ts new file mode 100644 index 0000000..d500287 --- /dev/null +++ b/lib/security.ts @@ -0,0 +1,172 @@ +/** + * Security utilities for input sanitization and validation + */ + +/** + * Sanitize a string by removing potentially dangerous characters + * Prevents XSS and injection attacks when displaying user input + */ +export function sanitizeString(input: string): string { + if (typeof input !== 'string') return '' + return input + .replace(/[<>]/g, '') // Remove HTML brackets + .replace(/javascript:/gi, '') // Remove javascript: protocol + .replace(/on\w+=/gi, '') // Remove event handlers + .trim() +} + +/** + * Sanitize HTML content - strips all HTML tags + * Use when you need plain text from user input + */ +export function stripHtml(input: string): string { + if (typeof input !== 'string') return '' + return input.replace(/<[^>]*>/g, '').trim() +} + +/** + * Escape HTML entities for safe display + * Use when you need to display user input in HTML context + */ +export function escapeHtml(input: string): string { + if (typeof input !== 'string') return '' + const htmlEntities: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + } + return input.replace(/[&<>"'/]/g, char => htmlEntities[char] || char) +} + +/** + * Validate and sanitize email address + */ +export function sanitizeEmail(email: string): string | null { + if (typeof email !== 'string') return null + const sanitized = email.toLowerCase().trim() + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(sanitized) ? sanitized : null +} + +/** + * Sanitize URL - only allow http/https protocols + */ +export function sanitizeUrl(url: string): string | null { + if (typeof url !== 'string') return null + try { + const parsed = new URL(url.trim()) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + return parsed.href + } catch { + return null + } +} + +/** + * Sanitize slug - only allow lowercase alphanumeric and hyphens + */ +export function sanitizeSlug(input: string): string { + if (typeof input !== 'string') return '' + return input + .toLowerCase() + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 50) +} + +/** + * Check if input contains potential SQL injection patterns + * Note: Always use parameterized queries - this is an additional safety layer + */ +export function hasSqlInjectionPatterns(input: string): boolean { + if (typeof input !== 'string') return false + const patterns = [ + /(\b(SELECT|INSERT|UPDATE|DELETE|DROP|UNION|ALTER|CREATE|TRUNCATE)\b)/i, + /(--|;|\/\*|\*\/)/, + /(\b(OR|AND)\b\s+\d+\s*=\s*\d+)/i, + ] + return patterns.some(pattern => pattern.test(input)) +} + +/** + * Validate UUID format + */ +export function isValidUuid(id: string): boolean { + if (typeof id !== 'string') return false + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + return uuidRegex.test(id) +} + +/** + * Truncate string to max length with ellipsis + */ +export function truncate(input: string, maxLength: number): string { + if (typeof input !== 'string') return '' + if (input.length <= maxLength) return input + return input.slice(0, maxLength - 3) + '...' +} + +/** + * Simple in-memory rate limiter for server actions + * For production, use Redis-based rate limiting + */ +const rateLimitStore = new Map() + +export function checkRateLimit( + identifier: string, + maxRequests: number = 10, + windowMs: number = 60000 +): { allowed: boolean; remaining: number; resetIn: number } { + const now = Date.now() + const entry = rateLimitStore.get(identifier) + + if (!entry || now > entry.resetTime) { + rateLimitStore.set(identifier, { count: 1, resetTime: now + windowMs }) + return { allowed: true, remaining: maxRequests - 1, resetIn: windowMs } + } + + if (entry.count >= maxRequests) { + return { allowed: false, remaining: 0, resetIn: entry.resetTime - now } + } + + entry.count++ + return { allowed: true, remaining: maxRequests - entry.count, resetIn: entry.resetTime - now } +} + +/** + * Clear expired rate limit entries (call periodically) + */ +export function cleanupRateLimits(): void { + const now = Date.now() + for (const [key, entry] of rateLimitStore.entries()) { + if (now > entry.resetTime) { + rateLimitStore.delete(key) + } + } +} + +/** + * Validate content length + */ +export function validateContentLength( + content: string, + minLength: number = 0, + maxLength: number = 10000 +): { valid: boolean; error?: string } { + if (typeof content !== 'string') { + return { valid: false, error: 'Content must be a string' } + } + if (content.length < minLength) { + return { valid: false, error: `Content must be at least ${minLength} characters` } + } + if (content.length > maxLength) { + return { valid: false, error: `Content must be less than ${maxLength} characters` } + } + return { valid: true } +} diff --git a/lib/validations.ts b/lib/validations.ts new file mode 100644 index 0000000..192a9bb --- /dev/null +++ b/lib/validations.ts @@ -0,0 +1,171 @@ +import { z } from 'zod' + +// ─── Auth ──────────────────────────────────────────────────────────────────── + +export const signInSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string().min(1, 'Password is required'), +}) + +export const signUpSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string().min(8, 'Password must be at least 8 characters'), + fullName: z.string().min(1, 'Full name is required').max(100, 'Name is too long'), + action: z.enum(['create', 'join']), + spaceName: z.string().max(100).optional(), + spaceSlug: z.string().regex(/^[a-z0-9]+$/, 'Slug must be lowercase letters and numbers only').max(50).optional(), + city: z.string().max(100).optional(), + inviteCode: z.string().max(20).optional(), +}) + +export const createSpaceSchema = z.object({ + spaceName: z.string().min(1, 'Space name is required').max(100, 'Name is too long'), + spaceSlug: z.string().min(1, 'Slug is required').max(50).regex(/^[a-z0-9]+$/, 'Slug must be lowercase letters and numbers only'), + spaceCity: z.string().max(100).optional(), + displayName: z.string().min(1, 'Display name is required').max(100), +}) + +export const joinSpaceSchema = z.object({ + inviteCode: z.string().min(1, 'Invite code is required').max(20), + displayName: z.string().min(1, 'Display name is required').max(100), +}) + +// ─── Tasks ─────────────────────────────────────────────────────────────────── + +export const createTaskSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200, 'Title is too long'), + description: z.string().max(2000, 'Description is too long').optional(), + type: z.string().max(50).default('task'), + area: z.string().max(100).optional(), + recurrence: z.enum(['none', 'daily', 'weekly', 'monthly']).default('none'), + due_date: z.string().datetime().optional().nullable(), +}) + +export const taskIdSchema = z.string().uuid('Invalid task ID') + +// ─── Projects ──────────────────────────────────────────────────────────────── + +export const createProjectSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + description: z.string().max(2000).optional(), + area: z.string().max(100).optional(), + due_date: z.string().datetime().optional().nullable(), +}) + +export const updateProjectStatusSchema = z.object({ + projectId: z.string().uuid('Invalid project ID'), + status: z.enum(['planning', 'active', 'paused', 'completed', 'cancelled']), +}) + +// ─── Members ───────────────────────────────────────────────────────────────── + +export const addMemberSchema = z.object({ + email: z.string().email('Invalid email address'), + display_name: z.string().min(1, 'Display name is required').max(100), + role: z.enum(['admin', 'board', 'treasurer', 'member']).default('member'), + tier: z.enum(['plus', 'basic', 'associate']).default('basic'), +}) + +export const updateMemberSchema = z.object({ + memberId: z.string().uuid('Invalid member ID'), + display_name: z.string().min(1).max(100).optional(), + email: z.string().email().optional(), + phone: z.string().max(20).optional().nullable(), + handle: z.string().max(50).optional().nullable(), + role: z.enum(['admin', 'board', 'treasurer', 'member']).optional(), + tier: z.enum(['plus', 'basic', 'associate']).optional(), + status: z.enum(['current', 'unverified', 'late', 'inactive']).optional(), + has_card_access: z.boolean().optional(), +}) + +// ─── Contacts ──────────────────────────────────────────────────────────────── + +export const createContactSchema = z.object({ + name: z.string().min(1, 'Name is required').max(200), + contact_type: z.enum(['vendor', 'sponsor', 'partner', 'city', 'media', 'other']), + email: z.string().email().optional().nullable(), + phone: z.string().max(20).optional().nullable(), + details: z.string().max(500).optional().nullable(), + note: z.string().max(2000).optional().nullable(), + group_label: z.string().max(100).optional().nullable(), + tags: z.array(z.string().max(50)).max(10).optional(), + code: z.string().max(50).optional().nullable(), +}) + +export const updateContactSchema = createContactSchema.partial().extend({ + contactId: z.string().uuid('Invalid contact ID'), +}) + +// ─── Knowledge Base ────────────────────────────────────────────────────────── + +export const createKbEntrySchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + content: z.string().min(1, 'Content is required').max(50000), + area: z.string().max(100).optional().nullable(), + visibility: z.enum(['public', 'members', 'board', 'admin']).default('members'), + is_pinned: z.boolean().default(false), + tags: z.array(z.string().max(50)).max(20).optional(), + icon: z.string().max(10).optional().nullable(), +}) + +export const updateKbEntrySchema = createKbEntrySchema.partial().extend({ + entryId: z.string().uuid('Invalid entry ID'), +}) + +// ─── Secrets ───────────────────────────────────────────────────────────────── + +export const createSecretSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200), + value: z.string().min(1, 'Value is required').max(5000), + description: z.string().max(500).optional().nullable(), + area: z.string().max(100).optional().nullable(), + icon: z.string().max(10).optional().nullable(), +}) + +// ─── Payments ──────────────────────────────────────────────────────────────── + +export const logCashPaymentSchema = z.object({ + amount: z.number().positive('Amount must be positive'), + from_identifier: z.string().min(1, 'Payer name is required').max(200), + from_note: z.string().max(500).optional().nullable(), + transaction_date: z.string().datetime().optional(), +}) + +export const linkPaymentSchema = z.object({ + paymentId: z.string().uuid('Invalid payment ID'), + memberId: z.string().uuid('Invalid member ID'), +}) + +// ─── Settings ──────────────────────────────────────────────────────────────── + +export const updateSpaceSettingsSchema = z.object({ + name: z.string().min(1).max(100).optional(), + city: z.string().max(100).optional().nullable(), + require_approval: z.boolean().optional(), + public_member_directory: z.boolean().optional(), +}) + +export const saveIntegrationSchema = z.object({ + platform: z.string().min(1).max(50), + config: z.record(z.string().max(500)), +}) + +// ─── Area Leads ────────────────────────────────────────────────────────────── + +export const upsertAreaLeadSchema = z.object({ + id: z.string().uuid().optional(), + area_name: z.string().min(1, 'Area name is required').max(100), + lead_handle: z.string().min(1, 'Lead handle is required').max(100), + description: z.string().max(500).optional().nullable(), +}) + +// ─── Generic ID schemas ────────────────────────────────────────────────────── + +export const uuidSchema = z.string().uuid('Invalid ID format') + +// Helper type exports +export type CreateTaskInput = z.infer +export type CreateProjectInput = z.infer +export type CreateContactInput = z.infer +export type CreateKbEntryInput = z.infer +export type CreateSecretInput = z.infer