1 Commits

Author SHA1 Message Date
dependabot[bot]
5c6fa5c023 Bump esbuild in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [esbuild](https://github.com/evanw/esbuild).


Updates `esbuild` from 0.21.5 to 0.27.2
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.27.2)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.27.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-23 09:45:18 +00:00
19 changed files with 383 additions and 2171 deletions

View File

@@ -23,10 +23,6 @@ RUN npx prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Build args for NEXT_PUBLIC_* variables (needed at build time)
ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY
ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY}
RUN npm run build
# Stage 3: Runner (using slim Debian for better OpenSSL compatibility)

View File

@@ -3,8 +3,6 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=BEFs_VtoxY7SpNnd-ubz1ioliESlRI4sY6ny7Qp3rm7V1cm0gqyZX8TAHp4AaQ81yKC4LfWtJFQz_aHc25G-Tww
container_name: nextstep-app
restart: unless-stopped
ports:
@@ -15,10 +13,6 @@ services:
- NEXT_PUBLIC_APP_URL=https://debianvm.kangaroo-eel.ts.net:10000
- TZ=Australia/Perth
- NODE_ENV=production
# Push notification VAPID keys
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=BEFs_VtoxY7SpNnd-ubz1ioliESlRI4sY6ny7Qp3rm7V1cm0gqyZX8TAHp4AaQ81yKC4LfWtJFQz_aHc25G-Tww
- VAPID_PRIVATE_KEY=KgVQVO7XhfCklrJ3o9wowzK90AxI6Exg9pXPq76Qx4A
- VAPID_EMAIL=mailto:admin@nextstep.local
depends_on:
db:
condition: service_healthy

1004
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@
"tailwindcss": "^3.4.17",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^2.1.8"
"vitest": "^4.0.18"
},
"engines": {
"node": ">=18.0.0"

View File

@@ -12,14 +12,12 @@ datasource db {
// ============================================
model User {
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)
id String @id @default(cuid())
email String @unique
passwordHash String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
sessions Session[]

View File

@@ -1,16 +1,66 @@
// NextStep Service Worker for Push Notifications
// Install event - activate immediately
const CACHE_NAME = 'nextstep-v1'
// Install event - cache critical assets
self.addEventListener('install', (event) => {
console.log('Service Worker: Installing...')
// Skip waiting to activate immediately
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/',
'/today',
'/meds',
'/icon-192.png',
'/icon-512.png',
])
})
)
self.skipWaiting()
})
// Activate event - claim clients immediately
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('Service Worker: Activating...')
event.waitUntil(self.clients.claim())
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
})
)
self.clients.claim()
})
// Fetch event - serve from cache when offline
self.addEventListener('fetch', (event) => {
// Only cache GET requests
if (event.request.method !== 'GET') return
event.respondWith(
caches.match(event.request).then((cached) => {
// Return cached version or fetch from network
return (
cached ||
fetch(event.request).then((response) => {
// Don't cache API responses
if (event.request.url.includes('/api/')) {
return response
}
// Cache successful responses
if (response.status === 200) {
const clone = response.clone()
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone)
})
}
return response
})
)
})
)
})
// Push event - handle incoming push notifications
@@ -20,6 +70,8 @@ self.addEventListener('push', (event) => {
let data = {
title: 'Medication Reminder',
body: 'Time to take your medication',
icon: '/icon-192.png',
badge: '/badge-72.png',
tag: 'medication-reminder',
data: {
url: '/meds',
@@ -36,9 +88,15 @@ self.addEventListener('push', (event) => {
const options = {
body: data.body,
icon: data.icon || '/icon-192.png',
badge: data.badge || '/badge-72.png',
tag: data.tag || 'default',
vibrate: [100, 50, 100],
data: data.data || {},
actions: data.actions || [
{ action: 'take', title: 'Taken' },
{ action: 'snooze', title: 'Snooze' },
],
requireInteraction: true,
}

View File

@@ -1,215 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { format, parseISO } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { Button, Input, Textarea, Card, LoadingState, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../../provider'
const TIMEZONE = 'Australia/Perth'
interface Appointment {
id: string
title: string
datetime: string
location: string | null
mapUrl: string | null
notes: string | null
}
export default function EditAppointmentPage() {
const router = useRouter()
const params = useParams()
const appointmentId = params.id as string
const { currentWorkspace, refreshData } = useApp()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [title, setTitle] = useState('')
const [date, setDate] = useState('')
const [time, setTime] = useState('')
const [location, setLocation] = useState('')
const [mapUrl, setMapUrl] = useState('')
const [notes, setNotes] = useState('')
useEffect(() => {
async function fetchAppointment() {
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`
)
if (response.ok) {
const data = await response.json()
const appt: Appointment = data.appointment
// Parse datetime and convert to local timezone
const apptDate = toZonedTime(parseISO(appt.datetime), TIMEZONE)
setTitle(appt.title)
setDate(format(apptDate, 'yyyy-MM-dd'))
setTime(format(apptDate, 'HH:mm'))
setLocation(appt.location || '')
setMapUrl(appt.mapUrl || '')
setNotes(appt.notes || '')
} else {
setError('Appointment not found')
}
} catch (err) {
console.error('Failed to fetch appointment:', err)
setError('Failed to load appointment')
} finally {
setLoading(false)
}
}
fetchAppointment()
}, [currentWorkspace.id, appointmentId])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSaving(true)
try {
// Combine date and time
const datetime = new Date(`${date}T${time}:00`)
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
datetime: datetime.toISOString(),
location: location || null,
mapUrl: mapUrl || null,
notes: notes || null,
}),
}
)
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to update appointment')
}
await refreshData()
showToast('Appointment updated', 'success')
router.push(`/appointments/${appointmentId}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<>
<Header title="Edit Appointment" showBack />
<PageContainer>
<LoadingState message="Loading appointment..." />
</PageContainer>
</>
)
}
if (error && !title) {
return (
<>
<Header title="Edit Appointment" showBack />
<PageContainer className="pt-4">
<Card className="text-center py-8">
<p className="text-secondary-500">{error}</p>
</Card>
</PageContainer>
</>
)
}
return (
<>
<Header title="Edit Appointment" showBack backHref={`/appointments/${appointmentId}`} />
<PageContainer className="pt-4">
<Card>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Oncology Appointment"
required
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
<Input
label="Time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
required
/>
</div>
<Input
label="Location"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g., Level 3, Cancer Centre"
/>
<Input
label="Map Link (optional)"
type="url"
value={mapUrl}
onChange={(e) => setMapUrl(e.target.value)}
placeholder="https://maps.google.com/..."
helperText="Paste a Google Maps or Apple Maps link"
/>
<Textarea
label="Notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any notes for this appointment..."
rows={3}
/>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
fullWidth
onClick={() => router.back()}
>
Cancel
</Button>
<Button type="submit" fullWidth loading={saving}>
Save Changes
</Button>
</div>
</form>
</Card>
</PageContainer>
</>
)
}

View File

@@ -1,493 +0,0 @@
'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>
</>
)
}

View File

@@ -289,29 +289,16 @@ export default function SettingsPage() {
</h2>
<Card padding="none">
<button
onClick={() => router.push('/settings/members')}
onClick={() => setShowInvite(true)}
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>
<p className="font-medium text-secondary-900">Invite Family Member</p>
<p className="text-sm text-secondary-500">Share access to this workspace</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
<div className="border-t border-border">
<button
onClick={() => setShowInvite(true)}
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">Invite Family Member</p>
<p className="text-sm text-secondary-500">Share access to this workspace</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</div>
</Card>
</section>
)}

View File

@@ -1,57 +0,0 @@
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 })
}
})

View File

@@ -44,7 +44,6 @@ async function handler(req: NextRequest) {
email: true,
name: true,
passwordHash: true,
forcePasswordReset: true,
},
})
@@ -66,14 +65,8 @@ async function handler(req: NextRequest) {
)
}
// 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() },
}),
])
// Record successful login
await recordLoginAttempt(email.toLowerCase(), true, ipAddress)
// Create session
const userAgent = req.headers.get('user-agent') || undefined
@@ -86,7 +79,6 @@ async function handler(req: NextRequest) {
email: user.email,
name: user.name,
},
forcePasswordReset: user.forcePasswordReset,
})
response.cookies.set(cookieConfig)

View File

@@ -56,17 +56,11 @@ export async function GET(
membership.workspace.name
)
// Sanitize filename for HTTP headers (remove non-ASCII characters)
const safeFilename = membership.workspace.name
.replace(/[^\x00-\x7F]/g, '') // Remove non-ASCII
.replace(/[<>:"/\\|?*]/g, '-') // Replace invalid filename chars
.trim() || 'appointments'
return new NextResponse(icalContent, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': `attachment; filename="${safeFilename}-appointments.ics"`,
'Content-Disposition': `attachment; filename="${membership.workspace.name}-appointments.ics"`,
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
})

View File

@@ -1,74 +0,0 @@
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 })
}
})

View File

@@ -1,161 +0,0 @@
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 })
}
})

View File

@@ -1,197 +0,0 @@
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 })
}
})

View File

@@ -1,111 +0,0 @@
'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>
)
}

View File

@@ -34,14 +34,6 @@ 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')

View File

@@ -31,28 +31,15 @@ export function NotificationPermission({ workspaceId }: NotificationPermissionPr
return
}
// Check if PushManager is available (not available in all browsers/contexts)
if (!('PushManager' in window)) {
setPermission('unsupported')
return
}
const perm = Notification.permission as PermissionState
setPermission(perm)
if (perm === 'granted') {
// Check if already subscribed with timeout
// Check if already subscribed
try {
const registrationPromise = navigator.serviceWorker.ready
const timeoutPromise = new Promise<ServiceWorkerRegistration>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 5000)
)
const registration = await Promise.race([registrationPromise, timeoutPromise])
if (registration.pushManager) {
const subscription = await registration.pushManager.getSubscription()
setIsSubscribed(!!subscription)
}
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
setIsSubscribed(!!subscription)
} catch (err) {
console.error('Failed to check subscription:', err)
}
@@ -79,54 +66,14 @@ export function NotificationPermission({ workspaceId }: NotificationPermissionPr
}
const { publicKey } = await keyResponse.json()
// Ensure service worker is registered and active
let registration: ServiceWorkerRegistration
// Register service worker if not already registered
const registration = await navigator.serviceWorker.ready
// First, try to register the service worker (in case it wasn't registered yet)
try {
registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' })
// Wait for it to be active
if (registration.installing || registration.waiting) {
await new Promise<void>((resolve, reject) => {
const sw = registration.installing || registration.waiting
if (!sw) {
resolve()
return
}
const timeout = setTimeout(() => reject(new Error('Service worker activation timeout')), 10000)
sw.addEventListener('statechange', () => {
if (sw.state === 'activated') {
clearTimeout(timeout)
resolve()
} else if (sw.state === 'redundant') {
clearTimeout(timeout)
reject(new Error('Service worker became redundant'))
}
})
})
}
} catch (regError: any) {
console.error('Service worker registration error:', regError)
throw new Error('Failed to register service worker: ' + regError.message)
}
// Check if push manager is available
if (!registration.pushManager) {
throw new Error('Push notifications not supported on this device')
}
// Subscribe to push with timeout
const subscribePromise = registration.pushManager.subscribe({
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
const subscribeTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Push subscription timed out - this device may not support web push')), 15000)
)
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
// Send subscription to server
const response = await fetch('/api/notifications/subscribe', {

View File

@@ -1,7 +1,5 @@
import { format, addHours } from 'date-fns'
const TIMEZONE = 'Australia/Perth'
interface Appointment {
id: string
title: string
@@ -37,19 +35,8 @@ export function generateICalendar(
'VERSION:2.0',
'PRODID:-//NextStep//Health Management//EN',
`X-WR-CALNAME:${escapeICalText(workspaceName)}`,
`X-WR-TIMEZONE:${TIMEZONE}`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
// Add timezone definition for Australia/Perth
'BEGIN:VTIMEZONE',
`TZID:${TIMEZONE}`,
'BEGIN:STANDARD',
'DTSTART:19700101T000000',
'TZOFFSETFROM:+0800',
'TZOFFSETTO:+0800',
'TZNAME:AWST',
'END:STANDARD',
'END:VTIMEZONE',
]
for (const appt of appointments) {
@@ -58,9 +45,9 @@ export function generateICalendar(
lines.push('BEGIN:VEVENT')
lines.push(`UID:${appt.id}@nextstep`)
lines.push(`DTSTAMP:${formatICalDateUTC(new Date())}`)
lines.push(`DTSTART;TZID=${TIMEZONE}:${formatICalDateLocal(startDate)}`)
lines.push(`DTEND;TZID=${TIMEZONE}:${formatICalDateLocal(endDate)}`)
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
lines.push(`DTSTART:${formatICalDate(startDate)}`)
lines.push(`DTEND:${formatICalDate(endDate)}`)
lines.push(`SUMMARY:${escapeICalText(appt.title)}`)
if (appt.location) {
@@ -129,9 +116,9 @@ export function generateMedicationEvents(
lines.push('BEGIN:VEVENT')
lines.push(`UID:med-${med.id}-${dateStr}-${time}@nextstep`)
lines.push(`DTSTAMP:${formatICalDateUTC(new Date())}`)
lines.push(`DTSTART;TZID=${TIMEZONE}:${formatICalDateLocal(startDate)}`)
lines.push(`DTEND;TZID=${TIMEZONE}:${formatICalDateLocal(endDate)}`)
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
lines.push(`DTSTART:${formatICalDate(startDate)}`)
lines.push(`DTEND:${formatICalDate(endDate)}`)
lines.push(`SUMMARY:Take ${escapeICalText(med.name)}`)
lines.push('CATEGORIES:MEDICATION')
@@ -174,16 +161,11 @@ function getMedicationTimes(med: Medication): string[] {
}
}
function formatICalDateUTC(date: Date): string {
// Format: YYYYMMDDTHHMMSSZ (UTC)
function formatICalDate(date: Date): string {
// Format: YYYYMMDDTHHMMSSZ
return format(date, "yyyyMMdd'T'HHmmss'Z'")
}
function formatICalDateLocal(date: Date): string {
// Format: YYYYMMDDTHHMMSS (local time, no Z suffix)
return format(date, "yyyyMMdd'T'HHmmss")
}
function escapeICalText(text: string): string {
return text
.replace(/\\/g, '\\\\')