Add user settings page and PWA install support
- Add profile update, password change, and account deletion endpoints - Create Settings page with profile editing, password change, and danger zone - Add Settings link to user dropdown menu in Layout - Add /settings route to App.tsx - Add API functions for profile management - Create PWA manifest.json and add meta tags to index.html
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
<meta name="twitter:title" content="Vynl - AI Music Discovery" />
|
||||
<meta name="twitter:description" content="Dig deeper. Discover more. AI-powered music recommendations that actually understand your taste." />
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#7C3AED" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<title>Vynl - AI Music Discovery</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
12
frontend/public/manifest.json
Normal file
12
frontend/public/manifest.json
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Settings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/shared/:recId/:token" element={<SharedView />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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 }) {
|
||||
<p className="text-sm font-medium text-charcoal">{user?.name}</p>
|
||||
<p className="text-xs text-charcoal-muted">{user?.email}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={() => 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 className="w-4 h-4" />
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors cursor-pointer bg-transparent border-none text-left"
|
||||
|
||||
@@ -404,6 +404,12 @@ export const exportSaved = () =>
|
||||
export const getTasteProfile = () =>
|
||||
api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data)
|
||||
|
||||
export const getProfileShareLink = () =>
|
||||
api.get<{ share_url: string }>('/profile/share-link').then((r) => r.data)
|
||||
|
||||
export const getPublicProfile = (userId: string, token: string) =>
|
||||
api.get<TasteProfileResponse & { name: string }>(`/profile/public/${userId}/${token}`).then((r) => r.data)
|
||||
|
||||
// Taste Compatibility
|
||||
export interface CompatibilityResponse {
|
||||
friend_name: string
|
||||
|
||||
216
frontend/src/pages/Settings.tsx
Normal file
216
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Settings as SettingsIcon, Save, Loader2, AlertTriangle, Check } from 'lucide-react'
|
||||
import { useAuth } from '../lib/auth'
|
||||
import { updateProfile, changePassword, deleteAccount } from '../lib/api'
|
||||
|
||||
export default function Settings() {
|
||||
const { user, logout, refreshUser } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Profile
|
||||
const [name, setName] = useState(user?.name || '')
|
||||
const [email, setEmail] = useState(user?.email || '')
|
||||
const [profileLoading, setProfileLoading] = useState(false)
|
||||
const [profileMsg, setProfileMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
// Password
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [passwordLoading, setPasswordLoading] = useState(false)
|
||||
const [passwordMsg, setPasswordMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
// Delete
|
||||
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
const [deleteMsg, setDeleteMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
setProfileLoading(true)
|
||||
setProfileMsg(null)
|
||||
try {
|
||||
await updateProfile({ name: name || undefined, email: email || undefined })
|
||||
await refreshUser()
|
||||
setProfileMsg({ type: 'success', text: 'Profile updated successfully' })
|
||||
} catch (err: any) {
|
||||
setProfileMsg({ type: 'error', text: err.response?.data?.detail || 'Failed to update profile' })
|
||||
} finally {
|
||||
setProfileLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordMsg({ type: 'error', text: 'New passwords do not match' })
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
setPasswordMsg({ type: 'error', text: 'New password must be at least 6 characters' })
|
||||
return
|
||||
}
|
||||
setPasswordLoading(true)
|
||||
setPasswordMsg(null)
|
||||
try {
|
||||
await changePassword(currentPassword, newPassword)
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setPasswordMsg({ type: 'success', text: 'Password changed successfully' })
|
||||
} catch (err: any) {
|
||||
setPasswordMsg({ type: 'error', text: err.response?.data?.detail || 'Failed to change password' })
|
||||
} finally {
|
||||
setPasswordLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (deleteConfirm !== 'DELETE') return
|
||||
setDeleteLoading(true)
|
||||
setDeleteMsg(null)
|
||||
try {
|
||||
await deleteAccount()
|
||||
logout()
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
setDeleteMsg({ type: 'error', text: err.response?.data?.detail || 'Failed to delete account' })
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-100 flex items-center justify-center">
|
||||
<SettingsIcon className="w-6 h-6 text-purple" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-charcoal">Settings</h1>
|
||||
<p className="text-charcoal-muted text-sm">Manage your account</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Section */}
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-charcoal">Profile</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{profileMsg && (
|
||||
<div className={`flex items-center gap-2 text-sm ${profileMsg.type === 'success' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{profileMsg.type === 'success' ? <Check className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
|
||||
{profileMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleUpdateProfile}
|
||||
disabled={profileLoading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-purple text-white rounded-xl font-medium hover:bg-purple-600 transition-colors disabled:opacity-50 cursor-pointer border-none"
|
||||
>
|
||||
{profileLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Change Password Section */}
|
||||
<div className="bg-white rounded-2xl border border-purple-100 p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-charcoal">Change Password</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-1">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-1">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-1">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{passwordMsg && (
|
||||
<div className={`flex items-center gap-2 text-sm ${passwordMsg.type === 'success' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{passwordMsg.type === 'success' ? <Check className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
|
||||
{passwordMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleChangePassword}
|
||||
disabled={passwordLoading || !currentPassword || !newPassword || !confirmPassword}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-purple text-white rounded-xl font-medium hover:bg-purple-600 transition-colors disabled:opacity-50 cursor-pointer border-none"
|
||||
>
|
||||
{passwordLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="bg-white rounded-2xl border-2 border-red-200 p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-red-600">Danger Zone</h2>
|
||||
<p className="text-sm text-charcoal-muted">
|
||||
Permanently delete your account and all associated data. This action cannot be undone.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-charcoal mb-1">
|
||||
Type <span className="font-mono font-bold">DELETE</span> to confirm
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
{deleteMsg && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-600">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{deleteMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleteLoading || deleteConfirm !== 'DELETE'}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 cursor-pointer border-none"
|
||||
>
|
||||
{deleteLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <AlertTriangle className="w-4 h-4" />}
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user