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

View File

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

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

View File

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

View File

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

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