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.database import get_db
|
||||||
from app.core.security import hash_password, verify_password, create_access_token, get_current_user
|
from app.core.security import hash_password, verify_password, create_access_token, get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse
|
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse
|
||||||
from app.services.spotify import get_spotify_auth_url, exchange_spotify_code, get_spotify_user
|
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))
|
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)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(select(User).where(User.email == data.email))
|
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:title" content="Vynl - AI Music Discovery" />
|
||||||
<meta name="twitter:description" content="Dig deeper. Discover more. AI-powered music recommendations that actually understand your taste." />
|
<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>
|
<title>Vynl - AI Music Discovery</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 Compatibility from './pages/Compatibility'
|
||||||
import CrateDigger from './pages/CrateDigger'
|
import CrateDigger from './pages/CrateDigger'
|
||||||
import RabbitHole from './pages/RabbitHole'
|
import RabbitHole from './pages/RabbitHole'
|
||||||
|
import Settings from './pages/Settings'
|
||||||
|
|
||||||
function RootRedirect() {
|
function RootRedirect() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
@@ -202,6 +203,16 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Settings />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/shared/:recId/:token" element={<SharedView />} />
|
<Route path="/shared/:recId/:token" element={<SharedView />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
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'
|
import { useAuth } from '../lib/auth'
|
||||||
|
|
||||||
const ADMIN_EMAIL = 'chris.ryan@deepcutsai.com'
|
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-sm font-medium text-charcoal">{user?.name}</p>
|
||||||
<p className="text-xs text-charcoal-muted">{user?.email}</p>
|
<p className="text-xs text-charcoal-muted">{user?.email}</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={handleLogout}
|
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"
|
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 = () =>
|
export const getTasteProfile = () =>
|
||||||
api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data)
|
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
|
// Taste Compatibility
|
||||||
export interface CompatibilityResponse {
|
export interface CompatibilityResponse {
|
||||||
friend_name: string
|
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