diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 956431d..fd237e8 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.security import hash_password, verify_password, create_access_token, get_current_user from app.models.user import User +from pydantic import BaseModel, EmailStr from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse from app.services.spotify import get_spotify_auth_url, exchange_spotify_code, get_spotify_user @@ -29,6 +30,42 @@ async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)): return TokenResponse(access_token=create_access_token(user.id)) +class UpdateProfileRequest(BaseModel): + name: str | None = None + email: EmailStr | None = None + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + +@router.put("/me") +async def update_profile(data: UpdateProfileRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + if data.name: + user.name = data.name + if data.email and data.email != user.email: + existing = await db.execute(select(User).where(User.email == data.email)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already in use") + user.email = data.email + return UserResponse(id=user.id, email=user.email, name=user.name, is_pro=user.is_pro, spotify_connected=user.spotify_id is not None) + + +@router.post("/change-password") +async def change_password(data: ChangePasswordRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + if not user.hashed_password or not verify_password(data.current_password, user.hashed_password): + raise HTTPException(status_code=400, detail="Current password is incorrect") + user.hashed_password = hash_password(data.new_password) + return {"ok": True} + + +@router.delete("/me") +async def delete_account(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + await db.delete(user) + return {"ok": True} + + @router.post("/login", response_model=TokenResponse) async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)): result = await db.execute(select(User).where(User.email == data.email)) diff --git a/frontend/index.html b/frontend/index.html index dcd0bb4..38dac03 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -22,6 +22,10 @@ + + + + Vynl - AI Music Discovery diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..c58a25f --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "Vynl - AI Music Discovery", + "short_name": "Vynl", + "description": "Discover music you'll love with AI", + "start_url": "/dashboard", + "display": "standalone", + "background_color": "#FFF7ED", + "theme_color": "#7C3AED", + "icons": [ + { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" } + ] +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8e8e24a..8f3dad7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import Timeline from './pages/Timeline' import Compatibility from './pages/Compatibility' import CrateDigger from './pages/CrateDigger' import RabbitHole from './pages/RabbitHole' +import Settings from './pages/Settings' function RootRedirect() { const { user, loading } = useAuth() @@ -202,6 +203,16 @@ function AppRoutes() { } /> + + + + + + } + /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c1d487b..2d9631c 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' -import { Disc3, LayoutDashboard, Fingerprint, Clock, ListMusic, ListPlus, Compass, Lightbulb, Store, Users, ArrowDownCircle, Heart, Crown, Shield, Menu, X, LogOut, User } from 'lucide-react' +import { Disc3, LayoutDashboard, Fingerprint, Clock, ListMusic, ListPlus, Compass, Lightbulb, Store, Users, ArrowDownCircle, Heart, Crown, Shield, Menu, X, LogOut, User, Settings } from 'lucide-react' import { useAuth } from '../lib/auth' const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com' @@ -96,6 +96,14 @@ export default function Layout({ children }: { children: React.ReactNode }) {

{user?.name}

{user?.email}

+ setUserMenuOpen(false)} + className="w-full flex items-center gap-2 px-4 py-2 text-sm text-charcoal hover:bg-purple-50 transition-colors no-underline" + > + + Settings + + + + {/* Change Password Section */} +
+

Change Password

+
+
+ + setCurrentPassword(e.target.value)} + className="w-full px-4 py-2.5 rounded-xl border border-purple-200 bg-cream focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent text-charcoal" + /> +
+
+ + setNewPassword(e.target.value)} + className="w-full px-4 py-2.5 rounded-xl border border-purple-200 bg-cream focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent text-charcoal" + /> +
+
+ + setConfirmPassword(e.target.value)} + className="w-full px-4 py-2.5 rounded-xl border border-purple-200 bg-cream focus:outline-none focus:ring-2 focus:ring-purple focus:border-transparent text-charcoal" + /> +
+
+ {passwordMsg && ( +
+ {passwordMsg.type === 'success' ? : } + {passwordMsg.text} +
+ )} + +
+ + {/* Danger Zone */} +
+

Danger Zone

+

+ Permanently delete your account and all associated data. This action cannot be undone. +

+
+ + setDeleteConfirm(e.target.value)} + placeholder="DELETE" + className="w-full px-4 py-2.5 rounded-xl border border-red-200 bg-cream focus:outline-none focus:ring-2 focus:ring-red-400 focus:border-transparent text-charcoal" + /> +
+ {deleteMsg && ( +
+ + {deleteMsg.text} +
+ )} + +
+ + ) +}