Add SaaS foundation: Auth.js, dashboard shell, org model, auto-assignment

Major architectural upgrade preparing Echo OCR for self-hosted SaaS deployment:

- Auth: Built-in Auth.js v5 with credentials + Authentik OIDC SSO, JWT sessions,
  middleware route protection, login/signup/setup pages, registration API
- UI: Dashboard layout with collapsible sidebar nav, AppShell wrapper, route
  groups for (dashboard) and (auth), new pages for events/people/reports
- Schema: Auth.js tables (Account, Session, VerificationToken), Organization,
  OrgMember, Location, CollectionDay, Invitation, SystemConfig, ApiKey models;
  proper User relations to ResponseCard/ActivityLog/Notification
- Permissions: Role hierarchy (owner/admin/editor/reviewer/viewer) with
  action-based permission map and requirePermission/requireAuth helpers
- Onboarding: Multi-step setup wizard for first-user bootstrap (account, org,
  location) with SystemConfig tracking
- Events: CollectionDay model with rrule support for recurring church services
- Auto-assign: Event-aware card assignment engine replacing getPreviousSunday()
- Migration: seed-migration.ts script for upgrading existing deployments

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-14 23:59:38 -05:00
parent 0ab9932599
commit d3e7374439
44 changed files with 3029 additions and 223 deletions

View file

@ -17,7 +17,18 @@ MINIO_BUCKET="echos-ocr"
# Folder Watch (optional, mount a host path into the container) # Folder Watch (optional, mount a host path into the container)
WATCH_DIR="" WATCH_DIR=""
# Authentik SSO (optional — user info comes from forward-auth headers automatically; # Auth.js (required — generate with: npx auth secret)
# these are only needed to enrich profiles with avatars via the Authentik API) AUTH_SECRET=""
# Authentik OIDC SSO (optional — enables "Sign in with SSO" button)
# Create an OAuth2/OIDC provider in Authentik and set these values.
AUTHENTIK_ISSUER=""
AUTHENTIK_CLIENT_ID=""
AUTHENTIK_CLIENT_SECRET=""
# Legacy Authentik forward-auth (deprecated — will be removed)
AUTHENTIK_URL="https://auth.stillwell.cloud" AUTHENTIK_URL="https://auth.stillwell.cloud"
AUTHENTIK_API_TOKEN="" AUTHENTIK_API_TOKEN=""
# Environment indicator (set to "staging" for staging deployments)
NEXT_PUBLIC_ENV=""

162
package-lock.json generated
View file

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@ai-sdk/gateway": "^3.0.66", "@ai-sdk/gateway": "^3.0.66",
"@ai-sdk/openai-compatible": "^2.0.35", "@ai-sdk/openai-compatible": "^2.0.35",
"@auth/prisma-adapter": "^2.11.2",
"@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0",
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
@ -18,6 +19,7 @@
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"ai": "^6.0.116", "ai": "^6.0.116",
"bcryptjs": "^3.0.3",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -28,6 +30,7 @@
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"mailparser": "^3.9.6", "mailparser": "^3.9.6",
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdf2pic": "^3.2.0", "pdf2pic": "^3.2.0",
@ -35,6 +38,7 @@
"prisma": "^7.4.2", "prisma": "^7.4.2",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"rrule": "^2.8.1",
"shadcn": "^4.0.2", "shadcn": "^4.0.2",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@ -44,6 +48,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/node": "^25.4.0", "@types/node": "^25.4.0",
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
@ -151,6 +156,47 @@
"nup": "bin/nup.mjs" "nup": "bin/nup.mjs"
} }
}, },
"node_modules/@auth/core": {
"version": "0.41.2",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz",
"integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"jose": "^6.0.6",
"oauth4webapi": "^3.3.0",
"preact": "10.24.3",
"preact-render-to-string": "6.5.11"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^7.0.7"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@auth/prisma-adapter": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.2.tgz",
"integrity": "sha512-GyNEUNtrPgDPs0M4xX6F5i7jTsCKwU6BXV9zutctcoo6K1Ud+juckrmQS11uyNgeWsw6sliextHbU/e+8lsizQ==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.41.2"
},
"peerDependencies": {
"@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6"
}
},
"node_modules/@aws-crypto/crc32": { "node_modules/@aws-crypto/crc32": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
@ -2995,6 +3041,15 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@pdf-lib/standard-fonts": { "node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
@ -4829,6 +4884,13 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -5968,6 +6030,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@ -8770,6 +8841,15 @@
"socks": "2.8.7" "socks": "2.8.7"
} }
}, },
"node_modules/imapflow/node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -10102,6 +10182,15 @@
"tlds": "1.261.0" "tlds": "1.261.0"
} }
}, },
"node_modules/mailparser/node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -10435,6 +10524,33 @@
} }
} }
}, },
"node_modules/next-auth": {
"version": "5.0.0-beta.31",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz",
"integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.41.2"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
"nodemailer": "^7.0.7",
"react": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next-themes": { "node_modules/next-themes": {
"version": "0.4.6", "version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
@ -10542,15 +10658,6 @@
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/npm-run-path": { "node_modules/npm-run-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
@ -10602,6 +10709,15 @@
"integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/oauth4webapi": {
"version": "3.8.5",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
"integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -11369,6 +11485,25 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
"license": "MIT",
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -11942,6 +12077,15 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/rrule": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/run-applescript": { "node_modules/run-applescript": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",

View file

@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@ai-sdk/gateway": "^3.0.66", "@ai-sdk/gateway": "^3.0.66",
"@ai-sdk/openai-compatible": "^2.0.35", "@ai-sdk/openai-compatible": "^2.0.35",
"@auth/prisma-adapter": "^2.11.2",
"@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0",
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
@ -22,6 +23,7 @@
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"ai": "^6.0.116", "ai": "^6.0.116",
"bcryptjs": "^3.0.3",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -32,6 +34,7 @@
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"mailparser": "^3.9.6", "mailparser": "^3.9.6",
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdf2pic": "^3.2.0", "pdf2pic": "^3.2.0",
@ -39,6 +42,7 @@
"prisma": "^7.4.2", "prisma": "^7.4.2",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"rrule": "^2.8.1",
"shadcn": "^4.0.2", "shadcn": "^4.0.2",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@ -48,6 +52,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/node": "^25.4.0", "@types/node": "^25.4.0",
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",

View file

@ -7,24 +7,162 @@ datasource db {
provider = "postgresql" provider = "postgresql"
} }
// ─── Auth.js tables ──────────────────────────────────────────
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
authentikUid String @unique email String @unique
username String emailVerified DateTime?
displayName String hashedPassword String?
email String username String?
avatarUrl String @default("") displayName String?
role String @default("viewer") avatarUrl String @default("")
createdAt DateTime @default(now()) role String @default("viewer")
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
memberships OrgMember[]
assignedCards ResponseCard[] @relation("AssignedCards")
assignorCards ResponseCard[] @relation("AssignorCards")
reviewedCards ResponseCard[] @relation("ReviewedCards")
activityLogs ActivityLog[]
notifications Notification[]
authentikUid String? @unique
} }
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// ─── Organization / Multi-tenancy ────────────────────────────
model Organization {
id String @id @default(cuid())
name String
slug String @unique
type String @default("church")
timezone String @default("America/Chicago")
settings Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
locations Location[]
members OrgMember[]
invitations Invitation[]
apiKeys ApiKey[]
cards ResponseCard[]
jobs ProcessingJob[]
}
model OrgMember {
id String @id @default(cuid())
userId String
organizationId String
role String @default("viewer")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, organizationId])
}
model Location {
id String @id @default(cuid())
organizationId String
name String
address String?
timezone String?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
collectionDays CollectionDay[]
cards ResponseCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CollectionDay {
id String @id @default(cuid())
locationId String
name String
description String?
rrule String?
dayOfWeek Int?
timeStart String?
timeEnd String?
isRecurring Boolean @default(true)
date DateTime?
isActive Boolean @default(true)
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
cards ResponseCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ─── Invitations ─────────────────────────────────────────────
model Invitation {
id String @id @default(cuid())
email String
role String @default("viewer")
organizationId String
invitedById String
token String @unique @default(cuid())
expiresAt DateTime
acceptedAt DateTime?
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id])
}
// ─── System config ───────────────────────────────────────────
model SystemConfig {
id String @id @default("singleton")
isSetupComplete Boolean @default(false)
setupStep Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ─── Core application models ─────────────────────────────────
model ResponseCard { model ResponseCard {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Personal Info (Response Card side)
name String? name String?
gender String? gender String?
dateOfBirth String? dateOfBirth String?
@ -43,7 +181,6 @@ model ResponseCard {
prayerForTeam Boolean @default(false) prayerForTeam Boolean @default(false)
prayerConfidential Boolean @default(false) prayerConfidential Boolean @default(false)
// Survey Info (Easter Survey side)
messageTopics Json? messageTopics Json?
messageTopicsOther String? messageTopicsOther String?
nextStep Json? nextStep Json?
@ -54,25 +191,26 @@ model ResponseCard {
howHeardOther String? howHeardOther String?
serviceAttended String? serviceAttended String?
// Monday.com / Workflow fields followUp String?
followUp String? notes String?
notes String? serviceTime String?
serviceTime String? planningCenter String?
planningCenter String? iSaidYesBookSent Boolean @default(false)
iSaidYesBookSent Boolean @default(false) ftGuestLetterSent Boolean @default(false)
ftGuestLetterSent Boolean @default(false) firstTimeGuestDate DateTime?
firstTimeGuestDate DateTime? salvationDate DateTime?
salvationDate DateTime?
// Assignment / Review workflow assignedToId String?
assignedToId String? assignedById String?
assignedById String? assignedAt DateTime?
assignedAt DateTime? reviewedById String?
reviewedById String? reviewedAt DateTime?
reviewedAt DateTime? reviewNotes String?
reviewNotes String?
assignedTo User? @relation("AssignedCards", fields: [assignedToId], references: [id])
assignedBy User? @relation("AssignorCards", fields: [assignedById], references: [id])
reviewedBy User? @relation("ReviewedCards", fields: [reviewedById], references: [id])
// Meta
sourceFile String? sourceFile String?
frontImagePath String? frontImagePath String?
backImagePath String? backImagePath String?
@ -84,6 +222,15 @@ model ResponseCard {
mondayItemId String? mondayItemId String?
organizationId String?
locationId String?
collectionDayId String?
collectionDate DateTime?
organization Organization? @relation(fields: [organizationId], references: [id])
location Location? @relation(fields: [locationId], references: [id])
collectionDay CollectionDay? @relation(fields: [collectionDayId], references: [id])
@@index([ocrStatus]) @@index([ocrStatus])
@@index([reviewStatus]) @@index([reviewStatus])
@@index([name]) @@index([name])
@ -91,19 +238,25 @@ model ResponseCard {
@@index([mondayItemId]) @@index([mondayItemId])
@@index([assignedToId]) @@index([assignedToId])
@@index([reviewedById]) @@index([reviewedById])
@@index([organizationId])
@@index([locationId])
@@index([collectionDayId])
} }
model ProcessingJob { model ProcessingJob {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
fileName String fileName String
filePath String filePath String
status String @default("queued") status String @default("queued")
totalPages Int @default(0) totalPages Int @default(0)
processed Int @default(0) processed Int @default(0)
error String? error String?
cardIds Json? cardIds Json?
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id])
@@index([status]) @@index([status])
} }
@ -147,13 +300,15 @@ model AppSettings {
model ActivityLog { model ActivityLog {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
cardId String cardId String?
action String action String
source String source String
summary String summary String
changes Json? changes Json?
userId String? userId String?
user User? @relation(fields: [userId], references: [id])
@@index([cardId, createdAt]) @@index([cardId, createdAt])
} }
@ -170,7 +325,22 @@ model Notification {
meta Json? meta Json?
userId String? userId String?
user User? @relation(fields: [userId], references: [id])
@@index([read, dismissed, createdAt]) @@index([read, dismissed, createdAt])
@@index([cardId]) @@index([cardId])
@@index([userId]) @@index([userId])
} }
model ApiKey {
id String @id @default(cuid())
name String
hashedKey String @unique
prefix String
organizationId String
permissions Json
lastUsedAt DateTime?
expiresAt DateTime?
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id])
}

180
prisma/seed-migration.ts Normal file
View file

@ -0,0 +1,180 @@
/**
* Data migration script for existing deployments upgrading to v2.
*
* Run with: npx tsx prisma/seed-migration.ts
*
* This script:
* 1. Creates a SystemConfig singleton (setup complete)
* 2. Creates a default Organization from existing data
* 3. Creates a default Location
* 4. Creates a default "Sunday Service" CollectionDay
* 5. Migrates existing Users into OrgMembers
* 6. Backfills organizationId and locationId on ResponseCards
* 7. Backfills collectionDayId using existing dates
*/
import "dotenv/config";
import { PrismaClient } from "../src/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
console.log("Starting v2 data migration...\n");
// 1. SystemConfig
const existingConfig = await prisma.systemConfig.findUnique({
where: { id: "singleton" },
});
if (existingConfig?.isSetupComplete) {
console.log("Setup already marked complete. Skipping migration.");
return;
}
await prisma.systemConfig.upsert({
where: { id: "singleton" },
update: { isSetupComplete: true, setupStep: 3 },
create: { id: "singleton", isSetupComplete: true, setupStep: 3 },
});
console.log("[1/7] SystemConfig created (setup complete)");
// 2. Default Organization
let org = await prisma.organization.findFirst();
if (!org) {
org = await prisma.organization.create({
data: {
name: "My Church",
slug: "my-church",
type: "church",
timezone: "America/Chicago",
},
});
console.log(`[2/7] Created default organization: ${org.name} (${org.id})`);
} else {
console.log(`[2/7] Organization already exists: ${org.name}`);
}
// 3. Default Location
let location = await prisma.location.findFirst({
where: { organizationId: org.id },
});
if (!location) {
location = await prisma.location.create({
data: {
name: "Main Campus",
organizationId: org.id,
},
});
console.log(`[3/7] Created default location: ${location.name} (${location.id})`);
} else {
console.log(`[3/7] Location already exists: ${location.name}`);
}
// 4. Default CollectionDay
let collectionDay = await prisma.collectionDay.findFirst({
where: { locationId: location.id },
});
if (!collectionDay) {
collectionDay = await prisma.collectionDay.create({
data: {
locationId: location.id,
name: "Sunday Service",
dayOfWeek: 0,
timeStart: "09:00",
timeEnd: "12:00",
isRecurring: true,
},
});
console.log(`[4/7] Created default collection day: ${collectionDay.name}`);
} else {
console.log(`[4/7] Collection day already exists: ${collectionDay.name}`);
}
// 5. Migrate Users to OrgMembers
const users = await prisma.user.findMany();
let memberCount = 0;
for (const user of users) {
const existing = await prisma.orgMember.findUnique({
where: {
userId_organizationId: {
userId: user.id,
organizationId: org.id,
},
},
});
if (!existing) {
await prisma.orgMember.create({
data: {
userId: user.id,
organizationId: org.id,
role: user.role || "viewer",
},
});
memberCount++;
}
}
console.log(`[5/7] Migrated ${memberCount} users to org members (${users.length} total users)`);
// 6. Backfill ResponseCard.organizationId and locationId
const cardResult = await prisma.responseCard.updateMany({
where: { organizationId: null },
data: {
organizationId: org.id,
locationId: location.id,
},
});
console.log(`[6/7] Backfilled ${cardResult.count} cards with org/location`);
// 7. Backfill collectionDayId
const cardsWithDates = await prisma.responseCard.findMany({
where: {
collectionDayId: null,
OR: [
{ firstTimeGuestDate: { not: null } },
{ createdAt: { not: undefined } },
],
},
select: { id: true, firstTimeGuestDate: true, createdAt: true },
});
let assignedCount = 0;
for (const card of cardsWithDates) {
const refDate = card.firstTimeGuestDate || card.createdAt;
const sunday = new Date(refDate);
const day = sunday.getDay();
if (day !== 0) sunday.setDate(sunday.getDate() - day);
sunday.setHours(0, 0, 0, 0);
await prisma.responseCard.update({
where: { id: card.id },
data: {
collectionDayId: collectionDay.id,
collectionDate: sunday,
},
});
assignedCount++;
}
console.log(`[7/7] Assigned ${assignedCount} cards to default collection day`);
// 8. Backfill ProcessingJob.organizationId
await prisma.processingJob.updateMany({
where: { organizationId: null },
data: { organizationId: org.id },
});
console.log("\nMigration complete!");
}
main()
.catch((e) => {
console.error("Migration failed:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});

View file

@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { ScanLine, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function InvitePage() {
const params = useParams();
const router = useRouter();
const token = params.token as string;
const [status, setStatus] = useState<"loading" | "valid" | "invalid">("loading");
const [invitation, setInvitation] = useState<{ email: string; role: string } | null>(null);
useEffect(() => {
fetch(`/api/invitations/verify?token=${token}`)
.then((r) => r.json())
.then((data) => {
if (data.valid) {
setInvitation({ email: data.email, role: data.role });
setStatus("valid");
} else {
setStatus("invalid");
}
})
.catch(() => setStatus("invalid"));
}, [token]);
if (status === "loading") {
return (
<div className="glass-card flex flex-col items-center rounded-2xl p-8">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
<p className="mt-4 text-sm text-muted-foreground">Verifying invitation...</p>
</div>
);
}
if (status === "invalid") {
return (
<div className="glass-card flex flex-col items-center rounded-2xl p-8 text-center">
<XCircle className="mb-4 size-12 text-destructive" />
<h1 className="text-xl font-bold">Invalid Invitation</h1>
<p className="mt-2 text-sm text-muted-foreground">
This invitation link is invalid, expired, or has already been used.
</p>
<Button
variant="outline"
className="mt-6 rounded-xl"
onClick={() => router.push("/login")}
>
Go to Login
</Button>
</div>
);
}
return (
<div className="glass-card flex flex-col items-center rounded-2xl p-8 text-center">
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
<ScanLine className="size-7 text-white" />
</div>
<CheckCircle2 className="mb-2 size-8 text-emerald-500" />
<h1 className="text-xl font-bold">You&apos;re Invited</h1>
<p className="mt-2 text-sm text-muted-foreground">
You&apos;ve been invited to join as a <strong>{invitation?.role}</strong>.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Invitation for: {invitation?.email}
</p>
<Link href={`/signup?token=${token}`}>
<Button className="mt-6 rounded-xl">Accept &amp; Create Account</Button>
</Link>
</div>
);
}

12
src/app/(auth)/layout.tsx Normal file
View file

@ -0,0 +1,12 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="relative flex min-h-screen items-center justify-center">
<div className="gradient-mesh pointer-events-none fixed inset-0 z-0" />
<div className="relative z-10 w-full max-w-md p-4">{children}</div>
</div>
);
}

View file

@ -0,0 +1,142 @@
"use client";
import { Suspense, useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { ScanLine, Mail, Lock, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/";
const error = searchParams.get("error");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [formError, setFormError] = useState("");
const hasSSO = !!process.env.NEXT_PUBLIC_SSO_ENABLED;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setFormError("");
const result = await signIn("credentials", {
email,
password,
redirect: false,
callbackUrl,
});
if (result?.error) {
setFormError("Invalid email or password");
setLoading(false);
} else {
router.push(callbackUrl);
router.refresh();
}
}
return (
<div className="glass-card w-full rounded-2xl p-8">
<div className="mb-8 flex flex-col items-center">
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
<ScanLine className="size-7 text-white" />
</div>
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
<p className="mt-1 text-sm text-muted-foreground">
Sign in to your Echo OCR account
</p>
</div>
{(error || formError) && (
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-center text-sm text-destructive">
{formError || "Authentication failed. Please try again."}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@church.org"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="pl-10"
/>
</div>
</div>
<Button type="submit" className="w-full rounded-xl" disabled={loading}>
{loading ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
Sign In
</Button>
</form>
{hasSSO && (
<>
<div className="my-6 flex items-center gap-4">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground">or</span>
<Separator className="flex-1" />
</div>
<Button
variant="outline"
className="w-full rounded-xl"
onClick={() => signIn("authentik", { callbackUrl })}
>
Sign in with SSO
</Button>
</>
)}
<p className="mt-6 text-center text-xs text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/signup" className="font-medium text-primary hover:underline">
Sign up with an invite
</Link>
</p>
</div>
);
}

View file

@ -0,0 +1,298 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import {
ScanLine,
User,
Building2,
MapPin,
Loader2,
ArrowRight,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const STEPS = [
{ label: "Account", icon: User },
{ label: "Organization", icon: Building2 },
{ label: "Location", icon: MapPin },
];
export default function SetupPage() {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [orgId, setOrgId] = useState("");
const [account, setAccount] = useState({ displayName: "", email: "", password: "", confirmPassword: "" });
const [org, setOrg] = useState({ name: "", type: "church", timezone: "America/Chicago" });
const [location, setLocation] = useState({ name: "", address: "" });
useEffect(() => {
fetch("/api/setup")
.then((r) => r.json())
.then((data) => {
if (data.isSetupComplete) {
router.replace("/login");
return;
}
setCurrentStep(data.setupStep || 0);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [router]);
async function submitStep(step: number, data: Record<string, unknown>) {
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step, data }),
});
const result = await res.json();
if (!res.ok) {
setError(result.error || "Something went wrong");
setSubmitting(false);
return;
}
if (result.orgId) setOrgId(result.orgId);
if (result.complete) {
await signIn("credentials", {
email: account.email,
password: account.password,
callbackUrl: "/",
});
return;
}
setCurrentStep(result.nextStep);
} catch {
setError("Something went wrong");
} finally {
setSubmitting(false);
}
}
function handleAccountSubmit(e: React.FormEvent) {
e.preventDefault();
if (account.password !== account.confirmPassword) {
setError("Passwords do not match");
return;
}
if (account.password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
submitStep(1, account);
}
function handleOrgSubmit(e: React.FormEvent) {
e.preventDefault();
submitStep(2, org);
}
function handleLocationSubmit(e: React.FormEvent) {
e.preventDefault();
submitStep(3, { ...location, organizationId: orgId });
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="glass-card w-full max-w-lg rounded-2xl p-8">
<div className="mb-8 flex flex-col items-center">
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
<ScanLine className="size-7 text-white" />
</div>
<h1 className="text-2xl font-bold tracking-tight">Set up Echo OCR</h1>
<p className="mt-1 text-sm text-muted-foreground">
Configure your instance in a few steps
</p>
</div>
<div className="mb-8 flex items-center justify-center gap-2">
{STEPS.map((step, i) => {
const done = currentStep > i;
const active = currentStep === i;
return (
<div key={step.label} className="flex items-center gap-2">
<div
className={`flex size-8 items-center justify-center rounded-full text-xs font-bold transition-colors ${
done
? "bg-emerald-500 text-white"
: active
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{done ? <Check className="size-4" /> : i + 1}
</div>
<span
className={`hidden text-xs font-medium sm:inline ${
active ? "text-foreground" : "text-muted-foreground"
}`}
>
{step.label}
</span>
{i < STEPS.length - 1 && (
<ArrowRight className="size-3 text-muted-foreground/50" />
)}
</div>
);
})}
</div>
{error && (
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-center text-sm text-destructive">
{error}
</div>
)}
{currentStep === 0 && (
<form onSubmit={handleAccountSubmit} className="space-y-4">
<p className="text-sm text-muted-foreground">
Create the owner account for this instance.
</p>
<div className="space-y-2">
<Label htmlFor="displayName">Full Name</Label>
<Input
id="displayName"
value={account.displayName}
onChange={(e) => setAccount((p) => ({ ...p, displayName: e.target.value }))}
required
placeholder="John Smith"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={account.email}
onChange={(e) => setAccount((p) => ({ ...p, email: e.target.value }))}
required
placeholder="you@church.org"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={account.password}
onChange={(e) => setAccount((p) => ({ ...p, password: e.target.value }))}
required
minLength={8}
placeholder="Min 8 characters"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
value={account.confirmPassword}
onChange={(e) => setAccount((p) => ({ ...p, confirmPassword: e.target.value }))}
required
minLength={8}
placeholder="Confirm password"
/>
</div>
<Button type="submit" className="w-full rounded-xl" disabled={submitting}>
{submitting && <Loader2 className="mr-2 size-4 animate-spin" />}
Create Account
</Button>
</form>
)}
{currentStep === 1 && (
<form onSubmit={handleOrgSubmit} className="space-y-4">
<p className="text-sm text-muted-foreground">
Set up your organization.
</p>
<div className="space-y-2">
<Label htmlFor="orgName">Organization Name</Label>
<Input
id="orgName"
value={org.name}
onChange={(e) => setOrg((p) => ({ ...p, name: e.target.value }))}
required
placeholder="Grace Community Church"
/>
</div>
<div className="space-y-2">
<Label htmlFor="orgType">Type</Label>
<Input
id="orgType"
value={org.type}
onChange={(e) => setOrg((p) => ({ ...p, type: e.target.value }))}
placeholder="church"
/>
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<Input
id="timezone"
value={org.timezone}
onChange={(e) => setOrg((p) => ({ ...p, timezone: e.target.value }))}
placeholder="America/Chicago"
/>
</div>
<Button type="submit" className="w-full rounded-xl" disabled={submitting}>
{submitting && <Loader2 className="mr-2 size-4 animate-spin" />}
Create Organization
</Button>
</form>
)}
{currentStep === 2 && (
<form onSubmit={handleLocationSubmit} className="space-y-4">
<p className="text-sm text-muted-foreground">
Add your first location or campus.
</p>
<div className="space-y-2">
<Label htmlFor="locName">Location Name</Label>
<Input
id="locName"
value={location.name}
onChange={(e) => setLocation((p) => ({ ...p, name: e.target.value }))}
required
placeholder="Main Campus"
/>
</div>
<div className="space-y-2">
<Label htmlFor="locAddress">Address (optional)</Label>
<Input
id="locAddress"
value={location.address}
onChange={(e) => setLocation((p) => ({ ...p, address: e.target.value }))}
placeholder="123 Church St, City, ST 12345"
/>
</div>
<Button type="submit" className="w-full rounded-xl" disabled={submitting}>
{submitting && <Loader2 className="mr-2 size-4 animate-spin" />}
Complete Setup
</Button>
</form>
)}
</div>
);
}

View file

@ -0,0 +1,183 @@
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { ScanLine, Mail, Lock, User, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function SignupPage() {
return (
<Suspense>
<SignupForm />
</Suspense>
);
}
function SignupForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") || "";
const [formData, setFormData] = useState({
displayName: "",
email: "",
password: "",
confirmPassword: "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
function update(field: string, value: string) {
setFormData((prev) => ({ ...prev, [field]: value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (formData.password !== formData.confirmPassword) {
setError("Passwords do not match");
return;
}
if (formData.password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
displayName: formData.displayName,
email: formData.email,
password: formData.password,
inviteToken: token,
}),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || "Registration failed");
setLoading(false);
return;
}
router.push("/login?registered=true");
} catch {
setError("Something went wrong. Please try again.");
setLoading(false);
}
}
return (
<div className="glass-card w-full rounded-2xl p-8">
<div className="mb-8 flex flex-col items-center">
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
<ScanLine className="size-7 text-white" />
</div>
<h1 className="text-2xl font-bold tracking-tight">Create account</h1>
<p className="mt-1 text-sm text-muted-foreground">
{token
? "Complete your account setup"
: "You need an invitation to sign up"}
</p>
</div>
{error && (
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-center text-sm text-destructive">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="displayName">Full Name</Label>
<div className="relative">
<User className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="displayName"
type="text"
placeholder="John Smith"
value={formData.displayName}
onChange={(e) => update("displayName", e.target.value)}
required
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@church.org"
value={formData.email}
onChange={(e) => update("email", e.target.value)}
required
autoComplete="email"
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="Min 8 characters"
value={formData.password}
onChange={(e) => update("password", e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={formData.confirmPassword}
onChange={(e) => update("confirmPassword", e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="pl-10"
/>
</div>
</div>
<Button type="submit" className="w-full rounded-xl" disabled={loading}>
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
Create Account
</Button>
</form>
<p className="mt-6 text-center text-xs text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="font-medium text-primary hover:underline">
Sign in
</Link>
</p>
</div>
);
}

View file

@ -131,7 +131,7 @@ export default function CardDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const id = params.id as string; const id = params.id as string;
const { role, dbUser } = useUserProfile(); const { role, userId } = useUserProfile();
const isAdmin = role === "admin"; const isAdmin = role === "admin";
const isReviewer = role === "reviewer"; const isReviewer = role === "reviewer";
const isViewer = role === "viewer"; const isViewer = role === "viewer";
@ -150,7 +150,7 @@ export default function CardDetailPage() {
const [users, setUsers] = React.useState<AssignableUser[]>([]); const [users, setUsers] = React.useState<AssignableUser[]>([]);
const [prevNextIds, setPrevNextIds] = React.useState<{ prev: string | null; next: string | null }>({ prev: null, next: null }); const [prevNextIds, setPrevNextIds] = React.useState<{ prev: string | null; next: string | null }>({ prev: null, next: null });
const isAssignedToMe = card?.assignedToId && dbUser?.id === card.assignedToId; const isAssignedToMe = card?.assignedToId && userId === card.assignedToId;
const canEdit = isAdmin || (isReviewer && isAssignedToMe); const canEdit = isAdmin || (isReviewer && isAssignedToMe);
const canMarkComplete = isAdmin || (isReviewer && isAssignedToMe); const canMarkComplete = isAdmin || (isReviewer && isAssignedToMe);
@ -452,7 +452,7 @@ export default function CardDetailPage() {
<div className="flex items-center gap-2 rounded-xl border border-purple-300 bg-purple-500/10 px-4 py-2.5 dark:border-purple-800"> <div className="flex items-center gap-2 rounded-xl border border-purple-300 bg-purple-500/10 px-4 py-2.5 dark:border-purple-800">
<User className="size-4 text-purple-600 dark:text-purple-400" /> <User className="size-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm"> <span className="text-sm">
Assigned to <strong>{card.assignedToId === dbUser?.id ? "you" : (card.assignedToId)}</strong> Assigned to <strong>{card.assignedToId === userId ? "you" : (card.assignedToId)}</strong>
{card.assignedAt && ( {card.assignedAt && (
<> on {new Date(card.assignedAt).toLocaleDateString()}</> <> on {new Date(card.assignedAt).toLocaleDateString()}</>
)} )}

View file

@ -0,0 +1,17 @@
import { CalendarDays } from "lucide-react";
import { Header } from "@/components/layout/header";
export default function EventDetailPage() {
return (
<div className="space-y-6">
<Header
title="Event Detail"
description="View cards assigned to this event"
icon={CalendarDays}
/>
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
Event detail view coming soon.
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { CalendarDays } from "lucide-react";
import { Header } from "@/components/layout/header";
export default function EventsPage() {
return (
<div className="space-y-6">
<Header
title="Collection Days"
description="Manage recurring events and service times"
icon={CalendarDays}
/>
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
<CalendarDays className="mb-4 size-12 text-muted-foreground/40" />
<h2 className="text-lg font-semibold">No collection days yet</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Collection days will be available once organizations and locations are
configured. Check back after setup is complete.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,9 @@
import { AppShell } from "@/components/layout/app-shell";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return <AppShell>{children}</AppShell>;
}

View file

@ -0,0 +1,179 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
CreditCard,
CalendarDays,
Upload,
ArrowRight,
CheckCircle2,
AlertCircle,
Clock,
Users,
} from "lucide-react";
import { Header } from "@/components/layout/header";
import { Skeleton } from "@/components/ui/skeleton";
type Stats = {
total: number;
byOcrStatus: { ocrStatus: string; _count: { id: number } }[];
byReviewStatus: { reviewStatus: string; _count: { id: number } }[];
};
function getStatCount(
groups: { ocrStatus?: string; reviewStatus?: string; _count: { id: number } }[],
key: string,
value: string
): number {
const match = groups.find((g) => (g as Record<string, unknown>)[key] === value);
return match?._count.id ?? 0;
}
export default function DashboardHomePage() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/stats")
.then((r) => r.json())
.then(setStats)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const completed = stats ? getStatCount(stats.byOcrStatus, "ocrStatus", "complete") : 0;
const errors = stats ? getStatCount(stats.byOcrStatus, "ocrStatus", "error") : 0;
const pending = stats ? getStatCount(stats.byOcrStatus, "ocrStatus", "pending") + getStatCount(stats.byOcrStatus, "ocrStatus", "processing") : 0;
const unreviewed = stats ? getStatCount(stats.byReviewStatus, "reviewStatus", "unreviewed") : 0;
const summaryCards = [
{
label: "Total Cards",
value: stats?.total ?? 0,
icon: CreditCard,
href: "/cards",
},
{
label: "OCR Complete",
value: completed,
icon: CheckCircle2,
href: "/cards?ocrStatus=complete",
},
{
label: "Needs Review",
value: unreviewed,
icon: Clock,
href: "/cards?reviewStatus=unreviewed",
},
{
label: "Errors",
value: errors,
icon: AlertCircle,
href: "/cards?ocrStatus=error",
},
];
const quickActions = [
{
label: "Upload Documents",
description: "Scan or import new response cards",
icon: Upload,
onClick: () => window.dispatchEvent(new CustomEvent("open-upload-modal")),
},
{
label: "View Response Cards",
description: "Browse and manage all scanned cards",
icon: CreditCard,
href: "/cards",
},
{
label: "Collection Days",
description: "Manage events and service times",
icon: CalendarDays,
href: "/events",
},
{
label: "People",
description: "View contact directory",
icon: Users,
href: "/people",
},
];
return (
<div className="space-y-8">
<Header
title="Dashboard"
description="Overview of your response card activity"
icon={CreditCard}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{summaryCards.map((card) =>
loading ? (
<Skeleton key={card.label} className="h-28 rounded-2xl" />
) : (
<Link
key={card.label}
href={card.href}
className="gradient-stat group flex flex-col justify-between rounded-2xl p-5 transition-transform hover:scale-[1.02]"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
{card.label}
</span>
<card.icon className="size-4 text-muted-foreground" />
</div>
<p className="mt-2 text-3xl font-bold tracking-tight">
{card.value.toLocaleString()}
</p>
</Link>
)
)}
</div>
<div>
<h2 className="mb-4 text-lg font-semibold">Quick Actions</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{quickActions.map((action) => {
const className =
"glass-card group flex items-start gap-4 rounded-xl p-4 text-left transition-all hover:scale-[1.01]";
const inner = (
<>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<action.icon className="size-5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{action.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{action.description}
</p>
</div>
<ArrowRight className="mt-1 size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</>
);
if (action.href) {
return (
<Link key={action.label} href={action.href} className={className}>
{inner}
</Link>
);
}
return (
<button
key={action.label}
type="button"
onClick={action.onClick}
className={className}
>
{inner}
</button>
);
})}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { Users } from "lucide-react";
import { Header } from "@/components/layout/header";
export default function PeoplePage() {
return (
<div className="space-y-6">
<Header
title="People"
description="Contact directory from scanned response cards"
icon={Users}
/>
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
<Users className="mb-4 size-12 text-muted-foreground/40" />
<h2 className="text-lg font-semibold">People directory</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
A deduplicated contact list aggregated from your response cards will
appear here once the people module is built out.
</p>
</div>
</div>
);
}

View file

@ -31,13 +31,12 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useUserProfile } from "@/lib/user-profile"; import { useUserProfile } from "@/lib/user-profile";
export default function ProfilePage() { export default function ProfilePage() {
const { profile, updateProfile, initials, authentikUser, isAuthenticated, loading } = useUserProfile(); const { profile, updateProfile, initials, role, isAuthenticated, loading } = useUserProfile();
const [form, setForm] = React.useState({ const [form, setForm] = React.useState({
jobTitle: profile.jobTitle, jobTitle: profile.jobTitle,
company: profile.company, company: profile.company,
bio: profile.bio, bio: profile.bio,
// Only editable when NOT coming from Authentik
displayName: profile.displayName, displayName: profile.displayName,
email: profile.email, email: profile.email,
avatarUrl: profile.avatarUrl, avatarUrl: profile.avatarUrl,
@ -61,17 +60,14 @@ export default function ProfilePage() {
}; };
const handleSave = () => { const handleSave = () => {
const updates: Record<string, string> = { updateProfile({
jobTitle: form.jobTitle, jobTitle: form.jobTitle,
company: form.company, company: form.company,
bio: form.bio, bio: form.bio,
}; displayName: form.displayName,
if (!isAuthenticated) { email: form.email,
updates.displayName = form.displayName; avatarUrl: form.avatarUrl,
updates.email = form.email; });
updates.avatarUrl = form.avatarUrl;
}
updateProfile(updates);
setDirty(false); setDirty(false);
toast.success("Profile updated"); toast.success("Profile updated");
}; };
@ -109,10 +105,6 @@ export default function ProfilePage() {
); );
} }
const nameFromAuthentik = isAuthenticated && !!authentikUser?.name;
const emailFromAuthentik = isAuthenticated && !!authentikUser?.email;
const avatarFromAuthentik = isAuthenticated && !!authentikUser?.avatar;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Header title="Profile" description="Manage your personal information" icon={UserCircle}> <Header title="Profile" description="Manage your personal information" icon={UserCircle}>
@ -126,8 +118,8 @@ export default function ProfilePage() {
<div className="flex items-center gap-2 rounded-xl border border-emerald-500/20 bg-emerald-500/5 px-4 py-3"> <div className="flex items-center gap-2 rounded-xl border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
<Shield className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" /> <Shield className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
<p className="text-sm text-emerald-700 dark:text-emerald-300"> <p className="text-sm text-emerald-700 dark:text-emerald-300">
Signed in via Authentik as <span className="font-medium">{authentikUser?.username}</span>. Signed in as <span className="font-medium">{profile.displayName || profile.email}</span>.
Name, email, and avatar are managed by your identity provider. Role: <Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0">{role}</Badge>
</p> </p>
</div> </div>
)} )}
@ -137,9 +129,7 @@ export default function ProfilePage() {
<CardHeader> <CardHeader>
<CardTitle className="text-base">Photo</CardTitle> <CardTitle className="text-base">Photo</CardTitle>
<CardDescription> <CardDescription>
{avatarFromAuthentik Your profile picture is visible in the header
? "Managed by Authentik"
: "Your profile picture is visible in the header"}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col items-center gap-4"> <CardContent className="flex flex-col items-center gap-4">
@ -152,60 +142,41 @@ export default function ProfilePage() {
{initials || <UserCircle className="size-10 text-muted-foreground" />} {initials || <UserCircle className="size-10 text-muted-foreground" />}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
{!avatarFromAuthentik && ( <label className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
<label className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity group-hover:opacity-100"> <Camera className="size-6 text-white" />
<Camera className="size-6 text-white" /> <input
<input type="file"
type="file" accept="image/*"
accept="image/*" className="hidden"
className="hidden" onChange={handleAvatarUpload}
onChange={handleAvatarUpload} />
/> </label>
</label> </div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => document.querySelector<HTMLInputElement>('input[type="file"]')?.click()}
>
<Camera className="mr-1.5 size-3" />
Upload
</Button>
{form.avatarUrl && (
<Button
variant="ghost"
size="sm"
className="rounded-xl text-muted-foreground"
onClick={removeAvatar}
>
<X className="mr-1.5 size-3" />
Remove
</Button>
)} )}
</div> </div>
{!avatarFromAuthentik && ( <p className="text-center text-xs text-muted-foreground">
<div className="flex gap-2"> JPG, PNG or WebP. Max 2 MB.
<Button </p>
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => document.querySelector<HTMLInputElement>('input[type="file"]')?.click()}
>
<Camera className="mr-1.5 size-3" />
Upload
</Button>
{form.avatarUrl && (
<Button
variant="ghost"
size="sm"
className="rounded-xl text-muted-foreground"
onClick={removeAvatar}
>
<X className="mr-1.5 size-3" />
Remove
</Button>
)}
</div>
)}
{avatarFromAuthentik && (
<p className="text-center text-xs text-muted-foreground">
Update your avatar in{" "}
<a
href="https://auth.stillwell.cloud/if/user/"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
Authentik
</a>
</p>
)}
{!avatarFromAuthentik && (
<p className="text-center text-xs text-muted-foreground">
JPG, PNG or WebP. Max 2 MB.
</p>
)}
</CardContent> </CardContent>
</Card> </Card>
@ -213,9 +184,7 @@ export default function ProfilePage() {
<CardHeader> <CardHeader>
<CardTitle className="text-base">Personal Information</CardTitle> <CardTitle className="text-base">Personal Information</CardTitle>
<CardDescription> <CardDescription>
{isAuthenticated Update your name, email, and other details
? "Some fields are synced from Authentik"
: "Update your name, email, and other details"}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
@ -224,54 +193,29 @@ export default function ProfilePage() {
<Label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <Label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<UserCircle className="size-3" /> <UserCircle className="size-3" />
Display Name Display Name
{nameFromAuthentik && (
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5 py-0">SSO</Badge>
)}
</Label> </Label>
<Input <Input
value={form.displayName} value={form.displayName}
onChange={(e) => handleChange("displayName", e.target.value)} onChange={(e) => handleChange("displayName", e.target.value)}
placeholder="Your name" placeholder="Your name"
readOnly={nameFromAuthentik}
className={nameFromAuthentik ? "bg-muted/40 cursor-default" : ""}
/> />
</div> </div>
<div> <div>
<Label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <Label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Mail className="size-3" /> <Mail className="size-3" />
Email Email
{emailFromAuthentik && (
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5 py-0">SSO</Badge>
)}
</Label> </Label>
<Input <Input
type="email" type="email"
value={form.email} value={form.email}
onChange={(e) => handleChange("email", e.target.value)} onChange={(e) => handleChange("email", e.target.value)}
placeholder="you@example.com" placeholder="you@example.com"
readOnly={emailFromAuthentik} readOnly
className={emailFromAuthentik ? "bg-muted/40 cursor-default" : ""} className="bg-muted/40 cursor-default"
/> />
</div> </div>
</div> </div>
{isAuthenticated && authentikUser?.groups && authentikUser.groups.length > 0 && (
<div>
<Label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Shield className="size-3" />
Groups
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5 py-0">SSO</Badge>
</Label>
<div className="flex flex-wrap gap-1.5 mt-1">
{authentikUser.groups.map((group) => (
<Badge key={group} variant="secondary" className="text-xs">
{group}
</Badge>
))}
</div>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<Label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <Label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">

View file

@ -0,0 +1,22 @@
import { BarChart3 } from "lucide-react";
import { Header } from "@/components/layout/header";
export default function ReportsPage() {
return (
<div className="space-y-6">
<Header
title="Reports"
description="Analytics, trends, and export center"
icon={BarChart3}
/>
<div className="glass-card flex flex-col items-center justify-center rounded-2xl p-12 text-center">
<BarChart3 className="mb-4 size-12 text-muted-foreground/40" />
<h2 className="text-lg font-semibold">Reports &amp; Analytics</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Detailed reports, trends over time, and data exports will be available
here in a future update.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import { MapPin } from "lucide-react";
import { Header } from "@/components/layout/header";
export default function LocationsSettingsPage() {
return (
<div className="space-y-6">
<Header
title="Locations"
description="Manage your organization's locations and campuses"
icon={MapPin}
/>
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
Location management will be available after the org model is set up.
</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import { Building2 } from "lucide-react";
import { Header } from "@/components/layout/header";
export default function OrganizationSettingsPage() {
return (
<div className="space-y-6">
<Header
title="Organization"
description="Manage your organization details and preferences"
icon={Building2}
/>
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
Organization settings will be available after the org model is set up.
</div>
</div>
);
}

View file

@ -0,0 +1,20 @@
"use client";
import { UserCog } from "lucide-react";
import { Header } from "@/components/layout/header";
export default function UsersSettingsPage() {
return (
<div className="space-y-6">
<Header
title="Users"
description="Manage team members and invitations"
icon={UserCog}
/>
<div className="glass-card rounded-2xl p-8 text-center text-sm text-muted-foreground">
User management and invitations will be available after the auth system
is set up.
</div>
</div>
);
}

View file

@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View file

@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { displayName, email, password, inviteToken } = body;
if (!email || !password || !displayName) {
return NextResponse.json(
{ error: "Name, email, and password are required" },
{ status: 400 }
);
}
if (password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters" },
{ status: 400 }
);
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
);
}
let role = "viewer";
let organizationId: string | null = null;
if (inviteToken) {
const invitation = await prisma.invitation.findUnique({
where: { token: inviteToken },
});
if (!invitation) {
return NextResponse.json({ error: "Invalid invitation" }, { status: 400 });
}
if (invitation.acceptedAt) {
return NextResponse.json(
{ error: "This invitation has already been used" },
{ status: 400 }
);
}
if (invitation.expiresAt < new Date()) {
return NextResponse.json(
{ error: "This invitation has expired" },
{ status: 400 }
);
}
role = invitation.role;
organizationId = invitation.organizationId;
} else {
const userCount = await prisma.user.count();
if (userCount > 0) {
return NextResponse.json(
{ error: "Registration requires an invitation" },
{ status: 403 }
);
}
role = "owner";
}
const hashedPassword = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: {
email,
displayName,
hashedPassword,
role,
},
});
if (organizationId) {
await prisma.orgMember.create({
data: {
userId: user.id,
organizationId,
role,
},
});
if (inviteToken) {
await prisma.invitation.update({
where: { token: inviteToken },
data: { acceptedAt: new Date() },
});
}
}
return NextResponse.json({ success: true, userId: user.id });
} catch (error) {
console.error("[register] Error:", error);
return NextResponse.json(
{ error: "Registration failed" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { PermissionError } from "@/lib/permissions";
export async function GET(req: NextRequest) {
try {
const user = await requireAuth();
if (!user.orgId) return NextResponse.json([]);
const locationId = req.nextUrl.searchParams.get("locationId");
const collectionDays = await prisma.collectionDay.findMany({
where: {
location: { organizationId: user.orgId },
...(locationId && { locationId }),
},
include: {
location: { select: { id: true, name: true } },
_count: { select: { cards: true } },
},
orderBy: { name: "asc" },
});
return NextResponse.json(collectionDays);
} catch (error) {
if (error instanceof PermissionError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
return NextResponse.json({ error: "Failed to fetch collection days" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const user = await requireAuth("events.manage");
const body = await req.json();
const { locationId, name, description, rrule, dayOfWeek, timeStart, timeEnd, isRecurring, date } = body;
if (!locationId || !name) {
return NextResponse.json(
{ error: "Location and name are required" },
{ status: 400 }
);
}
const location = await prisma.location.findFirst({
where: { id: locationId, organizationId: user.orgId },
});
if (!location) {
return NextResponse.json({ error: "Location not found" }, { status: 404 });
}
const collectionDay = await prisma.collectionDay.create({
data: {
locationId,
name,
description: description || null,
rrule: rrule || null,
dayOfWeek: dayOfWeek ?? null,
timeStart: timeStart || null,
timeEnd: timeEnd || null,
isRecurring: isRecurring ?? true,
date: date ? new Date(date) : null,
},
});
return NextResponse.json(collectionDay, { status: 201 });
} catch (error) {
if (error instanceof PermissionError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
console.error("[collection-days] Error:", error);
return NextResponse.json({ error: "Failed to create collection day" }, { status: 500 });
}
}

View file

@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth, withOrgScope } from "@/lib/auth";
import { PermissionError } from "@/lib/permissions";
export async function GET() {
try {
const user = await requireAuth("users.invite");
const scope = withOrgScope(user);
const invitations = await prisma.invitation.findMany({
where: { ...scope, acceptedAt: null },
orderBy: { createdAt: "desc" },
});
return NextResponse.json(invitations);
} catch (error) {
if (error instanceof PermissionError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
return NextResponse.json({ error: "Failed to fetch invitations" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const user = await requireAuth("users.invite");
if (!user.orgId) {
return NextResponse.json(
{ error: "No organization found" },
{ status: 400 }
);
}
const body = await req.json();
const { email, role } = body;
if (!email) {
return NextResponse.json(
{ error: "Email is required" },
{ status: 400 }
);
}
const validRoles = ["admin", "editor", "reviewer", "viewer"];
if (role && !validRoles.includes(role)) {
return NextResponse.json(
{ error: "Invalid role" },
{ status: 400 }
);
}
const existing = await prisma.invitation.findFirst({
where: {
email,
organizationId: user.orgId,
acceptedAt: null,
expiresAt: { gt: new Date() },
},
});
if (existing) {
return NextResponse.json(
{ error: "An active invitation already exists for this email" },
{ status: 409 }
);
}
const invitation = await prisma.invitation.create({
data: {
email,
role: role || "viewer",
organizationId: user.orgId,
invitedById: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
return NextResponse.json({
invitation,
inviteUrl: `/signup?token=${invitation.token}`,
});
} catch (error) {
if (error instanceof PermissionError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
console.error("[invitations] Error:", error);
return NextResponse.json(
{ error: "Failed to create invitation" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get("token");
if (!token) {
return NextResponse.json({ valid: false });
}
const invitation = await prisma.invitation.findUnique({
where: { token },
});
if (!invitation || invitation.acceptedAt || invitation.expiresAt < new Date()) {
return NextResponse.json({ valid: false });
}
return NextResponse.json({
valid: true,
email: invitation.email,
role: invitation.role,
});
}

View file

@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { PermissionError } from "@/lib/permissions";
export async function GET() {
try {
const user = await requireAuth();
if (!user.orgId) return NextResponse.json([]);
const locations = await prisma.location.findMany({
where: { organizationId: user.orgId },
include: {
_count: { select: { collectionDays: true, cards: true } },
},
orderBy: { name: "asc" },
});
return NextResponse.json(locations);
} catch (error) {
if (error instanceof PermissionError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
return NextResponse.json({ error: "Failed to fetch locations" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const user = await requireAuth("events.manage");
if (!user.orgId) {
return NextResponse.json({ error: "No organization" }, { status: 400 });
}
const body = await req.json();
const { name, address, timezone } = body;
if (!name) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
const location = await prisma.location.create({
data: {
name,
address: address || null,
timezone: timezone || null,
organizationId: user.orgId,
},
});
return NextResponse.json(location, { status: 201 });
} catch (error) {
if (error instanceof PermissionError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
return NextResponse.json({ error: "Failed to create location" }, { status: 500 });
}
}

View file

@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { PermissionError } from "@/lib/permissions";
export async function GET() {
try {
const user = await requireAuth();
if (!user.orgId) {
return NextResponse.json([]);
}
const org = await prisma.organization.findUnique({
where: { id: user.orgId },
include: {
locations: { orderBy: { name: "asc" } },
_count: { select: { members: true } },
},
});
return NextResponse.json(org);
} catch (error) {
if (error instanceof PermissionError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
return NextResponse.json({ error: "Failed to fetch organization" }, { status: 500 });
}
}
export async function PUT(req: NextRequest) {
try {
const user = await requireAuth("org.manage");
if (!user.orgId) {
return NextResponse.json({ error: "No organization" }, { status: 400 });
}
const body = await req.json();
const { name, type, timezone, settings } = body;
const org = await prisma.organization.update({
where: { id: user.orgId },
data: {
...(name !== undefined && { name }),
...(type !== undefined && { type }),
...(timezone !== undefined && { timezone }),
...(settings !== undefined && { settings }),
},
});
return NextResponse.json(org);
} catch (error) {
if (error instanceof PermissionError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
return NextResponse.json({ error: "Failed to update organization" }, { status: 500 });
}
}

148
src/app/api/setup/route.ts Normal file
View file

@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export async function GET() {
try {
const config = await prisma.systemConfig.findUnique({
where: { id: "singleton" },
});
return NextResponse.json({
isSetupComplete: config?.isSetupComplete ?? false,
setupStep: config?.setupStep ?? 0,
});
} catch {
return NextResponse.json({ isSetupComplete: false, setupStep: 0 });
}
}
export async function POST(req: NextRequest) {
try {
const config = await prisma.systemConfig.findUnique({
where: { id: "singleton" },
});
if (config?.isSetupComplete) {
return NextResponse.json(
{ error: "Setup is already complete" },
{ status: 400 }
);
}
const body = await req.json();
const { step, data } = body;
if (step === 1) {
const { email, password, displayName } = data;
if (!email || !password || !displayName) {
return NextResponse.json(
{ error: "All fields are required" },
{ status: 400 }
);
}
const hashedPassword = await bcrypt.hash(password, 12);
await prisma.user.create({
data: {
email,
displayName,
hashedPassword,
role: "owner",
},
});
await prisma.systemConfig.upsert({
where: { id: "singleton" },
update: { setupStep: 1 },
create: { id: "singleton", setupStep: 1 },
});
return NextResponse.json({ success: true, nextStep: 2 });
}
if (step === 2) {
const { name, type, timezone } = data;
if (!name) {
return NextResponse.json(
{ error: "Organization name is required" },
{ status: 400 }
);
}
const owner = await prisma.user.findFirst({
where: { role: "owner" },
});
if (!owner) {
return NextResponse.json(
{ error: "Owner account not found" },
{ status: 400 }
);
}
const org = await prisma.organization.create({
data: {
name,
slug: slugify(name),
type: type || "church",
timezone: timezone || "America/Chicago",
},
});
await prisma.orgMember.create({
data: {
userId: owner.id,
organizationId: org.id,
role: "owner",
},
});
await prisma.systemConfig.update({
where: { id: "singleton" },
data: { setupStep: 2 },
});
return NextResponse.json({ success: true, nextStep: 3, orgId: org.id });
}
if (step === 3) {
const { name, address, timezone, organizationId } = data;
if (!name || !organizationId) {
return NextResponse.json(
{ error: "Location name is required" },
{ status: 400 }
);
}
await prisma.location.create({
data: {
name,
address: address || null,
timezone: timezone || null,
organizationId,
},
});
await prisma.systemConfig.update({
where: { id: "singleton" },
data: { setupStep: 3, isSetupComplete: true },
});
return NextResponse.json({ success: true, complete: true });
}
return NextResponse.json({ error: "Invalid step" }, { status: 400 });
} catch (error) {
console.error("[setup] Error:", error);
return NextResponse.json(
{ error: "Setup failed" },
{ status: 500 }
);
}
}

View file

@ -72,6 +72,8 @@
--chart-4: oklch(0.65 0.06 200); --chart-4: oklch(0.65 0.06 200);
--chart-5: oklch(0.45 0.10 140); --chart-5: oklch(0.45 0.10 140);
--radius: 0.75rem; --radius: 0.75rem;
--sidebar-width: 240px;
--sidebar-collapsed-width: 64px;
--sidebar: oklch(1 0 0 / 50%); --sidebar: oklch(1 0 0 / 50%);
--sidebar-foreground: oklch(0.20 0.005 90); --sidebar-foreground: oklch(0.20 0.005 90);
--sidebar-primary: oklch(0.20 0.005 90); --sidebar-primary: oklch(0.20 0.005 90);

View file

@ -2,7 +2,6 @@ import type { Metadata } from "next";
import { Quicksand } from "next/font/google"; import { Quicksand } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Providers } from "@/components/providers"; import { Providers } from "@/components/providers";
import { TopBar } from "@/components/layout/top-bar";
const quicksand = Quicksand({ const quicksand = Quicksand({
variable: "--font-sans", variable: "--font-sans",
@ -23,17 +22,7 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${quicksand.variable} font-sans antialiased`}> <body className={`${quicksand.variable} font-sans antialiased`}>
<Providers> <Providers>{children}</Providers>
<div className="relative min-h-screen">
<div className="gradient-mesh pointer-events-none fixed inset-0 z-0" />
<TopBar />
<main className="relative z-10 pt-16">
<div className="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8">
{children}
</div>
</main>
</div>
</Providers>
</body> </body>
</html> </html>
); );

97
src/auth.ts Normal file
View file

@ -0,0 +1,97 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const email = credentials.email as string;
const password = credentials.password as string;
const user = await prisma.user.findUnique({ where: { email } });
if (!user?.hashedPassword) return null;
const valid = await bcrypt.compare(password, user.hashedPassword);
if (!valid) return null;
return {
id: user.id,
email: user.email,
name: user.displayName || user.username,
image: user.avatarUrl || undefined,
};
},
}),
// Authentik OIDC — enabled when env vars are set
...(process.env.AUTHENTIK_ISSUER
? [
{
id: "authentik",
name: "SSO",
type: "oidc" as const,
issuer: process.env.AUTHENTIK_ISSUER,
clientId: process.env.AUTHENTIK_CLIENT_ID!,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
},
]
: []),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
if (token.id) {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
include: {
memberships: {
take: 1,
include: { organization: { select: { id: true, name: true } } },
},
},
});
if (dbUser) {
token.role = dbUser.role;
token.displayName = dbUser.displayName;
token.avatarUrl = dbUser.avatarUrl;
if (dbUser.memberships[0]) {
token.orgId = dbUser.memberships[0].organizationId;
token.orgName = dbUser.memberships[0].organization.name;
token.orgRole = dbUser.memberships[0].role;
}
}
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = (token.orgRole || token.role || "viewer") as string;
session.user.orgId = token.orgId as string | undefined;
session.user.orgName = token.orgName as string | undefined;
session.user.displayName = token.displayName as string | undefined;
session.user.avatarUrl = token.avatarUrl as string | undefined;
}
return session;
},
},
});

View file

@ -0,0 +1,33 @@
"use client";
import { cn } from "@/lib/utils";
import { TopBar } from "@/components/layout/top-bar";
import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar";
function ShellContent({ children }: { children: React.ReactNode }) {
const { collapsed } = useSidebar();
return (
<div className="relative min-h-screen">
<div className="gradient-mesh pointer-events-none fixed inset-0 z-0" />
<TopBar />
<Sidebar />
<main
className={cn(
"relative z-10 pt-16 transition-[margin-left] duration-200 ease-in-out",
collapsed ? "ml-16" : "ml-60"
)}
>
<div className="p-4 sm:p-6 lg:p-8">{children}</div>
</main>
</div>
);
}
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider>
<ShellContent>{children}</ShellContent>
</SidebarProvider>
);
}

View file

@ -0,0 +1,190 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
CreditCard,
CalendarDays,
Users,
BarChart3,
Settings,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { ScrollArea } from "@/components/ui/scroll-area";
const STORAGE_KEY = "echo-sidebar-collapsed";
const navItems = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/cards", label: "Response Cards", icon: CreditCard },
{ href: "/events", label: "Collection Days", icon: CalendarDays },
{ href: "/people", label: "People", icon: Users },
{ href: "/reports", label: "Reports", icon: BarChart3 },
];
const bottomItems = [
{ href: "/settings", label: "Settings", icon: Settings },
];
function isActive(pathname: string, href: string) {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(href + "/");
}
type SidebarContextValue = {
collapsed: boolean;
setCollapsed: (v: boolean) => void;
toggle: () => void;
};
const SidebarContext = React.createContext<SidebarContextValue>({
collapsed: false,
setCollapsed: () => {},
toggle: () => {},
});
export function useSidebar() {
return React.useContext(SidebarContext);
}
export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsedState] = React.useState(false);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "true") setCollapsedState(true);
} catch {}
}, []);
const setCollapsed = React.useCallback((v: boolean) => {
setCollapsedState(v);
try {
localStorage.setItem(STORAGE_KEY, String(v));
} catch {}
}, []);
const toggle = React.useCallback(() => {
setCollapsed(!collapsed);
}, [collapsed, setCollapsed]);
const value = React.useMemo(
() => ({ collapsed: mounted ? collapsed : false, setCollapsed, toggle }),
[mounted, collapsed, setCollapsed, toggle]
);
return (
<SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>
);
}
function NavItem({
href,
label,
icon: Icon,
active,
collapsed,
}: {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
active: boolean;
collapsed: boolean;
}) {
const content = (
<Link
href={href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
collapsed && "justify-center px-0"
)}
>
<Icon className="size-4 shrink-0" />
{!collapsed && <span className="truncate">{label}</span>}
</Link>
);
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger render={<div />}>{content}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
}
return content;
}
export function Sidebar() {
const pathname = usePathname();
const { collapsed, toggle } = useSidebar();
return (
<aside
className={cn(
"fixed left-0 top-16 z-30 flex h-[calc(100vh-4rem)] flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200 ease-in-out",
collapsed ? "w-16" : "w-60"
)}
>
<ScrollArea className="flex-1">
<nav className={cn("flex flex-col gap-1 p-3", collapsed && "px-2")}>
{navItems.map((item) => (
<NavItem
key={item.href}
{...item}
active={isActive(pathname, item.href)}
collapsed={collapsed}
/>
))}
</nav>
</ScrollArea>
<div
className={cn(
"flex flex-col gap-1 border-t border-sidebar-border p-3",
collapsed && "px-2"
)}
>
{bottomItems.map((item) => (
<NavItem
key={item.href}
{...item}
active={isActive(pathname, item.href)}
collapsed={collapsed}
/>
))}
<button
onClick={toggle}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground/50 transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
collapsed && "justify-center px-0"
)}
>
{collapsed ? (
<ChevronsRight className="size-4 shrink-0" />
) : (
<ChevronsLeft className="size-4 shrink-0" />
)}
{!collapsed && <span>Collapse</span>}
</button>
</div>
</aside>
);
}

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { signOut } from "next-auth/react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { import {
Upload, Upload,
@ -52,12 +53,19 @@ export function TopBar() {
return ( return (
<header className="glass-panel fixed inset-x-0 top-0 z-40 flex h-16 items-center justify-between px-4 sm:px-6 lg:px-8"> <header className="glass-panel fixed inset-x-0 top-0 z-40 flex h-16 items-center justify-between px-4 sm:px-6 lg:px-8">
<Link href="/" className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl gradient-banner shadow-sm"> <Link href="/" className="flex items-center gap-2.5">
<ScanLine className="size-[18px] text-white" /> <div className="flex size-9 items-center justify-center rounded-xl gradient-banner shadow-sm">
</div> <ScanLine className="size-[18px] text-white" />
<span className="text-base font-bold tracking-tight">Echo OCR</span> </div>
</Link> <span className="text-base font-bold tracking-tight">Echo OCR</span>
</Link>
{process.env.NEXT_PUBLIC_ENV === "staging" && (
<span className="rounded-md bg-amber-500/15 px-2 py-0.5 text-xs font-semibold uppercase tracking-wider text-amber-600 ring-1 ring-amber-500/25 dark:text-amber-400">
Staging
</span>
)}
</div>
<div className="flex items-center gap-1 sm:gap-2"> <div className="flex items-center gap-1 sm:gap-2">
<Tooltip> <Tooltip>
@ -153,7 +161,10 @@ export function TopBar() {
Theme: {themeLabel} Theme: {themeLabel}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"> <DropdownMenuItem
variant="destructive"
onClick={() => signOut({ callbackUrl: "/login" })}
>
<LogOut className="size-4" /> <LogOut className="size-4" />
Sign Out Sign Out
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { SessionProvider } from "next-auth/react";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
@ -7,13 +8,15 @@ import { UserProfileProvider } from "@/lib/user-profile";
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <SessionProvider>
<UserProfileProvider> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider> <UserProfileProvider>
{children} <TooltipProvider>
<Toaster richColors position="bottom-right" /> {children}
</TooltipProvider> <Toaster richColors position="bottom-right" />
</UserProfileProvider> </TooltipProvider>
</ThemeProvider> </UserProfileProvider>
</ThemeProvider>
</SessionProvider>
); );
} }

View file

@ -1,27 +1,72 @@
import { prisma } from "./db"; import { prisma } from "./db";
import { auth } from "@/auth";
import { requirePermission as checkPermission, type Action } from "./permissions";
export { can, PermissionError } from "./permissions";
export type { Action } from "./permissions";
export type AppUser = { export type AppUser = {
id: string; id: string;
authentikUid: string; authentikUid: string | null;
username: string; username: string | null;
displayName: string; displayName: string | null;
email: string; email: string;
avatarUrl: string; avatarUrl: string;
role: "admin" | "reviewer" | "viewer"; role: "admin" | "reviewer" | "viewer" | "editor" | "owner";
orgId?: string;
}; };
type CacheEntry = { user: AppUser; ts: number }; type CacheEntry = { user: AppUser; ts: number };
const userCache = new Map<string, CacheEntry>(); const userCache = new Map<string, CacheEntry>();
const CACHE_TTL_MS = 60_000; const CACHE_TTL_MS = 60_000;
function mapGroupsToRole(groups: string[]): "admin" | "reviewer" | "viewer" { function mapGroupsToRole(groups: string[]): AppUser["role"] {
const lower = groups.map((g) => g.toLowerCase()); const lower = groups.map((g) => g.toLowerCase());
if (lower.some((g) => g.includes("admin"))) return "admin"; if (lower.some((g) => g.includes("admin"))) return "admin";
if (lower.some((g) => g.includes("reviewer") || g.includes("review"))) return "reviewer"; if (lower.some((g) => g.includes("reviewer") || g.includes("review"))) return "reviewer";
return "viewer"; return "viewer";
} }
/**
* Get current user from Auth.js session (primary) or Authentik headers (legacy fallback).
*/
export async function getSessionUser(): Promise<AppUser | null> {
const session = await auth();
if (session?.user) {
const cached = userCache.get(session.user.id);
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.user;
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
include: { memberships: { take: 1 } },
});
if (dbUser) {
const user: AppUser = {
id: dbUser.id,
authentikUid: dbUser.authentikUid,
username: dbUser.username,
displayName: dbUser.displayName,
email: dbUser.email,
avatarUrl: dbUser.avatarUrl,
role: (dbUser.memberships[0]?.role || dbUser.role) as AppUser["role"],
orgId: dbUser.memberships[0]?.organizationId,
};
userCache.set(user.id, { user, ts: Date.now() });
return user;
}
}
return null;
}
/**
* Legacy: get user from Authentik forward-auth headers.
* Kept for backward compatibility during migration.
*/
export async function getOrCreateUser(headers: Headers): Promise<AppUser | null> { export async function getOrCreateUser(headers: Headers): Promise<AppUser | null> {
const sessionUser = await getSessionUser();
if (sessionUser) return sessionUser;
const uid = headers.get("x-authentik-uid") ?? ""; const uid = headers.get("x-authentik-uid") ?? "";
const username = headers.get("x-authentik-username") ?? ""; const username = headers.get("x-authentik-username") ?? "";
const email = headers.get("x-authentik-email") ?? ""; const email = headers.get("x-authentik-email") ?? "";
@ -82,3 +127,25 @@ export class RoleError extends Error {
this.name = "RoleError"; this.name = "RoleError";
} }
} }
/**
* Get the authenticated user and verify they have a specific permission.
* Returns the user or throws PermissionError / returns null if unauthenticated.
*/
export async function requireAuth(action?: Action): Promise<AppUser> {
const user = await getSessionUser();
if (!user) {
throw new RoleError("Authentication required");
}
if (action) {
checkPermission(user.role, action);
}
return user;
}
/**
* Returns the org scope for Prisma queries based on the current user's membership.
*/
export function withOrgScope(user: AppUser): { organizationId: string } | {} {
return user.orgId ? { organizationId: user.orgId } : {};
}

210
src/lib/auto-assign.ts Normal file
View file

@ -0,0 +1,210 @@
import { RRule } from "rrule";
import { prisma } from "./db";
type OrgSettings = {
autoAssignEnabled?: boolean;
autoAssignWindowHours?: number;
autoAssignStrategy?: "most_recent" | "prompt_user" | "manual_only";
defaultLocationId?: string | null;
};
type AssignmentResult = {
assigned: boolean;
collectionDayId?: string;
collectionDate?: Date;
reason: string;
};
/**
* Find the most recent past occurrence of a CollectionDay before `asOf`.
* Uses rrule for recurring events, or the fixed `date` for one-offs.
*/
function getMostRecentOccurrence(
collectionDay: {
isRecurring: boolean;
rrule: string | null;
dayOfWeek: number | null;
date: Date | null;
},
asOf: Date,
windowMs: number
): Date | null {
const windowStart = new Date(asOf.getTime() - windowMs);
if (!collectionDay.isRecurring && collectionDay.date) {
const d = collectionDay.date;
if (d >= windowStart && d <= asOf) return d;
return null;
}
if (collectionDay.rrule) {
try {
const rule = RRule.fromString(collectionDay.rrule);
const occurrences = rule.between(windowStart, asOf, true);
return occurrences.length > 0 ? occurrences[occurrences.length - 1] : null;
} catch {
// Fall through to dayOfWeek
}
}
if (collectionDay.dayOfWeek !== null) {
const target = collectionDay.dayOfWeek;
const d = new Date(asOf);
const diff = (d.getDay() - target + 7) % 7;
if (diff === 0 && d.getHours() >= 12) {
// If today IS the target day and it's afternoon, use today
} else if (diff === 0) {
d.setDate(d.getDate() - 7);
} else {
d.setDate(d.getDate() - diff);
}
d.setHours(0, 0, 0, 0);
if (d >= windowStart && d <= asOf) return d;
return null;
}
return null;
}
/**
* Get the previous Sunday relative to a date.
* Backward compatible with the original getPreviousSunday() in ocr.ts.
*/
export function getPreviousSunday(date: Date = new Date()): Date {
const d = new Date(date);
const day = d.getDay();
if (day !== 0) {
d.setDate(d.getDate() - day);
}
d.setHours(0, 0, 0, 0);
return d;
}
export type ImportContext = {
organizationId?: string | null;
locationId?: string | null;
collectionDayId?: string | null;
importTimestamp?: Date;
};
/**
* Auto-assign a card to an event based on org settings and collection day templates.
*/
export async function autoAssignToEvent(
cardId: string,
context: ImportContext
): Promise<AssignmentResult> {
if (context.collectionDayId) {
const cd = await prisma.collectionDay.findUnique({
where: { id: context.collectionDayId },
});
if (cd) {
const collectionDate = context.importTimestamp
? getPreviousSunday(context.importTimestamp)
: getPreviousSunday();
await prisma.responseCard.update({
where: { id: cardId },
data: {
collectionDayId: cd.id,
collectionDate,
locationId: cd.locationId,
organizationId: context.organizationId || undefined,
},
});
return {
assigned: true,
collectionDayId: cd.id,
collectionDate,
reason: "Manual pre-assignment",
};
}
}
if (!context.organizationId) {
return { assigned: false, reason: "No organization context" };
}
const org = await prisma.organization.findUnique({
where: { id: context.organizationId },
select: { settings: true },
});
const settings = (org?.settings as OrgSettings) || {};
if (settings.autoAssignStrategy === "manual_only") {
return { assigned: false, reason: "Auto-assignment disabled (manual_only)" };
}
if (settings.autoAssignEnabled === false) {
return { assigned: false, reason: "Auto-assignment disabled" };
}
const windowHours = settings.autoAssignWindowHours ?? 48;
const windowMs = windowHours * 60 * 60 * 1000;
const asOf = context.importTimestamp || new Date();
const locationId = context.locationId || settings.defaultLocationId;
const collectionDays = await prisma.collectionDay.findMany({
where: {
isActive: true,
location: { organizationId: context.organizationId },
...(locationId && { locationId }),
},
});
if (collectionDays.length === 0) {
return { assigned: false, reason: "No active collection days found" };
}
const matches: { id: string; date: Date; locationId: string }[] = [];
for (const cd of collectionDays) {
const occurrence = getMostRecentOccurrence(cd, asOf, windowMs);
if (occurrence) {
matches.push({ id: cd.id, date: occurrence, locationId: cd.locationId });
}
}
if (matches.length === 0) {
return { assigned: false, reason: `No events within ${windowHours}h window` };
}
if (matches.length === 1 || settings.autoAssignStrategy === "most_recent") {
matches.sort((a, b) => b.date.getTime() - a.date.getTime());
const best = matches[0];
await prisma.responseCard.update({
where: { id: cardId },
data: {
collectionDayId: best.id,
collectionDate: best.date,
locationId: best.locationId,
organizationId: context.organizationId,
},
});
return {
assigned: true,
collectionDayId: best.id,
collectionDate: best.date,
reason: matches.length === 1 ? "Single match" : "Most recent match",
};
}
// Multiple matches and strategy is prompt_user
await prisma.responseCard.update({
where: { id: cardId },
data: {
organizationId: context.organizationId,
locationId: locationId || undefined,
},
});
return {
assigned: false,
reason: `${matches.length} matching events - awaiting user selection`,
};
}

76
src/lib/permissions.ts Normal file
View file

@ -0,0 +1,76 @@
export type Role = "owner" | "admin" | "editor" | "reviewer" | "viewer";
export type Action =
| "cards.view"
| "cards.create"
| "cards.edit"
| "cards.delete"
| "cards.assign"
| "cards.review"
| "cards.export"
| "events.view"
| "events.manage"
| "people.view"
| "reports.view"
| "uploads.create"
| "users.view"
| "users.invite"
| "users.manage"
| "settings.view"
| "settings.edit"
| "org.manage"
| "integrations.manage";
const ROLE_HIERARCHY: Record<Role, number> = {
owner: 5,
admin: 4,
editor: 3,
reviewer: 2,
viewer: 1,
};
const PERMISSION_MAP: Record<Action, Role> = {
"cards.view": "viewer",
"cards.create": "editor",
"cards.edit": "editor",
"cards.delete": "admin",
"cards.assign": "admin",
"cards.review": "reviewer",
"cards.export": "viewer",
"events.view": "viewer",
"events.manage": "editor",
"people.view": "viewer",
"reports.view": "viewer",
"uploads.create": "editor",
"users.view": "admin",
"users.invite": "admin",
"users.manage": "owner",
"settings.view": "viewer",
"settings.edit": "admin",
"org.manage": "owner",
"integrations.manage": "admin",
};
export function can(role: string, action: Action): boolean {
const minRole = PERMISSION_MAP[action];
if (!minRole) return false;
const userLevel = ROLE_HIERARCHY[role as Role] ?? 0;
const requiredLevel = ROLE_HIERARCHY[minRole] ?? 999;
return userLevel >= requiredLevel;
}
export function requirePermission(
role: string | undefined | null,
action: Action
): void {
if (!role || !can(role, action)) {
throw new PermissionError(`Missing permission: ${action}`);
}
}
export class PermissionError extends Error {
constructor(message: string) {
super(message);
this.name = "PermissionError";
}
}

View file

@ -1,10 +1,9 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import type { AuthentikUser } from "@/app/api/auth/me/route"; import { useSession } from "next-auth/react";
import type { AppUser } from "@/lib/auth";
export type UserRole = "admin" | "reviewer" | "viewer"; export type UserRole = "admin" | "reviewer" | "viewer" | "editor" | "owner";
export type UserProfile = { export type UserProfile = {
displayName: string; displayName: string;
@ -30,9 +29,10 @@ type UserProfileContextValue = {
profile: UserProfile; profile: UserProfile;
updateProfile: (updates: Partial<UserProfile>) => void; updateProfile: (updates: Partial<UserProfile>) => void;
initials: string; initials: string;
authentikUser: AuthentikUser | null; userId?: string;
dbUser: AppUser | null;
role: UserRole; role: UserRole;
orgId?: string;
orgName?: string;
isAuthenticated: boolean; isAuthenticated: boolean;
loading: boolean; loading: boolean;
}; };
@ -62,48 +62,35 @@ function saveLocalProfile(profile: Partial<UserProfile>) {
} }
export function UserProfileProvider({ children }: { children: React.ReactNode }) { export function UserProfileProvider({ children }: { children: React.ReactNode }) {
const [authentikUser, setAuthentikUser] = React.useState<AuthentikUser | null>(null); const { data: session, status } = useSession();
const [dbUser, setDbUser] = React.useState<AppUser | null>(null);
const [localOverrides, setLocalOverrides] = React.useState<Partial<UserProfile>>({}); const [localOverrides, setLocalOverrides] = React.useState<Partial<UserProfile>>({});
const [loading, setLoading] = React.useState(true);
const [mounted, setMounted] = React.useState(false); const [mounted, setMounted] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
setMounted(true); setMounted(true);
setLocalOverrides(loadLocalProfile()); setLocalOverrides(loadLocalProfile());
fetch("/api/auth/me")
.then((r) => r.json())
.then((data) => {
if (data.authenticated && data.user) {
setAuthentikUser(data.user);
}
if (data.dbUser) {
setDbUser(data.dbUser);
}
})
.catch(() => {})
.finally(() => setLoading(false));
}, []); }, []);
const loading = status === "loading";
const isAuthenticated = status === "authenticated";
const profile = React.useMemo<UserProfile>(() => { const profile = React.useMemo<UserProfile>(() => {
const base: UserProfile = { ...DEFAULT_PROFILE }; const base: UserProfile = { ...DEFAULT_PROFILE };
if (authentikUser) { if (session?.user) {
base.displayName = authentikUser.name || authentikUser.username || ""; base.displayName = session.user.displayName || session.user.name || "";
base.email = authentikUser.email || ""; base.email = session.user.email || "";
base.avatarUrl = authentikUser.avatar || ""; base.avatarUrl = session.user.avatarUrl || session.user.image || "";
} }
return { return {
...base, ...base,
...localOverrides, ...localOverrides,
// Authentik-sourced fields take priority for name/email/avatar when present ...(session?.user?.name ? { displayName: session.user.displayName || session.user.name } : {}),
...(authentikUser?.name ? { displayName: authentikUser.name } : {}), ...(session?.user?.email ? { email: session.user.email } : {}),
...(authentikUser?.email ? { email: authentikUser.email } : {}), ...(session?.user?.avatarUrl ? { avatarUrl: session.user.avatarUrl } : {}),
...(authentikUser?.avatar ? { avatarUrl: authentikUser.avatar } : {}),
}; };
}, [authentikUser, localOverrides]); }, [session, localOverrides]);
const updateProfile = React.useCallback((updates: Partial<UserProfile>) => { const updateProfile = React.useCallback((updates: Partial<UserProfile>) => {
setLocalOverrides((prev) => { setLocalOverrides((prev) => {
@ -115,12 +102,14 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
const initials = React.useMemo(() => getInitials(profile.displayName), [profile.displayName]); const initials = React.useMemo(() => getInitials(profile.displayName), [profile.displayName]);
const isAuthenticated = !!authentikUser; const userId = session?.user?.id;
const role: UserRole = dbUser?.role ?? "admin"; const role: UserRole = (session?.user?.role as UserRole) ?? "viewer";
const orgId = session?.user?.orgId;
const orgName = session?.user?.orgName;
const value = React.useMemo( const value = React.useMemo(
() => ({ profile, updateProfile, initials, authentikUser, dbUser, role, isAuthenticated, loading }), () => ({ profile, updateProfile, initials, userId, role, orgId, orgName, isAuthenticated, loading }),
[profile, updateProfile, initials, authentikUser, dbUser, role, isAuthenticated, loading] [profile, updateProfile, initials, userId, role, orgId, orgName, isAuthenticated, loading]
); );
if (!mounted) return <>{children}</>; if (!mounted) return <>{children}</>;
@ -139,9 +128,10 @@ export function useUserProfile() {
profile: DEFAULT_PROFILE, profile: DEFAULT_PROFILE,
updateProfile: () => {}, updateProfile: () => {},
initials: "", initials: "",
authentikUser: null, userId: undefined,
dbUser: null, role: "viewer" as UserRole,
role: "admin" as UserRole, orgId: undefined,
orgName: undefined,
isAuthenticated: false, isAuthenticated: false,
loading: false, loading: false,
}; };

45
src/middleware.ts Normal file
View file

@ -0,0 +1,45 @@
import { auth } from "@/auth";
import { NextResponse } from "next/server";
const publicPaths = [
"/login",
"/signup",
"/setup",
"/api/auth",
"/api/health",
"/api/setup",
];
function isPublic(pathname: string) {
return publicPaths.some(
(p) => pathname === p || pathname.startsWith(p + "/")
);
}
export default auth((req) => {
const { pathname } = req.nextUrl;
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
pathname.includes(".")
) {
return NextResponse.next();
}
if (isPublic(pathname)) {
return NextResponse.next();
}
if (!req.auth) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

33
src/types/next-auth.d.ts vendored Normal file
View file

@ -0,0 +1,33 @@
import "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
name?: string | null;
image?: string | null;
role: string;
orgId?: string;
orgName?: string;
displayName?: string;
avatarUrl?: string;
};
}
interface User {
role?: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string;
role?: string;
orgId?: string;
orgName?: string;
orgRole?: string;
displayName?: string;
avatarUrl?: string;
}
}