diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a4a38d3..1db2b63 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,12 +12,14 @@ datasource db { // ============================================ model User { - id String @id @default(cuid()) - email String @unique - passwordHash String - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + email String @unique + passwordHash String + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLoginAt DateTime? + forcePasswordReset Boolean @default(false) // Relations sessions Session[] diff --git a/src/app/(app)/settings/members/page.tsx b/src/app/(app)/settings/members/page.tsx new file mode 100644 index 0000000..1320526 --- /dev/null +++ b/src/app/(app)/settings/members/page.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + // Modals + const [showAddUser, setShowAddUser] = useState(false) + const [showEditRole, setShowEditRole] = useState(null) + const [showResetPassword, setShowResetPassword] = useState(null) + const [showRemove, setShowRemove] = useState(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 ( + <> +
+ +
+ +
+
+ + ) + } + + if (error) { + return ( + <> +
+ + +

{error}

+
+
+ + ) + } + + return ( + <> +
+ + {/* Add user button */} + + + {/* Members list */} +
+ {members.map((member) => { + const isCurrentUser = member.user.id === user.id + + return ( + +
+
+
+
+

+ {member.user.name} + {isCurrentUser && ( + (you) + )} +

+ + {member.role} + +
+

{member.user.email}

+
+ + Joined {format(new Date(member.joinedAt), 'MMM d, yyyy')} + + {member.user.lastLoginAt && ( + + Last login{' '} + {format(new Date(member.user.lastLoginAt), 'MMM d, yyyy')} + + )} +
+ {member.user.forcePasswordReset && ( +
+ + Must change password on next login +
+ )} +
+
+ + {/* Actions */} + {!isCurrentUser && ( +
+ + + +
+ )} +
+
+ ) + })} +
+ + {members.length === 0 && ( + + +

No members yet

+
+ )} +
+ + {/* Add User Modal */} + setShowAddUser(false)} title="Add User"> +
+ setAddUserForm((f) => ({ ...f, name: e.target.value }))} + placeholder="Enter name" + /> + setAddUserForm((f) => ({ ...f, email: e.target.value }))} + placeholder="Enter email" + /> + setAddUserForm((f) => ({ ...f, password: e.target.value }))} + placeholder="At least 8 characters" + helperText="User will be required to change this on first login" + /> +
+ +
+ {(['VIEWER', 'EDITOR', 'OWNER'] as const).map((role) => ( + + ))} +
+
+ +
+
+ + {/* Edit Role Modal */} + setShowEditRole(null)} + title="Change Role" + > +
+

+ Change role for {showEditRole?.user.name} +

+
+ {(['VIEWER', 'EDITOR', 'OWNER'] as const).map((role) => ( + + ))} +
+
+

Viewer: Can view everything but not make changes

+

Editor: Can add and edit appointments, medications, notes

+

Owner: Full access including member management

+
+ +
+
+ + {/* Reset Password Modal */} + setShowResetPassword(null)} + title="Reset Password" + > +
+

+ Reset password for {showResetPassword?.user.name} +

+ + setResetPasswordForm((f) => ({ ...f, newPassword: e.target.value })) + } + placeholder="At least 8 characters" + /> + +

+ This will log the user out of all devices. +

+ +
+
+ + {/* Remove Member Modal */} + setShowRemove(null)} + title="Remove Member" + > +
+

+ Are you sure you want to remove {showRemove?.user.name} from + this workspace? They will lose access to all data. +

+
+ + +
+
+
+ + ) +} diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 2df76ac..9559fcc 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -289,16 +289,29 @@ export default function SettingsPage() { +
+ +
)} diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts new file mode 100644 index 0000000..35a07a5 --- /dev/null +++ b/src/app/api/auth/change-password/route.ts @@ -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 }) + } +}) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 1fe9569..1d8bd45 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -44,6 +44,7 @@ async function handler(req: NextRequest) { email: true, name: true, passwordHash: true, + forcePasswordReset: true, }, }) @@ -65,8 +66,14 @@ async function handler(req: NextRequest) { ) } - // Record successful login - await recordLoginAttempt(email.toLowerCase(), true, ipAddress) + // Record successful login and update lastLoginAt + await Promise.all([ + recordLoginAttempt(email.toLowerCase(), true, ipAddress), + prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }), + ]) // Create session const userAgent = req.headers.get('user-agent') || undefined @@ -79,6 +86,7 @@ async function handler(req: NextRequest) { email: user.email, name: user.name, }, + forcePasswordReset: user.forcePasswordReset, }) response.cookies.set(cookieConfig) diff --git a/src/app/api/workspaces/[id]/members/[memberId]/reset-password/route.ts b/src/app/api/workspaces/[id]/members/[memberId]/reset-password/route.ts new file mode 100644 index 0000000..0777c89 --- /dev/null +++ b/src/app/api/workspaces/[id]/members/[memberId]/reset-password/route.ts @@ -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> } +) => { + 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 }) + } +}) diff --git a/src/app/api/workspaces/[id]/members/[memberId]/route.ts b/src/app/api/workspaces/[id]/members/[memberId]/route.ts new file mode 100644 index 0000000..7a544b2 --- /dev/null +++ b/src/app/api/workspaces/[id]/members/[memberId]/route.ts @@ -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> } +) => { + 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> } +) => { + 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> } +) => { + 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 }) + } +}) diff --git a/src/app/api/workspaces/[id]/members/route.ts b/src/app/api/workspaces/[id]/members/route.ts new file mode 100644 index 0000000..0ef8bfa --- /dev/null +++ b/src/app/api/workspaces/[id]/members/route.ts @@ -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> } +) => { + 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> } +) => { + 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 }) + } +}) diff --git a/src/app/change-password/page.tsx b/src/app/change-password/page.tsx new file mode 100644 index 0000000..e97d6e4 --- /dev/null +++ b/src/app/change-password/page.tsx @@ -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 ( +
+
+
+
+ +
+

Change Password

+

Please set a new password to continue

+
+ + +
+ setCurrentPassword(e.target.value)} + placeholder="Enter current password" + required + autoComplete="current-password" + /> + setNewPassword(e.target.value)} + placeholder="At least 8 characters" + required + autoComplete="new-password" + /> + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + required + autoComplete="new-password" + /> + + {error && ( +

+ {error} +

+ )} + + +
+
+
+
+ ) +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index b1df727..616d95a 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -34,6 +34,14 @@ function LoginForm() { 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') // If there's a redirect param (e.g., from invite link), go there router.push(redirectTo || '/today')