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:
root
2026-03-31 20:49:57 -05:00
parent 5215e8c792
commit db2767bfda
7 changed files with 295 additions and 1 deletions

View File

@@ -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))

View File

@@ -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>

View 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" }
]
}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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

View 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>
)
}