diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..3e05475 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,70 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +env: + NODE_VERSION: '20' + PYTHON_VERSION: '3.11' + +jobs: + # Run all tests + test: + name: Run Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Frontend tests + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: 'apps/web/package-lock.json' + + - name: Install frontend dependencies + working-directory: ./apps/web + run: npm ci + + - name: Run frontend tests + working-directory: ./apps/web + run: npm test -- --run + + # Backend tests + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install backend dependencies + working-directory: ./backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Run backend tests + working-directory: ./backend + run: pytest + + # Deployment Protection - Block deployment if tests fail + # Note: CodeQL analysis runs separately and will block merging if security issues are found + deployment-protection: + name: Deployment Protection + runs-on: ubuntu-latest + needs: test + if: failure() + + steps: + - name: Block deployment on test failure + run: | + echo "❌ Tests failed - deployment should be blocked" + echo "Please fix failing tests before deploying" + echo "CodeQL analysis runs separately and will also block merging if security issues are found" + exit 1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 865a1d4..5b869fd 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,12 @@ docs/ .cursorignore sprint-1-assignee-breakdown.txt -get-issues.ps1 \ No newline at end of file +get-issues.ps1 + +# Documentation +docs/ +# Task Completion Reports +TASK-*.md +PBI-59-ACCOUNT-SETTINGS-BREAKDOWN.md +# Supabase Setup Guides +SUPABASE_*.md diff --git a/apps/web/TESTING.md b/apps/web/TESTING.md index 729ef13..2206ca0 100644 --- a/apps/web/TESTING.md +++ b/apps/web/TESTING.md @@ -1,135 +1,139 @@ -# Testing Setup +# Frontend Testing Guide -This project uses a comprehensive testing framework with both unit tests and end-to-end tests. +This document explains how to run and write tests for the Vybe frontend application. -## Testing Stack +## Test Structure -- **Vitest**: Fast unit testing framework with React Testing Library -- **Playwright**: End-to-end testing for browser automation -- **React Testing Library**: Component testing utilities -- **Jest DOM**: Custom matchers for DOM testing +The frontend uses two testing frameworks: +- **Vitest** - Unit and component tests +- **Playwright** - End-to-end (E2E) tests ## Running Tests -### Unit Tests - +### Install Dependencies ```bash -# Run all unit tests -npm test - -# Run tests in watch mode -npm run test - -# Run tests once (CI mode) -npm run test:run +npm install +``` -# Open Vitest UI -npm run test:ui +### All Tests +```bash +npm run test:all ``` -### End-to-End Tests +### Unit Tests Only +```bash +npm test -- --run +``` +### E2E Tests Only ```bash -# Run Playwright tests npm run test:e2e - -# Run Playwright tests in headed mode -npm run test:e2e:headed - -# Run Playwright tests in debug mode -npm run test:e2e:debug ``` -### All Tests - +### Interactive Test UI ```bash -# Run both unit and E2E tests -npm run test:all +npm run test:ui ``` -## Test Structure +### E2E Tests with Browser +```bash +npm run test:e2e:headed +``` -```text -apps/web/ -├── components/ -│ └── __tests__/ # Component unit tests -│ ├── Navbar.test.jsx -│ └── LibraryView.test.jsx -├── test/ -│ ├── setup.ts # Test setup configuration -│ ├── helpers.js # Test helper utilities -│ └── utils.test.js # Utility function tests -├── tests/ -│ └── e2e/ # End-to-end tests -│ └── app.spec.ts -├── vitest.config.ts # Vitest configuration -└── playwright.config.ts # Playwright configuration +### Debug E2E Tests +```bash +npm run test:e2e:debug ``` -## Writing Tests +## Test Files -### Unit Test Guidelines +### Unit Tests +- `components/__tests__/` - Component tests +- `test/` - Utility and helper tests -- Use `describe` and `it` blocks for test organization -- Import testing utilities from `@testing-library/react` -- Mock external dependencies using `vi.mock()` -- Test component rendering, user interactions, and state changes +### E2E Tests +- `tests/e2e/` - End-to-end test scenarios -### End-to-End Test Guidelines +## Writing Tests -- Use Playwright's `test` and `expect` functions -- Test complete user workflows -- Verify page navigation and interactions -- Test responsive design across different viewports +### Component Tests +```javascript +import { render, screen } from '@testing-library/react' +import { expect, test } from 'vitest' +import MyComponent from '../MyComponent' -## Configuration +test('renders component', () => { + render() + expect(screen.getByText('Hello')).toBeInTheDocument() +}) +``` -### Vitest Configuration +### E2E Tests +```javascript +import { test, expect } from '@playwright/test' -- Configured with React plugin and jsdom environment -- Includes path aliases for `@/` imports -- Setup file includes Jest DOM matchers and act warning suppression -- Enhanced ESM compatibility with esbuild configuration -- CSS processing enabled for component testing +test('user can sign in', async ({ page }) => { + await page.goto('/sign-in') + await page.fill('[data-testid="email"]', 'test@example.com') + await page.click('[data-testid="sign-in-button"]') + await expect(page).toHaveURL('/dashboard') +}) +``` -### Playwright Configuration +## Test Configuration -- Configured for Chrome, Firefox, and Safari -- Automatically starts dev server before tests with 2-minute timeout -- Includes trace collection for debugging -- Robust error handling with stdout/stderr piping +### Vitest +- Configuration: `vitest.config.ts` +- Test utilities: `test/test-utils.jsx` +- Setup file: `test/setup.ts` -## Demo Tests Included +### Playwright +- Configuration: `playwright.config.ts` +- Test helpers: `test/helpers.js` -1. **Navbar Component**: Tests brand rendering, navigation links, and active states -2. **LibraryView Component**: Tests Spotify integration, tab switching, and data loading -3. **Utility Functions**: Tests time formatting helper functions -4. **E2E App Flow**: Tests homepage, navigation, and responsive design +## Best Practices -## Test Results & Performance +### Component Testing +- Use `data-testid` attributes for reliable element selection +- Test user interactions, not implementation details +- Mock external dependencies +- Test accessibility with `jest-axe` -- **Unit Tests**: 20 tests passing (comprehensive component and utility coverage) -- **E2E Tests**: 4 comprehensive tests covering navigation and responsive design -- **Performance**: Tests run efficiently with proper mocking and act warning suppression -- **Maintainability**: Centralized test helpers and parameterized test cases +### E2E Testing +- Test critical user journeys +- Use page object pattern for complex flows +- Keep tests independent and isolated +- Use meaningful test descriptions -## Best Practices +## Debugging Tests -- Write tests that focus on user behavior rather than implementation details -- Use meaningful test descriptions -- Mock external API calls and dependencies -- Test both happy paths and error scenarios -- Keep tests isolated and independent -- Use data-testid attributes for reliable element selection when needed -- Leverage test helpers for consistent mock data and selectors -- Use parameterized tests for comprehensive coverage of utility functions - -## Recent Improvements - -- ✅ Fixed all markdown linting issues (15 → 0) -- ✅ Enhanced test configuration with better ESM compatibility -- ✅ Added act warning suppression for cleaner test output -- ✅ Improved E2E tests with proper wait states and network idle checks -- ✅ Created centralized test helpers for better maintainability -- ✅ Added comprehensive test scripts for different scenarios -- ✅ Enhanced utility tests with parameterized test cases +### Unit Tests +- Use `console.log()` for debugging +- Run specific tests with `test.only()` +- Use `test.skip()` to temporarily skip tests + +### E2E Tests +- Use `npm run test:e2e:debug` for step-by-step debugging +- Take screenshots with `await page.screenshot()` +- Use `await page.pause()` to pause execution + +## CI/CD Integration + +Tests run automatically in GitHub Actions: +- Unit tests run on every push and PR +- E2E tests run on every push and PR +- All tests must pass before deployment + +## Troubleshooting + +### Common Issues +- **Tests failing**: Check console output for error messages +- **E2E timeouts**: Increase timeout in `playwright.config.ts` +- **Component not found**: Verify `data-testid` attributes +- **Async issues**: Use `await` for async operations + +### Getting Help +- Check test output in terminal +- Review test configuration files +- Look at existing test examples +- Check GitHub Actions logs for CI failures \ No newline at end of file diff --git a/apps/web/app/api/admin/account-deletion-job/route.js b/apps/web/app/api/admin/account-deletion-job/route.js new file mode 100644 index 0000000..0e58852 --- /dev/null +++ b/apps/web/app/api/admin/account-deletion-job/route.js @@ -0,0 +1,166 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@supabase/supabase-js'; +import { deleteAccount } from '@/lib/services/accountDeletion'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/admin/account-deletion-job + * + * Scheduled job endpoint for processing pending account deletions. + * This endpoint should be called by a cron job or scheduled task. + * + * Security: Should be protected by API key or secret token in production. + * + * Request headers: + * - X-API-Key: (optional) API key for authentication + * + * Returns: + * - 200: Job completed successfully + * - 401: Unauthorized (if API key check fails) + * - 500: Server error + */ +export async function POST(request) { + try { + // TODO: Add API key authentication in production + // const apiKey = request.headers.get('X-API-Key'); + // if (apiKey !== process.env.ACCOUNT_DELETION_JOB_API_KEY) { + // return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + // } + + // Create Supabase client with service role (for admin operations) + // In production, use service role key for admin operations + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !supabaseServiceKey) { + return NextResponse.json( + { error: 'Missing Supabase configuration' }, + { status: 500 } + ); + } + + const supabase = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + + // Query accounts marked for deletion past grace period + // Note: Current implementation deletes immediately, so this would need + // a "pending_deletion" or "marked_for_deletion_at" field in the database + // For now, we'll document this as a placeholder for when grace period is implemented + + const results = { + processed: 0, + deleted: 0, + failed: 0, + errors: [], + }; + + try { + // TODO: Query accounts with pending_deletion_at < (now - grace_period) + // Example query (when grace period is implemented): + // const { data: pendingDeletions, error } = await supabase + // .from('users') + // .select('*') + // .not('pending_deletion_at', 'is', null) + // .lt('pending_deletion_at', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()) + // .limit(100); // Process in batches + + // For now, return message about grace period requirement + // In production with grace period, would process each account: + + /* + for (const userRecord of pendingDeletions || []) { + try { + // Get full user object from auth + const { data: { user }, error: userError } = await supabase.auth.admin.getUserById(userRecord.id); + + if (userError || !user) { + results.failed++; + results.errors.push({ + user_id: userRecord.id, + error: 'User not found in auth', + }); + continue; + } + + // Send final confirmation email before deletion + // TODO: Implement email sending + // await sendDeletionConfirmationEmail(user.email); + + // Execute hard delete + const deletionResult = await deleteAccount(supabase, user, { + reason: 'Scheduled deletion after grace period', + }); + + if (deletionResult.success) { + // Delete from auth.users using admin API + await supabase.auth.admin.deleteUser(user.id); + + results.deleted++; + } else { + results.failed++; + results.errors.push({ + user_id: user.id, + error: deletionResult.error || 'Unknown error', + }); + } + + results.processed++; + } catch (error) { + results.failed++; + results.errors.push({ + user_id: userRecord?.id || 'unknown', + error: error.message, + }); + console.error('[deletion job] Error processing user:', error); + } + } + */ + + return NextResponse.json({ + success: true, + message: 'Account deletion job completed', + note: 'Grace period feature not yet implemented. This job is ready for when soft delete is added.', + results: { + processed: results.processed, + deleted: results.deleted, + failed: results.failed, + errors: results.errors, + }, + }); + } catch (error) { + console.error('[deletion job] Error:', error); + return NextResponse.json( + { + success: false, + error: 'Failed to process deletion job', + message: error.message, + }, + { status: 500 } + ); + } + } catch (error) { + console.error('[deletion job] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * GET /api/admin/account-deletion-job + * Health check endpoint for the deletion job + */ +export async function GET() { + return NextResponse.json({ + status: 'ok', + service: 'account-deletion-job', + note: 'POST to this endpoint to run the deletion job', + }); +} + diff --git a/apps/web/app/api/user/account/delete/route.js b/apps/web/app/api/user/account/delete/route.js new file mode 100644 index 0000000..f415bd4 --- /dev/null +++ b/apps/web/app/api/user/account/delete/route.js @@ -0,0 +1,162 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { + validateDeletionRequest, + verifyPassword, + deleteAccount, + checkAccountAge, +} from '@/lib/services/accountDeletion'; +import { + sanitizeRequestBody, + createErrorResponse, + checkRateLimit, +} from '@/lib/validation/serverValidation'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * POST /api/user/account/delete + * Permanently delete user account and all associated data + * + * Request body: + * { + * password: string (required for email-based auth) + * confirmation_phrase: string (should be "DELETE MY ACCOUNT") + * reason?: string (optional feedback) + * } + * + * Returns: + * - 200: Account deletion successful + * - 400: Validation error (password incorrect, confirmation phrase incorrect, account too new) + * - 401: Unauthorized + * - 403: Account too new (less than 24 hours old) + * - 500: Server error + */ +export async function POST(request) { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Rate limiting (strict for account deletion) + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 5, // 5 deletion attempts per hour + windowMs: 60 * 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Too many deletion requests. Please try again in ${Math.ceil(resetSeconds / 60)} minutes.`, + retryAfter: resetSeconds, + } + ), + { + status: 429, + headers: { + 'Retry-After': String(resetSeconds), + 'X-RateLimit-Limit': '5', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Parse and sanitize request body + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json( + createErrorResponse('Invalid JSON in request body', 400), + { status: 400 } + ); + } + + // Sanitize input (preserve password field as it's needed for verification) + const sanitizedBody = sanitizeRequestBody(body, { + deep: true, + preserveUrls: false, + }); + + // Validate deletion request + const validation = validateDeletionRequest(user, sanitizedBody); + if (!validation.valid) { + const status = validation.hoursRemaining !== undefined ? 403 : 400; + return NextResponse.json( + { + error: validation.error, + message: validation.message, + hoursRemaining: validation.hoursRemaining, + }, + { status } + ); + } + + // Verify password (for email-based authentication) + const authProvider = user.app_metadata?.provider || 'email'; + + if (authProvider === 'email') { + const passwordValid = await verifyPassword(supabase, user, validation.data.password); + if (!passwordValid) { + return NextResponse.json( + { error: 'Invalid password' }, + { status: 400 } + ); + } + } else { + // For OAuth providers (Spotify, Google), password verification is not applicable + // The user is already authenticated via OAuth + console.log('[account deletion] OAuth user deletion:', authProvider); + } + + // Perform account deletion using service layer + const deletionResult = await deleteAccount(supabase, user, { + reason: validation.data.reason, + }); + + if (!deletionResult.success) { + return NextResponse.json( + { + error: 'Failed to delete account', + message: deletionResult.error || 'An error occurred during account deletion', + }, + { status: 500 } + ); + } + + // Return success response + return NextResponse.json({ + success: true, + message: 'Account deletion initiated successfully', + note: 'Your account and all associated data will be permanently deleted. You have been signed out.', + deleted: deletionResult.deleted, + }); + } catch (error) { + console.error('[account deletion] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error', message: 'Failed to delete account' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/api/user/export/route.js b/apps/web/app/api/user/export/route.js new file mode 100644 index 0000000..a48a47e --- /dev/null +++ b/apps/web/app/api/user/export/route.js @@ -0,0 +1,281 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { + createErrorResponse, + checkRateLimit, +} from '@/lib/validation/serverValidation'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * GET /api/user/export + * Generate user data export (GDPR data portability) + * + * Returns: + * - 200: JSON file download with all user data + * - 401: Unauthorized + * - 429: Too many requests (rate limited - 1 export per 24 hours) + * - 500: Server error + */ +export async function GET(request) { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const userId = user.id; + + // Rate limiting: 1 export per 24 hours + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 1, // 1 export per 24 hours + windowMs: 24 * 60 * 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetHours = Math.ceil((rateLimit.resetAt - Date.now()) / (60 * 60 * 1000)); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Data export is limited to once per 24 hours. Please try again in ${resetHours} hours.`, + retryAfter: Math.ceil((rateLimit.resetAt - Date.now()) / 1000), + } + ), + { + status: 429, + headers: { + 'Retry-After': String(Math.ceil((rateLimit.resetAt - Date.now()) / 1000)), + 'X-RateLimit-Limit': '1', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Collect all user data + const exportData = { + export_metadata: { + exported_at: new Date().toISOString(), + user_id: userId, + format_version: '1.0', + }, + profile: {}, + preferences: {}, + listening_history: [], + playlists: [], + social_connections: [], + settings: {}, + }; + + try { + // 1. Profile Information + const { data: profile } = await supabase + .from('users') + .select('*') + .eq('id', userId) + .single(); + + if (profile) { + exportData.profile = { + id: profile.id, + display_name: profile.display_name, + bio: profile.bio, + username: profile.username, + profile_picture_url: profile.profile_picture_url, + created_at: profile.created_at, + updated_at: profile.updated_at, + }; + } + + // Add auth information + exportData.profile.auth = { + email: user.email, + email_verified: user.email_confirmed_at ? true : false, + provider: user.app_metadata?.provider || 'email', + created_at: user.created_at, + last_sign_in: user.last_sign_in_at, + }; + + // 2. Privacy Settings + const { data: privacySettings } = await supabase + .from('user_privacy_settings') + .select('*') + .eq('user_id', userId) + .single(); + + if (privacySettings) { + exportData.preferences.privacy = { + profile_visibility: privacySettings.profile_visibility, + playlist_visibility: privacySettings.playlist_visibility, + listening_activity_visible: privacySettings.listening_activity_visible, + song_of_day_visibility: privacySettings.song_of_day_visibility, + friend_request_setting: privacySettings.friend_request_setting, + searchable: privacySettings.searchable, + activity_feed_visible: privacySettings.activity_feed_visible, + created_at: privacySettings.created_at, + updated_at: privacySettings.updated_at, + }; + } + + // 3. Notification Preferences + const { data: notificationPreferences } = await supabase + .from('user_notification_preferences') + .select('*') + .eq('user_id', userId) + .single(); + + if (notificationPreferences) { + exportData.preferences.notifications = { + friend_requests_inapp: notificationPreferences.friend_requests_inapp, + friend_requests_email: notificationPreferences.friend_requests_email, + new_followers_inapp: notificationPreferences.new_followers_inapp, + new_followers_email: notificationPreferences.new_followers_email, + comments_inapp: notificationPreferences.comments_inapp, + comments_email: notificationPreferences.comments_email, + playlist_invites_inapp: notificationPreferences.playlist_invites_inapp, + playlist_invites_email: notificationPreferences.playlist_invites_email, + playlist_updates_inapp: notificationPreferences.playlist_updates_inapp, + playlist_updates_email: notificationPreferences.playlist_updates_email, + song_of_day_inapp: notificationPreferences.song_of_day_inapp, + song_of_day_email: notificationPreferences.song_of_day_email, + system_announcements_inapp: notificationPreferences.system_announcements_inapp, + system_announcements_email: notificationPreferences.system_announcements_email, + security_alerts_inapp: notificationPreferences.security_alerts_inapp, + security_alerts_email: notificationPreferences.security_alerts_email, + email_frequency: notificationPreferences.email_frequency, + notifications_enabled: notificationPreferences.notifications_enabled, + created_at: notificationPreferences.created_at, + updated_at: notificationPreferences.updated_at, + }; + } + + // 4. Listening History + // Fetch all listening history (may be large, but we want complete export) + const { data: history } = await supabase + .from('play_history') + .select('*') + .eq('user_id', userId) + .order('played_at', { ascending: false }); + + if (history) { + exportData.listening_history = history.map(item => ({ + track_id: item.track_id, + track_name: item.track_name, + artist_name: item.artist_name, + album_name: item.album_name, + played_at: item.played_at, + duration_ms: item.duration_ms, + spotify_uri: item.spotify_uri, + })); + } + + // 5. Playlists (if there's a playlists table) + // Note: Adjust table name and structure based on your schema + try { + const { data: playlists } = await supabase + .from('playlists') + .select('*') + .eq('user_id', userId) + .or('owner_id.eq.' + userId); + + if (playlists) { + exportData.playlists = playlists.map(playlist => ({ + id: playlist.id, + name: playlist.name, + description: playlist.description, + is_public: playlist.is_public, + created_at: playlist.created_at, + updated_at: playlist.updated_at, + // Note: Song list would need separate query if stored in separate table + })); + } + } catch (playlistError) { + // Table might not exist - that's okay + console.log('[data export] Playlists table not available:', playlistError.message); + } + + // 6. Social Connections (if there's a connections/friends table) + try { + const { data: connections } = await supabase + .from('friendships') + .select('*') + .or(`user_id.eq.${userId},friend_id.eq.${userId}`); + + if (connections) { + exportData.social_connections = connections.map(conn => ({ + friend_id: conn.friend_id === userId ? conn.user_id : conn.friend_id, + status: conn.status, + created_at: conn.created_at, + })); + } + } catch (connectionError) { + // Table might not exist - that's okay + console.log('[data export] Connections table not available:', connectionError.message); + } + + // 7. OAuth Connection Status + const { data: spotifyToken } = await supabase + .from('spotify_tokens') + .select('user_id, expires_at') + .eq('user_id', userId) + .single(); + + const { data: youtubeToken } = await supabase + .from('youtube_tokens') + .select('user_id, expires_at') + .eq('user_id', userId) + .single(); + + exportData.settings.oauth_connections = { + spotify_connected: !!spotifyToken, + spotify_token_expires_at: spotifyToken?.expires_at || null, + youtube_connected: !!youtubeToken, + youtube_token_expires_at: youtubeToken?.expires_at || null, + }; + + } catch (dataError) { + console.error('[data export] Error collecting data:', dataError); + // Continue with partial data + } + + // Generate filename with timestamp + const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const filename = `vybe-data-export-${timestamp}.json`; + + // Convert to JSON string with pretty formatting + const jsonString = JSON.stringify(exportData, null, 2); + + // Return as downloadable file + return new NextResponse(jsonString, { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store, no-cache, must-revalidate', + }, + }); + } catch (error) { + console.error('[data export] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error', message: 'Failed to generate data export' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/api/user/notifications/route.js b/apps/web/app/api/user/notifications/route.js new file mode 100644 index 0000000..4547107 --- /dev/null +++ b/apps/web/app/api/user/notifications/route.js @@ -0,0 +1,324 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { notificationSchema, notificationPartialSchema, getDefaultNotificationPreferences } from '@/lib/schemas/notificationSchema'; +import { + validateRequest, + formatValidationErrors, + createErrorResponse, + logValidationFailure, + checkRateLimit, +} from '@/lib/validation/serverValidation'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * GET /api/user/notifications + * Fetch current user's notification preferences + * + * Returns: + * - 200: Notification preferences object + * - 401: Unauthorized + * - 500: Server error + */ +export async function GET() { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Fetch notification preferences from user_notification_preferences table + const { data: notificationPreferences, error: notificationError } = await supabase + .from('user_notification_preferences') + .select('*') + .eq('user_id', user.id) + .single(); + + // Handle case where table doesn't exist yet or no record found + if (notificationError) { + // PGRST116 = no rows returned (table exists but no record) + // 42P01 = relation does not exist (table doesn't exist) + if (notificationError.code === 'PGRST116' || notificationError.code === '42P01') { + // Table doesn't exist yet or no record exists - return defaults + console.log('[notifications API] No notification preferences found, returning defaults'); + const defaults = getDefaultNotificationPreferences(); + return NextResponse.json(defaults); + } + + // Other errors - log but still return defaults to allow UI to work + console.error('[notifications API] Error fetching notification preferences:', notificationError); + const defaults = getDefaultNotificationPreferences(); + return NextResponse.json(defaults); + } + + // If no settings exist, return defaults + if (!notificationPreferences) { + const defaults = getDefaultNotificationPreferences(); + return NextResponse.json(defaults); + } + + // Return notification preferences in the expected format + return NextResponse.json({ + // Social Notifications + friend_requests_inapp: notificationPreferences.friend_requests_inapp ?? true, + friend_requests_email: notificationPreferences.friend_requests_email ?? true, + new_followers_inapp: notificationPreferences.new_followers_inapp ?? true, + new_followers_email: notificationPreferences.new_followers_email ?? false, + comments_inapp: notificationPreferences.comments_inapp ?? true, + comments_email: notificationPreferences.comments_email ?? false, + + // Playlist Notifications + playlist_invites_inapp: notificationPreferences.playlist_invites_inapp ?? true, + playlist_invites_email: notificationPreferences.playlist_invites_email ?? true, + playlist_updates_inapp: notificationPreferences.playlist_updates_inapp ?? true, + playlist_updates_email: notificationPreferences.playlist_updates_email ?? false, + + // System Notifications + song_of_day_inapp: notificationPreferences.song_of_day_inapp ?? true, + song_of_day_email: notificationPreferences.song_of_day_email ?? false, + system_announcements_inapp: notificationPreferences.system_announcements_inapp ?? true, + system_announcements_email: notificationPreferences.system_announcements_email ?? true, + security_alerts_inapp: notificationPreferences.security_alerts_inapp ?? true, // Always true + security_alerts_email: notificationPreferences.security_alerts_email ?? true, // Always true + + // Email Frequency + email_frequency: notificationPreferences.email_frequency || 'instant', + + // Master Toggle + notifications_enabled: notificationPreferences.notifications_enabled ?? true, + }); + } catch (error) { + console.error('[notifications API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * PUT /api/user/notifications + * Update user notification preferences + * + * Request body: + * { + * friend_requests_inapp?: boolean + * friend_requests_email?: boolean + * new_followers_inapp?: boolean + * new_followers_email?: boolean + * comments_inapp?: boolean + * comments_email?: boolean + * playlist_invites_inapp?: boolean + * playlist_invites_email?: boolean + * playlist_updates_inapp?: boolean + * playlist_updates_email?: boolean + * song_of_day_inapp?: boolean + * song_of_day_email?: boolean + * system_announcements_inapp?: boolean + * system_announcements_email?: boolean + * security_alerts_inapp?: boolean (always true, enforced) + * security_alerts_email?: boolean (always true, enforced) + * email_frequency?: 'instant' | 'daily' | 'weekly' + * notifications_enabled?: boolean + * } + * + * Returns: + * - 200: Updated notification preferences + * - 400: Validation error + * - 401: Unauthorized + * - 500: Server error + */ +export async function PUT(request) { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Rate limiting + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 10, // 10 updates per minute + windowMs: 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Too many requests. Please try again in ${resetSeconds} seconds.`, + retryAfter: resetSeconds, + } + ), + { + status: 429, + headers: { + 'Retry-After': String(resetSeconds), + 'X-RateLimit-Limit': '10', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Parse and validate request body + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json( + createErrorResponse('Invalid JSON in request body', 400), + { status: 400 } + ); + } + + // Ensure security alerts are always enabled (enforce at API level) + body.security_alerts_inapp = true; + body.security_alerts_email = true; + + // Validate and sanitize input (use partial schema to allow partial updates) + const validationResult = validateRequest(body, notificationPartialSchema, { + endpoint: '/api/user/notifications', + userId: user.id, + sanitize: true, + logErrors: true, + }); + + if (!validationResult.success) { + return NextResponse.json( + validationResult.errors, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + // Check if notification preferences record exists + const { data: existingPreferences } = await supabase + .from('user_notification_preferences') + .select('id') + .eq('user_id', user.id) + .single(); + + // Prepare data for database update + const updateData = { + user_id: user.id, + ...validatedData, + // Ensure security alerts are always true + security_alerts_inapp: true, + security_alerts_email: true, + updated_at: new Date().toISOString(), + }; + + let result; + if (existingPreferences) { + // Update existing record + const { data: updatedPreferences, error: updateError } = await supabase + .from('user_notification_preferences') + .update(updateData) + .eq('user_id', user.id) + .select() + .single(); + + if (updateError) { + console.error('[notifications API] Error updating notification preferences:', updateError); + return NextResponse.json( + { error: 'Failed to update notification preferences' }, + { status: 500 } + ); + } + + result = updatedPreferences; + } else { + // Create new record + const { data: newPreferences, error: insertError } = await supabase + .from('user_notification_preferences') + .insert(updateData) + .select() + .single(); + + if (insertError) { + console.error('[notifications API] Error creating notification preferences:', insertError); + // Check if table doesn't exist + if (insertError.code === '42P01') { + return NextResponse.json( + { + error: 'Notification preferences table not available yet', + message: 'Please contact support or try again later', + }, + { status: 503 } + ); + } + return NextResponse.json( + { error: 'Failed to create notification preferences' }, + { status: 500 } + ); + } + + result = newPreferences; + } + + // Return updated notification preferences in the expected format + return NextResponse.json({ + // Social Notifications + friend_requests_inapp: result.friend_requests_inapp ?? true, + friend_requests_email: result.friend_requests_email ?? true, + new_followers_inapp: result.new_followers_inapp ?? true, + new_followers_email: result.new_followers_email ?? false, + comments_inapp: result.comments_inapp ?? true, + comments_email: result.comments_email ?? false, + + // Playlist Notifications + playlist_invites_inapp: result.playlist_invites_inapp ?? true, + playlist_invites_email: result.playlist_invites_email ?? true, + playlist_updates_inapp: result.playlist_updates_inapp ?? true, + playlist_updates_email: result.playlist_updates_email ?? false, + + // System Notifications + song_of_day_inapp: result.song_of_day_inapp ?? true, + song_of_day_email: result.song_of_day_email ?? false, + system_announcements_inapp: result.system_announcements_inapp ?? true, + system_announcements_email: result.system_announcements_email ?? true, + security_alerts_inapp: true, // Always true + security_alerts_email: true, // Always true + + // Email Frequency + email_frequency: result.email_frequency || 'instant', + + // Master Toggle + notifications_enabled: result.notifications_enabled ?? true, + + // Success message + message: 'Notification preferences updated successfully', + }); + } catch (error) { + console.error('[notifications API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/api/user/privacy/route.js b/apps/web/app/api/user/privacy/route.js new file mode 100644 index 0000000..eb4d0ff --- /dev/null +++ b/apps/web/app/api/user/privacy/route.js @@ -0,0 +1,320 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { privacySchema, privacyPartialSchema, getDefaultPrivacySettings } from '@/lib/schemas/privacySchema'; +import { + validateRequest, + formatValidationErrors, + createErrorResponse, + logValidationFailure, + checkRateLimit, +} from '@/lib/validation/serverValidation'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * GET /api/user/privacy + * Fetch current user's privacy settings + * + * Returns: + * - 200: Privacy settings object + * - 401: Unauthorized + * - 500: Server error + */ +export async function GET() { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Fetch privacy settings from user_privacy_settings table + const { data: privacySettings, error: privacyError } = await supabase + .from('user_privacy_settings') + .select('*') + .eq('user_id', user.id) + .single(); + + // Handle case where table doesn't exist yet or no record found + if (privacyError) { + // PGRST116 = no rows returned (table exists but no record) + // 42P01 = relation does not exist (table doesn't exist) + // P0001 = other errors + if (privacyError.code === 'PGRST116' || privacyError.code === '42P01') { + // Table doesn't exist yet or no record exists - return defaults + console.log('[privacy API] No privacy settings found, returning defaults'); + const defaults = getDefaultPrivacySettings(); + return NextResponse.json(defaults); + } + + // Other errors - log but still return defaults to allow UI to work + console.error('[privacy API] Error fetching privacy settings:', privacyError); + const defaults = getDefaultPrivacySettings(); + return NextResponse.json(defaults); + } + + // If no settings exist, return defaults + if (!privacySettings) { + const defaults = getDefaultPrivacySettings(); + return NextResponse.json(defaults); + } + + // Return privacy settings in the expected format + return NextResponse.json({ + profile_visibility: privacySettings.profile_visibility || 'public', + playlist_visibility: privacySettings.playlist_visibility || 'public', + listening_activity_visible: privacySettings.listening_activity_visible ?? true, + song_of_day_visibility: privacySettings.song_of_day_visibility || 'public', + friend_request_setting: privacySettings.friend_request_setting || 'everyone', + searchable: privacySettings.searchable ?? true, + activity_feed_visible: privacySettings.activity_feed_visible ?? true, + }); + } catch (error) { + console.error('[privacy API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * PUT /api/user/privacy + * Update user privacy settings + * + * Request body: + * { + * profile_visibility?: 'public' | 'friends' | 'private' + * playlist_visibility?: 'public' | 'friends' | 'private' + * listening_activity_visible?: boolean + * song_of_day_visibility?: 'public' | 'friends' | 'private' + * friend_request_setting?: 'everyone' | 'friends_of_friends' | 'nobody' + * searchable?: boolean + * activity_feed_visible?: boolean + * } + * + * Returns: + * - 200: Updated privacy settings + * - 400: Validation error + * - 401: Unauthorized + * - 500: Server error + */ +export async function PUT(request) { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Rate limiting + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 10, // 10 updates per minute + windowMs: 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Too many requests. Please try again in ${resetSeconds} seconds.`, + retryAfter: resetSeconds, + } + ), + { + status: 429, + headers: { + 'Retry-After': String(resetSeconds), + 'X-RateLimit-Limit': '10', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Parse and validate request body + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json( + createErrorResponse('Invalid JSON in request body', 400), + { status: 400 } + ); + } + + // Validate and sanitize input (use partial schema to allow partial updates) + const validationResult = validateRequest(body, privacyPartialSchema, { + endpoint: '/api/user/privacy', + userId: user.id, + sanitize: true, + logErrors: true, + }); + + if (!validationResult.success) { + return NextResponse.json( + validationResult.errors, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + // Check if privacy settings record exists + const { data: existingSettings } = await supabase + .from('user_privacy_settings') + .select('*') + .eq('user_id', user.id) + .single(); + + // Prepare update data + const updateData = {}; + + if (validatedData.profile_visibility !== undefined) { + updateData.profile_visibility = validatedData.profile_visibility; + } + if (validatedData.playlist_visibility !== undefined) { + updateData.playlist_visibility = validatedData.playlist_visibility; + } + if (validatedData.listening_activity_visible !== undefined) { + updateData.listening_activity_visible = validatedData.listening_activity_visible; + } + if (validatedData.song_of_day_visibility !== undefined) { + updateData.song_of_day_visibility = validatedData.song_of_day_visibility; + } + if (validatedData.friend_request_setting !== undefined) { + updateData.friend_request_setting = validatedData.friend_request_setting; + } + if (validatedData.searchable !== undefined) { + updateData.searchable = validatedData.searchable; + } + if (validatedData.activity_feed_visible !== undefined) { + updateData.activity_feed_visible = validatedData.activity_feed_visible; + } + + // Update timestamp + updateData.updated_at = new Date().toISOString(); + + let result; + if (existingSettings) { + // Update existing record + const { data: updatedSettings, error: updateError } = await supabase + .from('user_privacy_settings') + .update(updateData) + .eq('user_id', user.id) + .select() + .single(); + + if (updateError) { + console.error('[privacy API] Error updating privacy settings:', updateError); + return NextResponse.json( + { error: 'Failed to update privacy settings' }, + { status: 500 } + ); + } + + result = updatedSettings; + } else { + // Create new record with defaults merged with updates + const defaults = getDefaultPrivacySettings(); + const newSettings = { + user_id: user.id, + ...defaults, + ...updateData, + created_at: new Date().toISOString(), + }; + + const { data: createdSettings, error: createError } = await supabase + .from('user_privacy_settings') + .insert(newSettings) + .select() + .single(); + + if (createError) { + console.error('[privacy API] Error creating privacy settings:', createError); + return NextResponse.json( + { error: 'Failed to create privacy settings' }, + { status: 500 } + ); + } + + result = createdSettings; + } + + // Audit logging: Log privacy changes + try { + // Create audit log entry + const auditLog = { + user_id: user.id, + action: 'privacy_settings_updated', + details: { + changed_fields: Object.keys(updateData).filter(key => key !== 'updated_at'), + previous_values: existingSettings ? { + profile_visibility: existingSettings.profile_visibility, + playlist_visibility: existingSettings.playlist_visibility, + listening_activity_visible: existingSettings.listening_activity_visible, + song_of_day_visibility: existingSettings.song_of_day_visibility, + friend_request_setting: existingSettings.friend_request_setting, + searchable: existingSettings.searchable, + activity_feed_visible: existingSettings.activity_feed_visible, + } : null, + new_values: updateData, + }, + created_at: new Date().toISOString(), + }; + + // Try to insert audit log (if table exists) + // Note: This will fail silently if audit table doesn't exist yet + await supabase + .from('privacy_settings_audit_log') + .insert(auditLog) + .catch((error) => { + // Log but don't fail the request if audit logging fails + console.log('[privacy API] Audit logging not available:', error.message); + }); + } catch (auditError) { + // Don't fail the request if audit logging fails + console.log('[privacy API] Could not log privacy changes:', auditError.message); + } + + // Return updated privacy settings in the expected format + return NextResponse.json({ + profile_visibility: result.profile_visibility || 'public', + playlist_visibility: result.playlist_visibility || 'public', + listening_activity_visible: result.listening_activity_visible ?? true, + song_of_day_visibility: result.song_of_day_visibility || 'public', + friend_request_setting: result.friend_request_setting || 'everyone', + searchable: result.searchable ?? true, + activity_feed_visible: result.activity_feed_visible ?? true, + message: 'Privacy settings updated successfully', + }); + } catch (error) { + console.error('[privacy API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/api/user/profile/picture/route.js b/apps/web/app/api/user/profile/picture/route.js new file mode 100644 index 0000000..4f6ffed --- /dev/null +++ b/apps/web/app/api/user/profile/picture/route.js @@ -0,0 +1,265 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { + createErrorResponse, + checkRateLimit, + sanitizeString, +} from '@/lib/validation/serverValidation'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * POST /api/user/profile/picture + * Upload profile picture to Supabase Storage + * + * TODO FOR SUPABASE DEVELOPER: + * 1. Ensure the 'profile-pictures' bucket exists in Supabase Storage + * 2. Configure bucket policies to allow authenticated users to upload/read their own files + * 3. Files should be stored with path: {user_id}/profile-picture.{ext} + */ +export async function POST(request) { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Rate limiting + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 20, // 20 uploads per minute + windowMs: 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Too many upload requests. Please try again in ${resetSeconds} seconds.`, + retryAfter: resetSeconds, + } + ), + { + status: 429, + headers: { + 'Retry-After': String(resetSeconds), + 'X-RateLimit-Limit': '20', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Get file from form data + const formData = await request.formData(); + const file = formData.get('file'); + + if (!file || !(file instanceof File)) { + return NextResponse.json( + createErrorResponse('No file provided', 400), + { status: 400 } + ); + } + + // Validate file type + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; + if (!allowedTypes.includes(file.type)) { + return NextResponse.json( + createErrorResponse('Invalid file type. Only JPEG, PNG, and WebP are allowed', 400), + { status: 400 } + ); + } + + // Validate file size (5MB max) + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + return NextResponse.json( + createErrorResponse('File size exceeds 5MB limit', 400), + { status: 400 } + ); + } + + // Sanitize file name + const sanitizedName = sanitizeString(file.name); + + // Generate file path: {user_id}/profile-picture.{ext} + const fileExt = sanitizedName.split('.').pop() || 'jpg'; + // Ensure file extension is safe (only allow alphanumeric) + const safeExt = fileExt.replace(/[^a-zA-Z0-9]/g, ''); + const fileName = `${user.id}/profile-picture.${safeExt}`; + const filePath = `profile-pictures/${fileName}`; + + // Convert File to ArrayBuffer for Supabase Storage + const arrayBuffer = await file.arrayBuffer(); + const fileBuffer = Buffer.from(arrayBuffer); + + // Upload to Supabase Storage + // TODO FOR SUPABASE DEVELOPER: Ensure storage bucket 'profile-pictures' exists + const { data: uploadData, error: uploadError } = await supabase.storage + .from('profile-pictures') + .upload(fileName, fileBuffer, { + contentType: file.type, + upsert: true, // Replace existing file if it exists + }); + + if (uploadError) { + console.error('[profile picture API] Upload error:', uploadError); + return NextResponse.json( + { error: 'Failed to upload image. Please check Supabase Storage configuration.' }, + { status: 500 } + ); + } + + // Get public URL for the uploaded image + const { data: urlData } = supabase.storage + .from('profile-pictures') + .getPublicUrl(fileName); + + const publicUrl = urlData.publicUrl; + + // Update users table with profile picture URL + const { error: updateError } = await supabase + .from('users') + .update({ profile_picture_url: publicUrl }) + .eq('id', user.id); + + if (updateError) { + console.error('[profile picture API] Update error:', updateError); + // Even if update fails, return the URL - it can be updated later + return NextResponse.json({ + url: publicUrl, + message: 'Image uploaded but failed to update profile. URL returned.', + warning: true, + }); + } + + return NextResponse.json({ + url: publicUrl, + message: 'Profile picture uploaded successfully', + }); + } catch (error) { + console.error('[profile picture API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/user/profile/picture + * Remove profile picture from Supabase Storage + */ +export async function DELETE() { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + createErrorResponse('Unauthorized', 401), + { status: 401 } + ); + } + + // Rate limiting + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 10, // 10 deletes per minute + windowMs: 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Too many delete requests. Please try again in ${resetSeconds} seconds.`, + retryAfter: resetSeconds, + } + ), + { + status: 429, + headers: { + 'Retry-After': String(resetSeconds), + 'X-RateLimit-Limit': '10', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Get current profile to find existing picture URL + const { data: profile } = await supabase + .from('users') + .select('profile_picture_url') + .eq('id', user.id) + .single(); + + // Try to delete from storage if we have a URL + if (profile?.profile_picture_url) { + // Extract file path from URL + // Supabase Storage URLs are typically: https://{project}.supabase.co/storage/v1/object/public/{bucket}/{path} + const urlParts = profile.profile_picture_url.split('/'); + const fileNameIndex = urlParts.findIndex(part => part === 'profile-pictures') + 1; + + if (fileNameIndex > 0 && fileNameIndex < urlParts.length) { + const fileName = urlParts.slice(fileNameIndex).join('/'); + + const { error: deleteError } = await supabase.storage + .from('profile-pictures') + .remove([fileName]); + + if (deleteError) { + console.error('[profile picture API] Delete from storage error:', deleteError); + // Continue anyway - we'll still update the database + } + } + } + + // Update users table to remove profile picture URL + const { error: updateError } = await supabase + .from('users') + .update({ profile_picture_url: null }) + .eq('id', user.id); + + if (updateError) { + console.error('[profile picture API] Update error:', updateError); + return NextResponse.json( + { error: 'Failed to remove profile picture' }, + { status: 500 } + ); + } + + return NextResponse.json({ + message: 'Profile picture removed successfully', + }); + } catch (error) { + console.error('[profile picture API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/api/user/profile/route.js b/apps/web/app/api/user/profile/route.js new file mode 100644 index 0000000..8a1d3ca --- /dev/null +++ b/apps/web/app/api/user/profile/route.js @@ -0,0 +1,330 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { profileSchema } from '@/lib/schemas/profileSchema'; +import { + validateRequest, + formatValidationErrors, + createErrorResponse, + logValidationFailure, + checkRateLimit, +} from '@/lib/validation/serverValidation'; + +export const dynamic = 'force-dynamic'; + +async function makeSupabase() { + const cookieStore = await cookies(); + return createRouteHandlerClient({ cookies: () => cookieStore }); +} + +/** + * GET /api/user/profile + * Fetch current user's profile information + */ +export async function GET() { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Fetch user profile from users table + const { data: profile, error: profileError } = await supabase + .from('users') + .select('*') + .eq('id', user.id) + .single(); + + if (profileError && profileError.code !== 'PGRST116') { + console.error('[profile API] Error fetching profile:', profileError); + return NextResponse.json( + { error: 'Failed to fetch profile' }, + { status: 500 } + ); + } + + // Get authentication provider + const authProvider = user.app_metadata?.provider || 'email'; + + // Get provider account info from user metadata + const providerAccountName = user.user_metadata?.full_name || user.user_metadata?.name || null; + const providerAccountEmail = user.user_metadata?.email || user.email; + const providerUserId = user.user_metadata?.preferred_username || user.user_metadata?.user_name || null; + + // Check Spotify connection and get account info if available + const { data: spotifyToken } = await supabase + .from('spotify_tokens') + .select('user_id') + .eq('user_id', user.id) + .single(); + + let spotifyAccountInfo = null; + if (spotifyToken) { + // Try to fetch Spotify account info if token is valid + try { + const { getValidAccessToken } = await import('../../../lib/spotify.js'); + const accessToken = await getValidAccessToken(supabase, user.id); + const spotifyRes = await fetch('https://api.spotify.com/v1/me', { + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + if (spotifyRes.ok) { + const spotifyData = await spotifyRes.json(); + spotifyAccountInfo = { + display_name: spotifyData.display_name || null, + id: spotifyData.id || null, + }; + } + } catch (e) { + // Token may be invalid or expired - that's okay, we'll just show connected + console.log('[profile API] Could not fetch Spotify account:', e.message); + } + } + + // Check YouTube connection + const { data: youtubeToken } = await supabase + .from('youtube_tokens') + .select('user_id') + .eq('user_id', user.id) + .single(); + + let youtubeAccountInfo = null; + if (youtubeToken) { + // Try to fetch YouTube account info if token is available + // Note: YouTube API access would require similar token handling + // For now, we'll show connection status without account details + youtubeAccountInfo = { + connected: true, + }; + } + + // Format provider name for display + const formatProviderName = (provider) => { + const names = { + 'spotify': 'Spotify', + 'google': 'Google (YouTube)', + 'email': 'Email', + }; + return names[provider] || provider; + }; + + // Return profile data with connection status + return NextResponse.json({ + id: user.id, + email: user.email, + email_verified: user.email_confirmed_at ? true : false, + display_name: profile?.display_name || null, + bio: profile?.bio || null, + profile_picture_url: profile?.profile_picture_url || user.user_metadata?.avatar_url || null, + username: profile?.username || null, + created_at: user.created_at, + // Authentication provider info + auth_provider: authProvider, + auth_provider_display: formatProviderName(authProvider), + provider_account_name: providerAccountName, + provider_account_email: providerAccountEmail, + provider_user_id: providerUserId, + // Connection status + spotify_connected: !!spotifyToken, + spotify_account: spotifyAccountInfo, + youtube_connected: !!youtubeToken, + youtube_account: youtubeAccountInfo, + }); + } catch (error) { + console.error('[profile API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * PUT /api/user/profile + * Update user profile + * + * Request body: + * { + * display_name: string (required, 2-50 chars, alphanumeric + spaces) + * bio?: string (optional, max 200 chars) + * profile_picture_url?: string (optional, valid URL) + * } + * + * Returns: + * - 200: Updated profile data + * - 400: Validation error + * - 401: Unauthorized + * - 500: Server error + */ +export async function PUT(request) { + try { + const supabase = await makeSupabase(); + + // Get authenticated user + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Rate limiting + const rateLimitKey = user.id || 'anonymous'; + const rateLimit = checkRateLimit(rateLimitKey, { + limit: 10, // 10 updates per minute + windowMs: 60 * 1000, + }); + + if (!rateLimit.allowed) { + const resetSeconds = Math.ceil((rateLimit.resetAt - Date.now()) / 1000); + return NextResponse.json( + createErrorResponse( + 'Rate limit exceeded', + 429, + { + message: `Too many requests. Please try again in ${resetSeconds} seconds.`, + retryAfter: resetSeconds, + } + ), + { + status: 429, + headers: { + 'Retry-After': String(resetSeconds), + 'X-RateLimit-Limit': '10', + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), + }, + } + ); + } + + // Parse and validate request body + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json( + createErrorResponse('Invalid JSON in request body', 400), + { status: 400 } + ); + } + + // Validate and sanitize input + const validationResult = validateRequest(body, profileSchema, { + endpoint: '/api/user/profile', + userId: user.id, + sanitize: true, + logErrors: true, + }); + + if (!validationResult.success) { + return NextResponse.json( + validationResult.errors, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + + // Prepare update data (only include fields that are provided) + const updateData = {}; + + if (validatedData.display_name !== undefined) { + updateData.display_name = validatedData.display_name; + } + + if (validatedData.bio !== undefined) { + // Convert undefined to null for database (or empty string if preferred) + updateData.bio = validatedData.bio || null; + } + + if (validatedData.profile_picture_url !== undefined) { + // Convert null to null (or empty string if preferred) + updateData.profile_picture_url = validatedData.profile_picture_url || null; + } + + // Update user profile in database + const { data: updatedProfile, error: updateError } = await supabase + .from('users') + .update(updateData) + .eq('id', user.id) + .select() + .single(); + + if (updateError) { + console.error('[profile API] Error updating profile:', updateError); + + // Handle specific database errors + if (updateError.code === '23505') { // Unique constraint violation + return NextResponse.json( + { error: 'A profile with this information already exists' }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Failed to update profile' }, + { status: 500 } + ); + } + + // Fetch updated profile with all fields (including those not updated) + const { data: profile, error: profileError } = await supabase + .from('users') + .select('*') + .eq('id', user.id) + .single(); + + if (profileError) { + console.error('[profile API] Error fetching updated profile:', profileError); + // Even if fetch fails, return what we updated + return NextResponse.json({ + id: user.id, + email: user.email, + display_name: updatedProfile?.display_name || null, + bio: updatedProfile?.bio || null, + profile_picture_url: updatedProfile?.profile_picture_url || null, + message: 'Profile updated successfully', + }); + } + + // Get authentication provider info for response + const authProvider = user.app_metadata?.provider || 'email'; + const formatProviderName = (provider) => { + const names = { + 'spotify': 'Spotify', + 'google': 'Google (YouTube)', + 'email': 'Email', + }; + return names[provider] || provider; + }; + + // Return updated profile data (matching GET endpoint format) + return NextResponse.json({ + id: user.id, + email: user.email, + email_verified: user.email_confirmed_at ? true : false, + display_name: profile?.display_name || null, + bio: profile?.bio || null, + profile_picture_url: profile?.profile_picture_url || user.user_metadata?.avatar_url || null, + username: profile?.username || null, + created_at: user.created_at, + auth_provider: authProvider, + auth_provider_display: formatProviderName(authProvider), + message: 'Profile updated successfully', + }); + } catch (error) { + console.error('[profile API] Unexpected error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/auth/callback/route.js b/apps/web/app/auth/callback/route.js index 7bfb43c..00b16fb 100644 --- a/apps/web/app/auth/callback/route.js +++ b/apps/web/app/auth/callback/route.js @@ -3,6 +3,38 @@ import { NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +/** + * Validates and sanitizes a redirect path to prevent open redirect vulnerabilities + * @param {string} path - The redirect path to validate + * @param {URL} baseUrl - The base URL to resolve relative paths against + * @returns {URL} A safe redirect URL or the default path + */ +function getSafeRedirectUrl(path, baseUrl) { + // Default to home if no path provided + const defaultPath = '/'; + + if (!path) { + return new URL(defaultPath, baseUrl); + } + + // Validate that path is a relative path (prevents open redirect) + // Must start with / and not contain protocol schemes (http://, https://, //) + if (path.startsWith('/') && !path.match(/^\/\/|^https?:\/\//i)) { + try { + const dest = new URL(path, baseUrl); + // Double-check the origin matches (prevents protocol-relative URLs) + if (dest.origin === baseUrl.origin) { + return dest; + } + } catch { + // URL parsing failed, use default + } + } + + // If validation fails, return default path + return new URL(defaultPath, baseUrl); +} + export async function GET(request) { const url = new URL(request.url); const code = url.searchParams.get('code'); @@ -20,7 +52,7 @@ export async function GET(request) { const userId = session.user.id; const provider = session.user?.app_metadata?.provider || null; const accessToken = session.provider_token ?? null; - const refreshToken = session.provider_refresh_token ?? null; // must be non-null to “upgrade” + const refreshToken = session.provider_refresh_token ?? null; // must be non-null to "upgrade" const expiresIn = session.provider_token_expires_in ?? 3600; const scope = session.provider_scope ?? null; @@ -47,5 +79,6 @@ export async function GET(request) { } } - return NextResponse.redirect(new URL(next, request.url)); + const safeRedirectUrl = getSafeRedirectUrl(next, request.url); + return NextResponse.redirect(safeRedirectUrl); } diff --git a/apps/web/app/groups/page.jsx b/apps/web/app/groups/page.jsx new file mode 100644 index 0000000..f81765b --- /dev/null +++ b/apps/web/app/groups/page.jsx @@ -0,0 +1,13 @@ +export default function GroupsPage() { + return ( +
+
+

+ Groups +

+

This page is under development

+

Coming soon...

+
+
+ ); +} diff --git a/apps/web/app/home/page.jsx b/apps/web/app/home/page.jsx new file mode 100644 index 0000000..f23b520 --- /dev/null +++ b/apps/web/app/home/page.jsx @@ -0,0 +1,13 @@ +export default function HomePage() { + return ( +
+
+

+ Home +

+

This page is under development

+

Coming soon...

+
+
+ ); +} diff --git a/apps/web/app/layout.jsx b/apps/web/app/layout.jsx index 85e0fbd..a753985 100644 --- a/apps/web/app/layout.jsx +++ b/apps/web/app/layout.jsx @@ -2,7 +2,7 @@ import './globals.css'; import { createServerComponentClient } from '@supabase/auth-helpers-nextjs' import { cookies } from 'next/headers' import Navbar from '@/components/Navbar'; - +import ClientProviders from '@/components/ClientProviders'; import { supabaseServer } from '@/lib/supabase/server'; @@ -18,8 +18,10 @@ export default async function RootLayout({ children }) { return ( - {user && } -
{children}
+ + {user && } +
{children}
+
); diff --git a/apps/web/app/library/page.jsx b/apps/web/app/library/page.jsx index 3a1453c..ecd46d4 100644 --- a/apps/web/app/library/page.jsx +++ b/apps/web/app/library/page.jsx @@ -1,5 +1,5 @@ -import LibraryView from '@/components/LibraryView'; // or '../components/LibraryView' if no alias +import LibraryView from '@/components/LibraryView'; -export default function Page() { +export default function LibraryPage() { return ; } diff --git a/apps/web/app/page.jsx b/apps/web/app/page.jsx index 75a079f..d399e7d 100644 --- a/apps/web/app/page.jsx +++ b/apps/web/app/page.jsx @@ -1,8 +1,35 @@ -import Image from "next/image"; -import SignInPage from './(auth)/sign-in/page'; +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { supabaseBrowser } from '@/lib/supabase/client'; export default function Home() { + const router = useRouter(); + + useEffect(() => { + const checkAuth = async () => { + const supabase = supabaseBrowser(); + const { data: { user } } = await supabase.auth.getUser(); + + if (user) { + // User is signed in, redirect to library + router.push('/library'); + } else { + // User is not signed in, redirect to sign-in + router.push('/sign-in'); + } + }; + + checkAuth(); + }, [router]); + return ( - +
+
+

Loading...

+

Redirecting you to the right page

+
+
); -} +} \ No newline at end of file diff --git a/apps/web/app/playlist/page.jsx b/apps/web/app/playlist/page.jsx new file mode 100644 index 0000000..23741c7 --- /dev/null +++ b/apps/web/app/playlist/page.jsx @@ -0,0 +1,13 @@ +export default function PlaylistPage() { + return ( +
+
+

+ Playlist +

+

This page is under development

+

Coming soon...

+
+
+ ); +} diff --git a/apps/web/app/profile/page.jsx b/apps/web/app/profile/page.jsx new file mode 100644 index 0000000..a8dc00c --- /dev/null +++ b/apps/web/app/profile/page.jsx @@ -0,0 +1,13 @@ +export default function ProfilePage() { + return ( +
+
+

+ Profile +

+

This page is under development

+

Coming soon...

+
+
+ ); +} diff --git a/apps/web/app/settings/account/page.jsx b/apps/web/app/settings/account/page.jsx new file mode 100644 index 0000000..a2920d7 --- /dev/null +++ b/apps/web/app/settings/account/page.jsx @@ -0,0 +1,346 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Settings as SettingsIcon, AlertTriangle, Trash2, Clock, Info, Download } from 'lucide-react'; +import SettingsPageWrapper from '@/components/SettingsPageWrapper'; +import { useProfile } from '@/hooks/useProfileUpdate'; +import DeleteAccountModal from '@/components/DeleteAccountModal'; + +// Inner component that uses hooks +function AccountSettingsContent() { + const { data: profileData, isLoading: loading } = useProfile(); + const [accountAge, setAccountAge] = useState(null); + const [isAccountTooNew, setIsAccountTooNew] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isExporting, setIsExporting] = useState(false); + + // Check account age (24 hour restriction) + // Note: We'll get created_at from user metadata, profile data, or calculate from account creation + useEffect(() => { + // Try to get created_at from profile data first + if (profileData?.created_at) { + const createdAt = new Date(profileData.created_at); + const now = new Date(); + const hoursSinceCreation = (now - createdAt) / (1000 * 60 * 60); + setAccountAge(hoursSinceCreation); + setIsAccountTooNew(hoursSinceCreation < 24); + } else { + // If created_at not in profile, we'll need to fetch it from auth.users or handle gracefully + // For now, assume account is old enough (will be checked on server side too) + setIsAccountTooNew(false); + } + }, [profileData]); + + const handleDeleteClick = () => { + setDeleteModalOpen(true); + }; + + const handleDeleteConfirm = async (data) => { + setIsDeleting(true); + + try { + const response = await fetch('/api/user/account/delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + const result = await response.json(); + + if (!response.ok) { + // Show error message + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('show-toast', { + detail: { + type: 'error', + message: result.error || result.message || 'Failed to delete account', + }, + })); + } + setIsDeleting(false); + return; + } + + // Success - show message and redirect + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('show-toast', { + detail: { + type: 'success', + message: result.message || 'Account deleted successfully', + }, + })); + + // Redirect to home after a short delay + setTimeout(() => { + window.location.href = '/'; + }, 2000); + } + } catch (error) { + console.error('Failed to delete account:', error); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('show-toast', { + detail: { + type: 'error', + message: 'An error occurred while deleting your account', + }, + })); + } + setIsDeleting(false); + } + }; + + if (loading) { + return ( + <> +
+
+ +
+

Account

+

+ Account settings and data management +

+
+
+
+
+
+
+
+
+ + ); + } + + return ( +
+ {/* Section Header */} +
+
+ +
+

Account

+

+ Account settings and data management +

+
+
+
+ + {/* Section Content */} +
+ {/* Account Information Section */} +
+

Account Information

+ +
+
+ +
+

+ Manage your account settings, export your data, or permanently delete your account. +

+ + {/* Data Export */} +
+
+
+

+ Export Your Data +

+

+ Download all your account data in JSON format. This includes your profile, + playlists, listening history, settings, and preferences. Recommended before + deleting your account. +

+

+ Rate limit: 1 export per 24 hours +

+
+ +
+
+
+
+
+
+ + {/* Danger Zone Section */} +
+
+ +

Danger Zone

+
+ +
+
+
+ +
+

+ Delete Your Account +

+

+ This action cannot be undone. Deleting your account will permanently remove: +

+ +
    +
  • Your profile and all associated data
  • +
  • All playlists you've created
  • +
  • Your listening history and activity
  • +
  • All social connections and friendships
  • +
  • Group playlists and collaborations
  • +
  • Your notification preferences and privacy settings
  • +
+ + {isAccountTooNew && accountAge !== null && ( +
+
+ +
+

+ Account Protection Period +

+

+ For security purposes, accounts less than 24 hours old cannot be deleted. + Your account was created {Math.floor(accountAge)} hours ago. + You'll be able to delete your account in {Math.ceil(24 - accountAge)} hour(s). +

+
+
+
+ )} + + {!isAccountTooNew && ( +
+
+ +
+

+ Account Deletion Process +

+

+ Account deletion is permanent and irreversible. You'll be asked to confirm + your decision through a multi-step process, including typing a confirmation phrase + and re-entering your password. +

+
+
+
+ )} + + +
+
+
+
+
+
+ + {/* Delete Account Modal */} + setDeleteModalOpen(false)} + onConfirm={handleDeleteConfirm} + isDeleting={isDeleting} + /> +
+ ); +} + +// Outer component that wraps content with SettingsPageWrapper +export default function AccountSettingsPage() { + return ( + + + + ); +} + diff --git a/apps/web/app/settings/layout.jsx b/apps/web/app/settings/layout.jsx new file mode 100644 index 0000000..08aadbb --- /dev/null +++ b/apps/web/app/settings/layout.jsx @@ -0,0 +1,12 @@ +import { Suspense } from 'react'; + +export default function SettingsLayout({ children }) { + return ( +
+ {children} +
+ ); +} + + + diff --git a/apps/web/app/settings/notifications/page.jsx b/apps/web/app/settings/notifications/page.jsx new file mode 100644 index 0000000..25609da --- /dev/null +++ b/apps/web/app/settings/notifications/page.jsx @@ -0,0 +1,418 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Bell, Users, Music, MessageSquare, Megaphone, Shield, Info, ToggleLeft, ToggleRight } from 'lucide-react'; +import SettingsPageWrapper, { useSettingsContext } from '@/components/SettingsPageWrapper'; +import { NotificationToggle } from '@/components/NotificationToggle'; +import { notificationSchema, getDefaultNotificationPreferences } from '@/lib/schemas/notificationSchema'; +import { useNotificationPreferences, useNotificationPreferencesUpdate } from '@/hooks/useNotificationPreferences'; + +// Inner component that uses the context (must be inside SettingsPageWrapper) +function NotificationSettingsContent() { + const { setHasUnsavedChanges, setFormSubmitHandler, setFormResetHandler } = useSettingsContext(); + + // Fetch notification preferences using TanStack Query + const { data: notificationData, isLoading: loading, error: notificationError } = useNotificationPreferences(); + const notificationUpdate = useNotificationPreferencesUpdate(); + + const { + register, + handleSubmit, + formState: { errors, isDirty }, + setValue, + watch, + reset, + } = useForm({ + resolver: zodResolver(notificationSchema), + defaultValues: getDefaultNotificationPreferences(), + }); + + // Watch all form values + const notificationsEnabled = watch('notifications_enabled'); + const emailFrequency = watch('email_frequency'); + + // Store original form values for cancel + const [originalValues, setOriginalValues] = useState(null); + + // Update unsaved changes indicator when form is dirty + useEffect(() => { + setHasUnsavedChanges(isDirty); + }, [isDirty, setHasUnsavedChanges]); + + // Initialize form with fetched notification preferences + useEffect(() => { + if (notificationData) { + // Set all form values from API response + Object.keys(notificationData).forEach(key => { + if (key !== 'message') { // Exclude success message from form data + setValue(key, notificationData[key], { shouldDirty: false }); + } + }); + + setOriginalValues(notificationData); + } else if (!loading && !notificationError) { + // If no data and not loading, use defaults + const defaultValues = getDefaultNotificationPreferences(); + Object.keys(defaultValues).forEach(key => { + setValue(key, defaultValues[key], { shouldDirty: false }); + }); + setOriginalValues(defaultValues); + } + }, [notificationData, loading, notificationError, setValue]); + + // Form submission handler + const onSubmit = async (data) => { + try { + // Ensure security alerts are always enabled + const submitData = { + ...data, + security_alerts_inapp: true, + security_alerts_email: true, + }; + + // Use TanStack Query mutation to update preferences + const updatedPreferences = await notificationUpdate.mutateAsync(submitData); + + // Reset form with updated data + reset(updatedPreferences); + setOriginalValues(updatedPreferences); + setHasUnsavedChanges(false); + } catch (error) { + console.error('Failed to update notification preferences:', error); + // Error toast is handled by the mutation hook + throw error; + } + }; + + // Register form submit handler with the wrapper + useEffect(() => { + const submitFn = () => { + return handleSubmit(onSubmit)(); + }; + setFormSubmitHandler(() => submitFn); + }, [handleSubmit, setFormSubmitHandler]); + + // Register form reset handler with the wrapper + useEffect(() => { + const resetFn = () => { + if (originalValues) { + reset(originalValues); + } + }; + setFormResetHandler(() => resetFn); + }, [reset, originalValues, setFormResetHandler]); + + // Master toggle handler + const handleMasterToggle = (enabled) => { + const allNotificationFields = [ + 'friend_requests_inapp', 'friend_requests_email', + 'new_followers_inapp', 'new_followers_email', + 'comments_inapp', 'comments_email', + 'playlist_invites_inapp', 'playlist_invites_email', + 'playlist_updates_inapp', 'playlist_updates_email', + 'song_of_day_inapp', 'song_of_day_email', + 'system_announcements_inapp', 'system_announcements_email', + ]; + + allNotificationFields.forEach(field => { + setValue(field, enabled, { shouldDirty: true }); + }); + + // Keep security alerts enabled + setValue('security_alerts_inapp', true, { shouldDirty: true }); + setValue('security_alerts_email', true, { shouldDirty: true }); + }; + + // Loading state + if (loading) { + return ( + <> +
+
+ +
+

Notifications

+

+ Configure your notification preferences +

+
+
+
+
+
+
+
+
+ + ); + } + + // Error state + if (notificationError) { + return ( + <> +
+
+ +
+

Notifications

+

+ Configure your notification preferences +

+
+
+
+
+
+

Error loading notification preferences: {notificationError.message}

+
+
+ + ); + } + + return ( +
+ {/* Section Header */} +
+
+ +
+

Notifications

+

+ Configure your notification preferences +

+
+
+
+ + {/* Section Content */} +
+
+ {/* Master Toggle */} +
+
+
+

Enable All Notifications

+

+ Master toggle to enable or disable all non-essential notifications at once +

+
+ +
+
+ + {/* Email Frequency */} +
+ + +

+ Choose how often you receive email notifications +

+
+ + {/* Divider */} +
+ + {/* Social Notifications */} +
+
+ +

Social Notifications

+
+ + setValue('friend_requests_inapp', value, { shouldDirty: true })} + onEmailChange={(value) => setValue('friend_requests_email', value, { shouldDirty: true })} + disabled={!notificationsEnabled} + /> + + setValue('new_followers_inapp', value, { shouldDirty: true })} + onEmailChange={(value) => setValue('new_followers_email', value, { shouldDirty: true })} + disabled={!notificationsEnabled} + /> + + setValue('comments_inapp', value, { shouldDirty: true })} + onEmailChange={(value) => setValue('comments_email', value, { shouldDirty: true })} + disabled={!notificationsEnabled} + /> +
+ + {/* Divider */} +
+ + {/* Playlist Notifications */} +
+
+ +

Playlist Notifications

+
+ + setValue('playlist_invites_inapp', value, { shouldDirty: true })} + onEmailChange={(value) => setValue('playlist_invites_email', value, { shouldDirty: true })} + disabled={!notificationsEnabled} + /> + + setValue('playlist_updates_inapp', value, { shouldDirty: true })} + onEmailChange={(value) => setValue('playlist_updates_email', value, { shouldDirty: true })} + disabled={!notificationsEnabled} + /> +
+ + {/* Divider */} +
+ + {/* System Notifications */} +
+
+ +

System Notifications

+
+ + setValue('song_of_day_inapp', value, { shouldDirty: true })} + onEmailChange={(value) => setValue('song_of_day_email', value, { shouldDirty: true })} + disabled={!notificationsEnabled} + /> + + setValue('system_announcements_inapp', value, { shouldDirty: true })} + onEmailChange={(value) => setValue('system_announcements_email', value, { shouldDirty: true })} + disabled={!notificationsEnabled} + /> + + setValue('security_alerts_inapp', true, { shouldDirty: true })} + onEmailChange={(value) => setValue('security_alerts_email', true, { shouldDirty: true })} + disabled={true} + required={true} + /> +
+ + {/* Info Box */} +
+
+ +
+

Notification Preferences Explained

+

+ You can customize how and when you receive notifications. In-app notifications appear in the + Vybe app, while email notifications are sent to your registered email address. Security alerts + cannot be disabled to ensure account safety. +

+
+
+
+
+ + {/* Hidden form fields for React Hook Form */} + + + + + + + + + + + + + + + + + +
+
+ ); +} + +// Outer component that wraps content with SettingsPageWrapper +export default function NotificationSettingsPage() { + return ( + + + + ); +} diff --git a/apps/web/app/settings/page.jsx b/apps/web/app/settings/page.jsx new file mode 100644 index 0000000..7f30209 --- /dev/null +++ b/apps/web/app/settings/page.jsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function SettingsPage() { + redirect('/settings/profile'); +} diff --git a/apps/web/app/settings/privacy/page.jsx b/apps/web/app/settings/privacy/page.jsx new file mode 100644 index 0000000..f923cfb --- /dev/null +++ b/apps/web/app/settings/privacy/page.jsx @@ -0,0 +1,412 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Shield, Info, Globe, Users, Lock, Eye, EyeOff, Search, Rss } from 'lucide-react'; +import SettingsPageWrapper, { useSettingsContext } from '@/components/SettingsPageWrapper'; +import { PrivacyToggle, PrivacyRadioGroup } from '@/components/PrivacyToggle'; +import { privacySchema, getDefaultPrivacySettings } from '@/lib/schemas/privacySchema'; +import { usePrivacySettings, usePrivacySettingsUpdate } from '@/hooks/usePrivacySettings'; + +// Inner component that uses the context (must be inside SettingsPageWrapper) +function PrivacySettingsContent() { + const { setHasUnsavedChanges, setFormSubmitHandler, setFormResetHandler } = useSettingsContext(); + + // Fetch privacy settings using TanStack Query + const { data: privacyData, isLoading: loading, error: privacyError } = usePrivacySettings(); + + // Privacy settings update mutation hook + const privacyUpdate = usePrivacySettingsUpdate(); + + const { + register, + handleSubmit, + formState: { errors, isDirty }, + setValue, + watch, + reset, + } = useForm({ + resolver: zodResolver(privacySchema), + defaultValues: getDefaultPrivacySettings(), + }); + + // Watch all form values + const profileVisibility = watch('profile_visibility'); + const playlistVisibility = watch('playlist_visibility'); + const listeningActivityVisible = watch('listening_activity_visible'); + const songOfDayVisibility = watch('song_of_day_visibility'); + const friendRequestSetting = watch('friend_request_setting'); + const searchable = watch('searchable'); + const activityFeedVisible = watch('activity_feed_visible'); + + // Store original form values for cancel + const [originalValues, setOriginalValues] = useState(null); + + // Update unsaved changes indicator when form is dirty + useEffect(() => { + setHasUnsavedChanges(isDirty); + }, [isDirty, setHasUnsavedChanges]); + + // Update form when privacy data loads + useEffect(() => { + if (privacyData) { + // Set form values + const formValues = { + profile_visibility: privacyData.profile_visibility || 'public', + playlist_visibility: privacyData.playlist_visibility || 'public', + listening_activity_visible: privacyData.listening_activity_visible ?? true, + song_of_day_visibility: privacyData.song_of_day_visibility || 'public', + friend_request_setting: privacyData.friend_request_setting || 'everyone', + searchable: privacyData.searchable ?? true, + activity_feed_visible: privacyData.activity_feed_visible ?? true, + }; + + setValue('profile_visibility', formValues.profile_visibility); + setValue('playlist_visibility', formValues.playlist_visibility); + setValue('listening_activity_visible', formValues.listening_activity_visible); + setValue('song_of_day_visibility', formValues.song_of_day_visibility); + setValue('friend_request_setting', formValues.friend_request_setting); + setValue('searchable', formValues.searchable); + setValue('activity_feed_visible', formValues.activity_feed_visible); + + // Store original values for cancel + setOriginalValues(formValues); + } + }, [privacyData, setValue]); + + // Form submission handler using the mutation hook + const onSubmit = async (data) => { + try { + // Use the mutation hook to update privacy settings + const updatedPrivacy = await privacyUpdate.mutateAsync(data); + + // Privacy data will be updated via cache invalidation + // Update form values with response data + const formValues = { + profile_visibility: updatedPrivacy.profile_visibility || 'public', + playlist_visibility: updatedPrivacy.playlist_visibility || 'public', + listening_activity_visible: updatedPrivacy.listening_activity_visible ?? true, + song_of_day_visibility: updatedPrivacy.song_of_day_visibility || 'public', + friend_request_setting: updatedPrivacy.friend_request_setting || 'everyone', + searchable: updatedPrivacy.searchable ?? true, + activity_feed_visible: updatedPrivacy.activity_feed_visible ?? true, + }; + + reset(formValues); + setOriginalValues(formValues); + setHasUnsavedChanges(false); + } catch (error) { + // Error is handled by the mutation hook (toast notification) + // Re-throw to allow form to handle error state if needed + throw error; + } + }; + + // Register form submit handler with the wrapper + useEffect(() => { + const submitFn = () => { + return handleSubmit(onSubmit)(); + }; + setFormSubmitHandler(() => submitFn); + }, [handleSubmit, setFormSubmitHandler]); + + // Register form reset handler with the wrapper + useEffect(() => { + const resetFn = () => { + if (originalValues) { + reset(originalValues); + } + }; + setFormResetHandler(() => resetFn); + }, [reset, originalValues, setFormResetHandler]); + + if (loading) { + return ( + <> +
+
+ +
+

Privacy

+

+ Control who can see your activity and playlists +

+
+
+
+
+
+
+
+
+ + ); + } + + if (privacyError) { + return ( + <> +
+
+ +
+

Privacy

+

+ Control who can see your activity and playlists +

+
+
+
+
+
+

Error loading privacy settings: {privacyError.message}

+
+
+ + ); + } + + return ( +
+ {/* Section Header */} +
+
+ +
+

Privacy

+

+ Control who can see your activity and playlists +

+
+
+
+ + {/* Section Content */} +
+
+ {/* Profile Visibility */} +
+ setValue('profile_visibility', value, { shouldDirty: true })} + requireConfirmation={true} + options={[ + { + value: 'public', + label: 'Public', + description: 'Anyone can view your profile', + icon: 'Globe', + }, + { + value: 'friends', + label: 'Friends Only', + description: 'Only your friends can view your profile', + icon: 'Users', + }, + { + value: 'private', + label: 'Private', + description: 'Only you can view your profile', + icon: 'Lock', + }, + ]} + /> +
+ + {/* Divider */} +
+ + {/* Playlist Visibility */} +
+ setValue('playlist_visibility', value, { shouldDirty: true })} + requireConfirmation={true} + options={[ + { + value: 'public', + label: 'Public', + description: 'Anyone can view and follow your playlists', + icon: 'Globe', + }, + { + value: 'friends', + label: 'Friends Only', + description: 'Only your friends can view your playlists', + icon: 'Users', + }, + { + value: 'private', + label: 'Private', + description: 'Only you can view your playlists', + icon: 'Lock', + }, + ]} + /> +
+ + {/* Divider */} +
+ + {/* Listening Activity */} +
+ setValue('listening_activity_visible', checked, { shouldDirty: true })} + requireConfirmation={true} + confirmationTitle="Hide Listening Activity?" + confirmationMessage="This will hide what you're currently listening to from your profile. Are you sure you want to continue?" + /> +
+ + {/* Divider */} +
+ + {/* Song of the Day Visibility */} +
+ setValue('song_of_day_visibility', value, { shouldDirty: true })} + requireConfirmation={true} + options={[ + { + value: 'public', + label: 'Public', + description: 'Everyone can see your Song of the Day', + icon: 'Globe', + }, + { + value: 'friends', + label: 'Friends Only', + description: 'Only your friends can see your Song of the Day', + icon: 'Users', + }, + { + value: 'private', + label: 'Private', + description: 'Only you can see your Song of the Day', + icon: 'Lock', + }, + ]} + /> +
+ + {/* Divider */} +
+ + {/* Friend Request Settings */} +
+ setValue('friend_request_setting', value, { shouldDirty: true })} + requireConfirmation={true} + options={[ + { + value: 'everyone', + label: 'Everyone', + description: 'Anyone can send you friend requests', + icon: 'Globe', + }, + { + value: 'friends_of_friends', + label: 'Friends of Friends', + description: 'Only people who are friends with your friends can send requests', + icon: 'Users', + }, + { + value: 'nobody', + label: 'Nobody', + description: 'No one can send you friend requests', + icon: 'Lock', + }, + ]} + /> +
+ + {/* Divider */} +
+ + {/* Search Visibility */} +
+ setValue('searchable', checked, { shouldDirty: true })} + requireConfirmation={true} + confirmationTitle="Remove from Search Results?" + confirmationMessage="This will prevent others from finding you through search. Are you sure you want to continue?" + /> +
+ + {/* Divider */} +
+ + {/* Activity Feed Visibility */} +
+ setValue('activity_feed_visible', checked, { shouldDirty: true })} + requireConfirmation={true} + confirmationTitle="Hide Activity Feed?" + confirmationMessage="This will hide your recent activity from your activity feed. Are you sure you want to continue?" + /> +
+ + {/* Info Box */} +
+
+ +
+

Privacy Settings Explained

+

+ Your privacy settings control what information is visible to others. These settings help you + maintain control over your personal data and listening habits. You can change these settings + at any time, and changes take effect immediately. +

+
+
+
+
+ + {/* Hidden form fields for React Hook Form */} + + + + + + + +
+
+ ); +} + +// Outer component that wraps content with SettingsPageWrapper +export default function PrivacySettingsPage() { + return ( + + + + ); +} diff --git a/apps/web/app/settings/profile/page.jsx b/apps/web/app/settings/profile/page.jsx new file mode 100644 index 0000000..c17f799 --- /dev/null +++ b/apps/web/app/settings/profile/page.jsx @@ -0,0 +1,403 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { User, Mail, Calendar, CheckCircle2, XCircle, Music, ExternalLink } from 'lucide-react'; +import SettingsPageWrapper, { useSettingsContext } from '@/components/SettingsPageWrapper'; +import { profileSchema } from '@/lib/schemas/profileSchema'; +import ProfilePictureUpload from '@/components/ProfilePictureUpload'; +import { useProfileUpdate, useProfile } from '@/hooks/useProfileUpdate'; + +// Inner component that uses the context (must be inside SettingsPageWrapper) +function ProfileSettingsContent() { + const { setHasUnsavedChanges, setFormSubmitHandler, setFormResetHandler } = useSettingsContext(); + + // Fetch profile data using TanStack Query + const { data: profileData, isLoading: loading, error: profileError } = useProfile(); + + // Profile update mutation hook + const profileUpdate = useProfileUpdate(); + + const { + register, + handleSubmit, + formState: { errors, isDirty }, + setValue, + watch, + reset, + } = useForm({ + resolver: zodResolver(profileSchema), + defaultValues: { + display_name: '', + bio: '', + profile_picture_url: '', + }, + }); + + const displayName = watch('display_name'); + const bio = watch('bio'); + + // Update unsaved changes indicator when form is dirty + useEffect(() => { + setHasUnsavedChanges(isDirty); + }, [isDirty, setHasUnsavedChanges]); + + // Store original form values for cancel + const [originalValues, setOriginalValues] = useState(null); + + // Form submission handler using the mutation hook + const onSubmit = async (data) => { + try { + // Use the mutation hook to update profile + const updatedProfile = await profileUpdate.mutateAsync(data); + + // Profile data will be updated via cache invalidation + // Update form values with response data + const formValues = { + display_name: updatedProfile.display_name || '', + bio: updatedProfile.bio || '', + profile_picture_url: updatedProfile.profile_picture_url || '', + }; + + reset(formValues); + setOriginalValues(formValues); + setHasUnsavedChanges(false); + } catch (error) { + // Error is handled by the mutation hook (toast notification) + // Re-throw to allow form to handle error state if needed + throw error; + } + }; + + // Register form submit handler with the wrapper + useEffect(() => { + const submitFn = () => { + return handleSubmit(onSubmit)(); + }; + setFormSubmitHandler(() => submitFn); + }, [handleSubmit, setFormSubmitHandler]); + + // Register form reset handler with the wrapper + useEffect(() => { + const resetFn = () => { + if (originalValues) { + reset(originalValues); + } + }; + setFormResetHandler(() => resetFn); + }, [reset, originalValues, setFormResetHandler]); + + // Update form when profile data loads + useEffect(() => { + if (profileData) { + // Set form values + const formValues = { + display_name: profileData.display_name || '', + bio: profileData.bio || '', + profile_picture_url: profileData.profile_picture_url || '', + }; + setValue('display_name', formValues.display_name); + setValue('bio', formValues.bio); + setValue('profile_picture_url', formValues.profile_picture_url); + + // Store original values for cancel + setOriginalValues(formValues); + } + }, [profileData, setValue]); + + // Format date for display + const formatDate = (dateString) => { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + if (loading) { + return ( + <> +
+
+ +
+

Profile

+

+ Manage your display name, bio, and profile picture +

+
+
+
+
+
+
+
+
+ + ); + } + + if (profileError) { + return ( + <> +
+
+ +
+

Profile

+

+ Manage your display name, bio, and profile picture +

+
+
+
+
+
+

Error loading profile: {profileError.message}

+
+
+ + ); + } + + return ( +
+ {/* Section Header */} +
+
+ +
+

Profile

+

+ Manage your display name, bio, and profile picture +

+
+
+
+ + {/* Section Content */} +
+
+ {/* Display Name Input */} +
+ + +
+
+ {errors.display_name ? ( + {errors.display_name.message} + ) : ( + 2-50 characters, letters, numbers, and spaces only + )} +
+
+ {displayName?.length || 0}/50 +
+
+
+ + {/* Bio Textarea */} +
+ +