mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-24 21:31:43 +08:00
Add admin panel with member management and password reset
Features: - Admin panel at /settings/members for workspace owners - View all workspace members with roles and last login - Create new users directly (with temporary password) - Change member roles (Owner/Editor/Viewer) - Reset user passwords (forces change on next login) - Remove members from workspace - Force password reset flow on login - Track last login timestamp for users API Routes: - GET/POST /api/workspaces/[id]/members - GET/PATCH/DELETE /api/workspaces/[id]/members/[memberId] - POST /api/workspaces/[id]/members/[memberId]/reset-password - POST /api/auth/change-password Schema changes: - Added lastLoginAt DateTime? to User model - Added forcePasswordReset Boolean to User model Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,8 @@ model User {
|
|||||||
name String
|
name String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
forcePasswordReset Boolean @default(false)
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
|||||||
493
src/app/(app)/settings/members/page.tsx
Normal file
493
src/app/(app)/settings/members/page.tsx
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
UserPlus,
|
||||||
|
Trash2,
|
||||||
|
Key,
|
||||||
|
Shield,
|
||||||
|
Edit2,
|
||||||
|
Loader,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button, Card, Input, Modal, showToast } from '@/components/ui'
|
||||||
|
import { Header, PageContainer } from '@/components/layout/header'
|
||||||
|
import { useApp } from '../../provider'
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id: string
|
||||||
|
role: 'OWNER' | 'EDITOR' | 'VIEWER'
|
||||||
|
joinedAt: string
|
||||||
|
user: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
lastLoginAt: string | null
|
||||||
|
forcePasswordReset: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MembersPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { currentWorkspace, user } = useApp()
|
||||||
|
|
||||||
|
const [members, setMembers] = useState<Member[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [showAddUser, setShowAddUser] = useState(false)
|
||||||
|
const [showEditRole, setShowEditRole] = useState<Member | null>(null)
|
||||||
|
const [showResetPassword, setShowResetPassword] = useState<Member | null>(null)
|
||||||
|
const [showRemove, setShowRemove] = useState<Member | null>(null)
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [addUserForm, setAddUserForm] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'VIEWER' as 'OWNER' | 'EDITOR' | 'VIEWER',
|
||||||
|
forcePasswordReset: true,
|
||||||
|
})
|
||||||
|
const [resetPasswordForm, setResetPasswordForm] = useState({
|
||||||
|
newPassword: '',
|
||||||
|
forceChange: true,
|
||||||
|
})
|
||||||
|
const [newRole, setNewRole] = useState<'OWNER' | 'EDITOR' | 'VIEWER'>('VIEWER')
|
||||||
|
const [actionLoading, setActionLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchMembers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/members`)
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch members')
|
||||||
|
const data = await response.json()
|
||||||
|
setMembers(data.members)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load members')
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [currentWorkspace.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentWorkspace.role !== 'OWNER') {
|
||||||
|
router.push('/settings')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetchMembers()
|
||||||
|
}, [currentWorkspace.role, fetchMembers, router])
|
||||||
|
|
||||||
|
const handleAddUser = async () => {
|
||||||
|
setActionLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(addUserForm),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error)
|
||||||
|
|
||||||
|
showToast(data.message || 'User added', 'success')
|
||||||
|
setShowAddUser(false)
|
||||||
|
setAddUserForm({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'VIEWER',
|
||||||
|
forcePasswordReset: true,
|
||||||
|
})
|
||||||
|
fetchMembers()
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : 'Failed to add user', 'error')
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateRole = async () => {
|
||||||
|
if (!showEditRole) return
|
||||||
|
setActionLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/workspaces/${currentWorkspace.id}/members/${showEditRole.id}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ role: newRole }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error)
|
||||||
|
|
||||||
|
showToast('Role updated', 'success')
|
||||||
|
setShowEditRole(null)
|
||||||
|
fetchMembers()
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : 'Failed to update role', 'error')
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetPassword = async () => {
|
||||||
|
if (!showResetPassword) return
|
||||||
|
setActionLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/workspaces/${currentWorkspace.id}/members/${showResetPassword.id}/reset-password`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(resetPasswordForm),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error)
|
||||||
|
|
||||||
|
showToast(data.message || 'Password reset', 'success')
|
||||||
|
setShowResetPassword(null)
|
||||||
|
setResetPasswordForm({ newPassword: '', forceChange: true })
|
||||||
|
fetchMembers()
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : 'Failed to reset password', 'error')
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveMember = async () => {
|
||||||
|
if (!showRemove) return
|
||||||
|
setActionLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/workspaces/${currentWorkspace.id}/members/${showRemove.id}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error)
|
||||||
|
|
||||||
|
showToast('Member removed', 'success')
|
||||||
|
setShowRemove(null)
|
||||||
|
fetchMembers()
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : 'Failed to remove member', 'error')
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'OWNER':
|
||||||
|
return 'bg-purple-100 text-purple-800'
|
||||||
|
case 'EDITOR':
|
||||||
|
return 'bg-blue-100 text-blue-800'
|
||||||
|
default:
|
||||||
|
return 'bg-secondary-100 text-secondary-800'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Manage Members" showBack />
|
||||||
|
<PageContainer className="pt-4">
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader className="w-6 h-6 animate-spin text-secondary-400" />
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Manage Members" showBack />
|
||||||
|
<PageContainer className="pt-4">
|
||||||
|
<Card className="text-center py-8">
|
||||||
|
<p className="text-secondary-500">{error}</p>
|
||||||
|
</Card>
|
||||||
|
</PageContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Manage Members" showBack />
|
||||||
|
<PageContainer className="pt-4 space-y-4">
|
||||||
|
{/* Add user button */}
|
||||||
|
<Button onClick={() => setShowAddUser(true)} className="w-full">
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Members list */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{members.map((member) => {
|
||||||
|
const isCurrentUser = member.user.id === user.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={member.id} padding="none">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-secondary-900">
|
||||||
|
{member.user.name}
|
||||||
|
{isCurrentUser && (
|
||||||
|
<span className="text-secondary-500 font-normal"> (you)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full font-medium ${getRoleBadgeColor(member.role)}`}
|
||||||
|
>
|
||||||
|
{member.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500 mt-0.5">{member.user.email}</p>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-secondary-400">
|
||||||
|
<span>
|
||||||
|
Joined {format(new Date(member.joinedAt), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
{member.user.lastLoginAt && (
|
||||||
|
<span>
|
||||||
|
Last login{' '}
|
||||||
|
{format(new Date(member.user.lastLoginAt), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{member.user.forcePasswordReset && (
|
||||||
|
<div className="flex items-center gap-1 mt-2 text-xs text-amber-600">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
<span>Must change password on next login</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{!isCurrentUser && (
|
||||||
|
<div className="flex gap-2 mt-3 pt-3 border-t border-border">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowEditRole(member)
|
||||||
|
setNewRole(member.role)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5 mr-1" />
|
||||||
|
Role
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowResetPassword(member)
|
||||||
|
setResetPasswordForm({ newPassword: '', forceChange: true })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Key className="w-3.5 h-3.5 mr-1" />
|
||||||
|
Reset Password
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-600 hover:bg-red-50"
|
||||||
|
onClick={() => setShowRemove(member)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{members.length === 0 && (
|
||||||
|
<Card className="text-center py-8">
|
||||||
|
<Users className="w-12 h-12 text-secondary-300 mx-auto mb-3" />
|
||||||
|
<p className="text-secondary-500">No members yet</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
|
||||||
|
{/* Add User Modal */}
|
||||||
|
<Modal isOpen={showAddUser} onClose={() => setShowAddUser(false)} title="Add User">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
value={addUserForm.name}
|
||||||
|
onChange={(e) => setAddUserForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="Enter name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={addUserForm.email}
|
||||||
|
onChange={(e) => setAddUserForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
|
placeholder="Enter email"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Temporary Password"
|
||||||
|
type="text"
|
||||||
|
value={addUserForm.password}
|
||||||
|
onChange={(e) => setAddUserForm((f) => ({ ...f, password: e.target.value }))}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
helperText="User will be required to change this on first login"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 mb-2">Role</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['VIEWER', 'EDITOR', 'OWNER'] as const).map((role) => (
|
||||||
|
<button
|
||||||
|
key={role}
|
||||||
|
onClick={() => setAddUserForm((f) => ({ ...f, role }))}
|
||||||
|
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
|
||||||
|
addUserForm.role === role
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-muted text-secondary-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddUser}
|
||||||
|
fullWidth
|
||||||
|
loading={actionLoading}
|
||||||
|
disabled={!addUserForm.name || !addUserForm.email || addUserForm.password.length < 8}
|
||||||
|
>
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit Role Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!showEditRole}
|
||||||
|
onClose={() => setShowEditRole(null)}
|
||||||
|
title="Change Role"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-secondary-600">
|
||||||
|
Change role for <strong>{showEditRole?.user.name}</strong>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['VIEWER', 'EDITOR', 'OWNER'] as const).map((role) => (
|
||||||
|
<button
|
||||||
|
key={role}
|
||||||
|
onClick={() => setNewRole(role)}
|
||||||
|
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
|
||||||
|
newRole === role
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-muted text-secondary-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-secondary-500">
|
||||||
|
<p><strong>Viewer:</strong> Can view everything but not make changes</p>
|
||||||
|
<p><strong>Editor:</strong> Can add and edit appointments, medications, notes</p>
|
||||||
|
<p><strong>Owner:</strong> Full access including member management</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleUpdateRole} fullWidth loading={actionLoading}>
|
||||||
|
Update Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Reset Password Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!showResetPassword}
|
||||||
|
onClose={() => setShowResetPassword(null)}
|
||||||
|
title="Reset Password"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-secondary-600">
|
||||||
|
Reset password for <strong>{showResetPassword?.user.name}</strong>
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="New Password"
|
||||||
|
type="text"
|
||||||
|
value={resetPasswordForm.newPassword}
|
||||||
|
onChange={(e) =>
|
||||||
|
setResetPasswordForm((f) => ({ ...f, newPassword: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={resetPasswordForm.forceChange}
|
||||||
|
onChange={(e) =>
|
||||||
|
setResetPasswordForm((f) => ({ ...f, forceChange: e.target.checked }))
|
||||||
|
}
|
||||||
|
className="w-4 h-4 rounded border-secondary-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-secondary-700">
|
||||||
|
Require password change on next login
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-amber-600 bg-amber-50 p-3 rounded-lg">
|
||||||
|
This will log the user out of all devices.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleResetPassword}
|
||||||
|
fullWidth
|
||||||
|
loading={actionLoading}
|
||||||
|
disabled={resetPasswordForm.newPassword.length < 8}
|
||||||
|
>
|
||||||
|
Reset Password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Remove Member Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!showRemove}
|
||||||
|
onClose={() => setShowRemove(null)}
|
||||||
|
title="Remove Member"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-secondary-600">
|
||||||
|
Are you sure you want to remove <strong>{showRemove?.user.name}</strong> from
|
||||||
|
this workspace? They will lose access to all data.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="secondary" fullWidth onClick={() => setShowRemove(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
fullWidth
|
||||||
|
loading={actionLoading}
|
||||||
|
onClick={handleRemoveMember}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -288,6 +288,18 @@ export default function SettingsPage() {
|
|||||||
Family Access
|
Family Access
|
||||||
</h2>
|
</h2>
|
||||||
<Card padding="none">
|
<Card padding="none">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/settings/members')}
|
||||||
|
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Users className="w-5 h-5 text-secondary-500" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="font-medium text-secondary-900">Manage Members</p>
|
||||||
|
<p className="text-sm text-secondary-500">View and manage workspace access</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-border">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInvite(true)}
|
onClick={() => setShowInvite(true)}
|
||||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||||
@@ -299,6 +311,7 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
57
src/app/api/auth/change-password/route.ts
Normal file
57
src/app/api/auth/change-password/route.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db/prisma'
|
||||||
|
import { hashPassword, verifyPassword, withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1, 'Current password is required'),
|
||||||
|
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const POST = withAuth(async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const result = changePasswordSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid input', details: result.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPassword, newPassword } = result.data
|
||||||
|
const userId = req.session.user.id
|
||||||
|
|
||||||
|
// Get current user with password hash
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { passwordHash: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const validPassword = await verifyPassword(user.passwordHash, currentPassword)
|
||||||
|
if (!validPassword) {
|
||||||
|
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password and update user
|
||||||
|
const newPasswordHash = await hashPassword(newPassword)
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
passwordHash: newPasswordHash,
|
||||||
|
forcePasswordReset: false, // Clear the forced reset flag
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Change password error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to change password' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -44,6 +44,7 @@ async function handler(req: NextRequest) {
|
|||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
passwordHash: true,
|
passwordHash: true,
|
||||||
|
forcePasswordReset: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,8 +66,14 @@ async function handler(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record successful login
|
// Record successful login and update lastLoginAt
|
||||||
await recordLoginAttempt(email.toLowerCase(), true, ipAddress)
|
await Promise.all([
|
||||||
|
recordLoginAttempt(email.toLowerCase(), true, ipAddress),
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
const userAgent = req.headers.get('user-agent') || undefined
|
const userAgent = req.headers.get('user-agent') || undefined
|
||||||
@@ -79,6 +86,7 @@ async function handler(req: NextRequest) {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
},
|
},
|
||||||
|
forcePasswordReset: user.forcePasswordReset,
|
||||||
})
|
})
|
||||||
|
|
||||||
response.cookies.set(cookieConfig)
|
response.cookies.set(cookieConfig)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db/prisma'
|
||||||
|
import { withAuth, type AuthenticatedRequest, hashPassword } from '@/lib/auth'
|
||||||
|
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const resetPasswordSchema = z.object({
|
||||||
|
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
forceChange: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/workspaces/[id]/members/[memberId]/reset-password - Reset user password
|
||||||
|
export const POST = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, memberId } = await params
|
||||||
|
|
||||||
|
// Check access (must be owner)
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access || access.role !== 'OWNER') {
|
||||||
|
return NextResponse.json({ error: 'Only owners can reset passwords' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = resetPasswordSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid input', details: result.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { newPassword, forceChange } = result.data
|
||||||
|
|
||||||
|
// Get the member
|
||||||
|
const member = await prisma.workspaceMember.findFirst({
|
||||||
|
where: { id: memberId, workspaceId },
|
||||||
|
include: { user: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password and update user
|
||||||
|
const passwordHash = await hashPassword(newPassword)
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: member.userId },
|
||||||
|
data: {
|
||||||
|
passwordHash,
|
||||||
|
forcePasswordReset: forceChange,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Invalidate all existing sessions for this user
|
||||||
|
await prisma.session.deleteMany({
|
||||||
|
where: { userId: member.userId },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: forceChange
|
||||||
|
? 'Password reset. User must change password on next login.'
|
||||||
|
: 'Password reset successfully.',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reset password error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
161
src/app/api/workspaces/[id]/members/[memberId]/route.ts
Normal file
161
src/app/api/workspaces/[id]/members/[memberId]/route.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db/prisma'
|
||||||
|
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||||
|
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// GET /api/workspaces/[id]/members/[memberId] - Get member details
|
||||||
|
export const GET = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, memberId } = await params
|
||||||
|
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await prisma.workspaceMember.findFirst({
|
||||||
|
where: { id: memberId, workspaceId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
forcePasswordReset: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ member })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get member error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to get member' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMemberSchema = z.object({
|
||||||
|
role: z.enum(['OWNER', 'EDITOR', 'VIEWER']).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// PATCH /api/workspaces/[id]/members/[memberId] - Update member role
|
||||||
|
export const PATCH = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, memberId } = await params
|
||||||
|
|
||||||
|
// Check access (must be owner)
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access || access.role !== 'OWNER') {
|
||||||
|
return NextResponse.json({ error: 'Only owners can update members' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = updateMemberSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid input', details: result.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { role } = result.data
|
||||||
|
|
||||||
|
// Get the member
|
||||||
|
const member = await prisma.workspaceMember.findFirst({
|
||||||
|
where: { id: memberId, workspaceId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent changing own role
|
||||||
|
if (member.userId === req.session.user.id) {
|
||||||
|
return NextResponse.json({ error: 'Cannot change your own role' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update member
|
||||||
|
const updatedMember = await prisma.workspaceMember.update({
|
||||||
|
where: { id: memberId },
|
||||||
|
data: { role },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
forcePasswordReset: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
member: {
|
||||||
|
id: updatedMember.id,
|
||||||
|
role: updatedMember.role,
|
||||||
|
joinedAt: updatedMember.createdAt,
|
||||||
|
user: updatedMember.user,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update member error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to update member' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/workspaces/[id]/members/[memberId] - Remove member from workspace
|
||||||
|
export const DELETE = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId, memberId } = await params
|
||||||
|
|
||||||
|
// Check access (must be owner)
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access || access.role !== 'OWNER') {
|
||||||
|
return NextResponse.json({ error: 'Only owners can remove members' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the member
|
||||||
|
const member = await prisma.workspaceMember.findFirst({
|
||||||
|
where: { id: memberId, workspaceId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent removing self
|
||||||
|
if (member.userId === req.session.user.id) {
|
||||||
|
return NextResponse.json({ error: 'Cannot remove yourself from workspace' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete member
|
||||||
|
await prisma.workspaceMember.delete({
|
||||||
|
where: { id: memberId },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Remove member error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
197
src/app/api/workspaces/[id]/members/route.ts
Normal file
197
src/app/api/workspaces/[id]/members/route.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db/prisma'
|
||||||
|
import { withAuth, type AuthenticatedRequest, hashPassword } from '@/lib/auth'
|
||||||
|
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// GET /api/workspaces/[id]/members - List all members
|
||||||
|
export const GET = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
|
||||||
|
// Check access (must be at least a member)
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await prisma.workspaceMember.findMany({
|
||||||
|
where: { workspaceId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
forcePasswordReset: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
members: members.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
role: m.role,
|
||||||
|
joinedAt: m.createdAt,
|
||||||
|
user: m.user,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('List members error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to list members' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const createUserSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
email: z.string().email('Invalid email'),
|
||||||
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
role: z.enum(['OWNER', 'EDITOR', 'VIEWER']).default('VIEWER'),
|
||||||
|
forcePasswordReset: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/workspaces/[id]/members - Create a new user and add to workspace
|
||||||
|
export const POST = withAuth(async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
{ params }: { params: Promise<Record<string, string>> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id: workspaceId } = await params
|
||||||
|
|
||||||
|
// Check access (must be owner)
|
||||||
|
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||||
|
if (!access || access.role !== 'OWNER') {
|
||||||
|
return NextResponse.json({ error: 'Only owners can create users' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = createUserSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid input', details: result.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, email, password, role, forcePasswordReset } = result.data
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: email.toLowerCase() },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
// Check if already a member
|
||||||
|
const existingMember = await prisma.workspaceMember.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId: existingUser.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingMember) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'User is already a member of this workspace' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add existing user to workspace
|
||||||
|
const member = await prisma.workspaceMember.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
userId: existingUser.id,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
forcePasswordReset: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
member: {
|
||||||
|
id: member.id,
|
||||||
|
role: member.role,
|
||||||
|
joinedAt: member.createdAt,
|
||||||
|
user: member.user,
|
||||||
|
},
|
||||||
|
message: 'Existing user added to workspace',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new user and add to workspace
|
||||||
|
const passwordHash = await hashPassword(password)
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
passwordHash,
|
||||||
|
forcePasswordReset,
|
||||||
|
workspaceMembers: {
|
||||||
|
create: {
|
||||||
|
workspaceId,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
forcePasswordReset: true,
|
||||||
|
createdAt: true,
|
||||||
|
workspaceMembers: {
|
||||||
|
where: { workspaceId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const member = user.workspaceMembers[0]
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
member: {
|
||||||
|
id: member.id,
|
||||||
|
role: member.role,
|
||||||
|
joinedAt: member.createdAt,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
forcePasswordReset: user.forcePasswordReset,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: 'User created and added to workspace',
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create user error:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to create user' }, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
111
src/app/change-password/page.tsx
Normal file
111
src/app/change-password/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Heart } from 'lucide-react'
|
||||||
|
import { Button, Input, Card, showToast } from '@/components/ui'
|
||||||
|
|
||||||
|
export default function ChangePasswordPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('New passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError('New password must be at least 8 characters')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(data.error || 'Failed to change password')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Password changed successfully!', 'success')
|
||||||
|
router.push('/today')
|
||||||
|
router.refresh()
|
||||||
|
} catch {
|
||||||
|
setError('Something went wrong. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Heart className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900">Change Password</h1>
|
||||||
|
<p className="text-secondary-500 mt-1">Please set a new password to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Current Password"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
placeholder="Enter current password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Confirm New Password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" fullWidth loading={loading}>
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,14 @@ function LoginForm() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user needs to change password
|
||||||
|
if (data.forcePasswordReset) {
|
||||||
|
showToast('Please change your password to continue', 'info')
|
||||||
|
router.push('/change-password')
|
||||||
|
router.refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
showToast('Welcome back!', 'success')
|
showToast('Welcome back!', 'success')
|
||||||
// If there's a redirect param (e.g., from invite link), go there
|
// If there's a redirect param (e.g., from invite link), go there
|
||||||
router.push(redirectTo || '/today')
|
router.push(redirectTo || '/today')
|
||||||
|
|||||||
Reference in New Issue
Block a user