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:
parent
0ab9932599
commit
d3e7374439
44 changed files with 3029 additions and 223 deletions
15
.env.example
15
.env.example
|
|
@ -17,7 +17,18 @@ MINIO_BUCKET="echos-ocr"
|
|||
# Folder Watch (optional, mount a host path into the container)
|
||||
WATCH_DIR=""
|
||||
|
||||
# Authentik SSO (optional — user info comes from forward-auth headers automatically;
|
||||
# these are only needed to enrich profiles with avatars via the Authentik API)
|
||||
# Auth.js (required — generate with: npx auth secret)
|
||||
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_API_TOKEN=""
|
||||
|
||||
# Environment indicator (set to "staging" for staging deployments)
|
||||
NEXT_PUBLIC_ENV=""
|
||||
|
|
|
|||
162
package-lock.json
generated
162
package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"@ai-sdk/gateway": "^3.0.66",
|
||||
"@ai-sdk/openai-compatible": "^2.0.35",
|
||||
"@auth/prisma-adapter": "^2.11.2",
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@base-ui/react": "^1.2.0",
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
"@prisma/client": "^7.4.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"ai": "^6.0.116",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"chokidar": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -28,6 +30,7 @@
|
|||
"lucide-react": "^0.577.0",
|
||||
"mailparser": "^3.9.6",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf2pic": "^3.2.0",
|
||||
|
|
@ -35,6 +38,7 @@
|
|||
"prisma": "^7.4.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"rrule": "^2.8.1",
|
||||
"shadcn": "^4.0.2",
|
||||
"sharp": "^0.34.5",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
@ -44,6 +48,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^25.4.0",
|
||||
"@types/pg": "^8.18.0",
|
||||
|
|
@ -151,6 +156,47 @@
|
|||
"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": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
|
||||
|
|
@ -2995,6 +3041,15 @@
|
|||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
|
|
@ -4829,6 +4884,13 @@
|
|||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -5968,6 +6030,15 @@
|
|||
"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": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
|
|
@ -8770,6 +8841,15 @@
|
|||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -10102,6 +10182,15 @@
|
|||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||
|
|
@ -10542,15 +10658,6 @@
|
|||
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
|
||||
"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": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
||||
|
|
@ -10602,6 +10709,15 @@
|
|||
"integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==",
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
@ -11369,6 +11485,25 @@
|
|||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
|
@ -11942,6 +12077,15 @@
|
|||
"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": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"dependencies": {
|
||||
"@ai-sdk/gateway": "^3.0.66",
|
||||
"@ai-sdk/openai-compatible": "^2.0.35",
|
||||
"@auth/prisma-adapter": "^2.11.2",
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@base-ui/react": "^1.2.0",
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
"@prisma/client": "^7.4.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"ai": "^6.0.116",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"chokidar": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -32,6 +34,7 @@
|
|||
"lucide-react": "^0.577.0",
|
||||
"mailparser": "^3.9.6",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf2pic": "^3.2.0",
|
||||
|
|
@ -39,6 +42,7 @@
|
|||
"prisma": "^7.4.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"rrule": "^2.8.1",
|
||||
"shadcn": "^4.0.2",
|
||||
"sharp": "^0.34.5",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
@ -48,6 +52,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^25.4.0",
|
||||
"@types/pg": "^8.18.0",
|
||||
|
|
|
|||
|
|
@ -7,24 +7,162 @@ datasource db {
|
|||
provider = "postgresql"
|
||||
}
|
||||
|
||||
// ─── Auth.js tables ──────────────────────────────────────────
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
authentikUid String @unique
|
||||
username String
|
||||
displayName String
|
||||
email String
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
hashedPassword String?
|
||||
username String?
|
||||
displayName String?
|
||||
avatarUrl String @default("")
|
||||
role String @default("viewer")
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Personal Info (Response Card side)
|
||||
name String?
|
||||
gender String?
|
||||
dateOfBirth String?
|
||||
|
|
@ -43,7 +181,6 @@ model ResponseCard {
|
|||
prayerForTeam Boolean @default(false)
|
||||
prayerConfidential Boolean @default(false)
|
||||
|
||||
// Survey Info (Easter Survey side)
|
||||
messageTopics Json?
|
||||
messageTopicsOther String?
|
||||
nextStep Json?
|
||||
|
|
@ -54,7 +191,6 @@ model ResponseCard {
|
|||
howHeardOther String?
|
||||
serviceAttended String?
|
||||
|
||||
// Monday.com / Workflow fields
|
||||
followUp String?
|
||||
notes String?
|
||||
serviceTime String?
|
||||
|
|
@ -64,7 +200,6 @@ model ResponseCard {
|
|||
firstTimeGuestDate DateTime?
|
||||
salvationDate DateTime?
|
||||
|
||||
// Assignment / Review workflow
|
||||
assignedToId String?
|
||||
assignedById String?
|
||||
assignedAt DateTime?
|
||||
|
|
@ -72,7 +207,10 @@ model ResponseCard {
|
|||
reviewedAt DateTime?
|
||||
reviewNotes String?
|
||||
|
||||
// Meta
|
||||
assignedTo User? @relation("AssignedCards", fields: [assignedToId], references: [id])
|
||||
assignedBy User? @relation("AssignorCards", fields: [assignedById], references: [id])
|
||||
reviewedBy User? @relation("ReviewedCards", fields: [reviewedById], references: [id])
|
||||
|
||||
sourceFile String?
|
||||
frontImagePath String?
|
||||
backImagePath String?
|
||||
|
|
@ -84,6 +222,15 @@ model ResponseCard {
|
|||
|
||||
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([reviewStatus])
|
||||
@@index([name])
|
||||
|
|
@ -91,6 +238,9 @@ model ResponseCard {
|
|||
@@index([mondayItemId])
|
||||
@@index([assignedToId])
|
||||
@@index([reviewedById])
|
||||
@@index([organizationId])
|
||||
@@index([locationId])
|
||||
@@index([collectionDayId])
|
||||
}
|
||||
|
||||
model ProcessingJob {
|
||||
|
|
@ -104,6 +254,9 @@ model ProcessingJob {
|
|||
processed Int @default(0)
|
||||
error String?
|
||||
cardIds Json?
|
||||
organizationId String?
|
||||
|
||||
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
|
@ -147,13 +300,15 @@ model AppSettings {
|
|||
model ActivityLog {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
cardId String
|
||||
cardId String?
|
||||
action String
|
||||
source String
|
||||
summary String
|
||||
changes Json?
|
||||
userId String?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([cardId, createdAt])
|
||||
}
|
||||
|
||||
|
|
@ -170,7 +325,22 @@ model Notification {
|
|||
meta Json?
|
||||
userId String?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([read, dismissed, createdAt])
|
||||
@@index([cardId])
|
||||
@@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
180
prisma/seed-migration.ts
Normal 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();
|
||||
});
|
||||
76
src/app/(auth)/invite/[token]/page.tsx
Normal file
76
src/app/(auth)/invite/[token]/page.tsx
Normal 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're Invited</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
You'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 & Create Account</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/app/(auth)/layout.tsx
Normal file
12
src/app/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
src/app/(auth)/login/page.tsx
Normal file
142
src/app/(auth)/login/page.tsx
Normal 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't have an account?{" "}
|
||||
<Link href="/signup" className="font-medium text-primary hover:underline">
|
||||
Sign up with an invite
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
src/app/(auth)/setup/page.tsx
Normal file
298
src/app/(auth)/setup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
src/app/(auth)/signup/page.tsx
Normal file
183
src/app/(auth)/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -131,7 +131,7 @@ export default function CardDetailPage() {
|
|||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
const { role, dbUser } = useUserProfile();
|
||||
const { role, userId } = useUserProfile();
|
||||
const isAdmin = role === "admin";
|
||||
const isReviewer = role === "reviewer";
|
||||
const isViewer = role === "viewer";
|
||||
|
|
@ -150,7 +150,7 @@ export default function CardDetailPage() {
|
|||
const [users, setUsers] = React.useState<AssignableUser[]>([]);
|
||||
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 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">
|
||||
<User className="size-4 text-purple-600 dark:text-purple-400" />
|
||||
<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 && (
|
||||
<> on {new Date(card.assignedAt).toLocaleDateString()}</>
|
||||
)}
|
||||
17
src/app/(dashboard)/events/[id]/page.tsx
Normal file
17
src/app/(dashboard)/events/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/(dashboard)/events/page.tsx
Normal file
22
src/app/(dashboard)/events/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/(dashboard)/layout.tsx
Normal file
9
src/app/(dashboard)/layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { AppShell } from "@/components/layout/app-shell";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
179
src/app/(dashboard)/page.tsx
Normal file
179
src/app/(dashboard)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/(dashboard)/people/page.tsx
Normal file
22
src/app/(dashboard)/people/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,13 +31,12 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|||
import { useUserProfile } from "@/lib/user-profile";
|
||||
|
||||
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({
|
||||
jobTitle: profile.jobTitle,
|
||||
company: profile.company,
|
||||
bio: profile.bio,
|
||||
// Only editable when NOT coming from Authentik
|
||||
displayName: profile.displayName,
|
||||
email: profile.email,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
|
|
@ -61,17 +60,14 @@ export default function ProfilePage() {
|
|||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const updates: Record<string, string> = {
|
||||
updateProfile({
|
||||
jobTitle: form.jobTitle,
|
||||
company: form.company,
|
||||
bio: form.bio,
|
||||
};
|
||||
if (!isAuthenticated) {
|
||||
updates.displayName = form.displayName;
|
||||
updates.email = form.email;
|
||||
updates.avatarUrl = form.avatarUrl;
|
||||
}
|
||||
updateProfile(updates);
|
||||
displayName: form.displayName,
|
||||
email: form.email,
|
||||
avatarUrl: form.avatarUrl,
|
||||
});
|
||||
setDirty(false);
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
<Shield className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
<p className="text-sm text-emerald-700 dark:text-emerald-300">
|
||||
Signed in via Authentik as <span className="font-medium">{authentikUser?.username}</span>.
|
||||
Name, email, and avatar are managed by your identity provider.
|
||||
Signed in as <span className="font-medium">{profile.displayName || profile.email}</span>.
|
||||
Role: <Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0">{role}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -137,9 +129,7 @@ export default function ProfilePage() {
|
|||
<CardHeader>
|
||||
<CardTitle className="text-base">Photo</CardTitle>
|
||||
<CardDescription>
|
||||
{avatarFromAuthentik
|
||||
? "Managed by Authentik"
|
||||
: "Your profile picture is visible in the header"}
|
||||
Your profile picture is visible in the header
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
|
|
@ -152,7 +142,6 @@ export default function ProfilePage() {
|
|||
{initials || <UserCircle className="size-10 text-muted-foreground" />}
|
||||
</AvatarFallback>
|
||||
</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">
|
||||
<Camera className="size-6 text-white" />
|
||||
<input
|
||||
|
|
@ -162,9 +151,7 @@ export default function ProfilePage() {
|
|||
onChange={handleAvatarUpload}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{!avatarFromAuthentik && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -187,25 +174,9 @@ export default function ProfilePage() {
|
|||
</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>
|
||||
</Card>
|
||||
|
||||
|
|
@ -213,9 +184,7 @@ export default function ProfilePage() {
|
|||
<CardHeader>
|
||||
<CardTitle className="text-base">Personal Information</CardTitle>
|
||||
<CardDescription>
|
||||
{isAuthenticated
|
||||
? "Some fields are synced from Authentik"
|
||||
: "Update your name, email, and other details"}
|
||||
Update your name, email, and other details
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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">
|
||||
<UserCircle className="size-3" />
|
||||
Display Name
|
||||
{nameFromAuthentik && (
|
||||
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5 py-0">SSO</Badge>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
value={form.displayName}
|
||||
onChange={(e) => handleChange("displayName", e.target.value)}
|
||||
placeholder="Your name"
|
||||
readOnly={nameFromAuthentik}
|
||||
className={nameFromAuthentik ? "bg-muted/40 cursor-default" : ""}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Mail className="size-3" />
|
||||
Email
|
||||
{emailFromAuthentik && (
|
||||
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5 py-0">SSO</Badge>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => handleChange("email", e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
readOnly={emailFromAuthentik}
|
||||
className={emailFromAuthentik ? "bg-muted/40 cursor-default" : ""}
|
||||
readOnly
|
||||
className="bg-muted/40 cursor-default"
|
||||
/>
|
||||
</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>
|
||||
<Label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
22
src/app/(dashboard)/reports/page.tsx
Normal file
22
src/app/(dashboard)/reports/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
19
src/app/(dashboard)/settings/locations/page.tsx
Normal file
19
src/app/(dashboard)/settings/locations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/app/(dashboard)/settings/organization/page.tsx
Normal file
19
src/app/(dashboard)/settings/organization/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/app/(dashboard)/settings/users/page.tsx
Normal file
20
src/app/(dashboard)/settings/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
105
src/app/api/auth/register/route.ts
Normal file
105
src/app/api/auth/register/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
78
src/app/api/collection-days/route.ts
Normal file
78
src/app/api/collection-days/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
94
src/app/api/invitations/route.ts
Normal file
94
src/app/api/invitations/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/api/invitations/verify/route.ts
Normal file
23
src/app/api/invitations/verify/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
58
src/app/api/locations/route.ts
Normal file
58
src/app/api/locations/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
57
src/app/api/organizations/route.ts
Normal file
57
src/app/api/organizations/route.ts
Normal 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
148
src/app/api/setup/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +72,8 @@
|
|||
--chart-4: oklch(0.65 0.06 200);
|
||||
--chart-5: oklch(0.45 0.10 140);
|
||||
--radius: 0.75rem;
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-collapsed-width: 64px;
|
||||
--sidebar: oklch(1 0 0 / 50%);
|
||||
--sidebar-foreground: oklch(0.20 0.005 90);
|
||||
--sidebar-primary: oklch(0.20 0.005 90);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { Metadata } from "next";
|
|||
import { Quicksand } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { TopBar } from "@/components/layout/top-bar";
|
||||
|
||||
const quicksand = Quicksand({
|
||||
variable: "--font-sans",
|
||||
|
|
@ -23,17 +22,7 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${quicksand.variable} font-sans antialiased`}>
|
||||
<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>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
97
src/auth.ts
Normal file
97
src/auth.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
33
src/components/layout/app-shell.tsx
Normal file
33
src/components/layout/app-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
src/components/layout/sidebar.tsx
Normal file
190
src/components/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Upload,
|
||||
|
|
@ -52,12 +53,19 @@ export function TopBar() {
|
|||
|
||||
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">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<div className="flex size-9 items-center justify-center rounded-xl gradient-banner shadow-sm">
|
||||
<ScanLine className="size-[18px] text-white" />
|
||||
</div>
|
||||
<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">
|
||||
<Tooltip>
|
||||
|
|
@ -153,7 +161,10 @@ export function TopBar() {
|
|||
Theme: {themeLabel}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Toaster } from "sonner";
|
||||
|
|
@ -7,6 +8,7 @@ import { UserProfileProvider } from "@/lib/user-profile";
|
|||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<UserProfileProvider>
|
||||
<TooltipProvider>
|
||||
|
|
@ -15,5 +17,6 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||
</TooltipProvider>
|
||||
</UserProfileProvider>
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,72 @@
|
|||
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 = {
|
||||
id: string;
|
||||
authentikUid: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
authentikUid: string | null;
|
||||
username: string | null;
|
||||
displayName: string | null;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
role: "admin" | "reviewer" | "viewer";
|
||||
role: "admin" | "reviewer" | "viewer" | "editor" | "owner";
|
||||
orgId?: string;
|
||||
};
|
||||
|
||||
type CacheEntry = { user: AppUser; ts: number };
|
||||
const userCache = new Map<string, CacheEntry>();
|
||||
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());
|
||||
if (lower.some((g) => g.includes("admin"))) return "admin";
|
||||
if (lower.some((g) => g.includes("reviewer") || g.includes("review"))) return "reviewer";
|
||||
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> {
|
||||
const sessionUser = await getSessionUser();
|
||||
if (sessionUser) return sessionUser;
|
||||
|
||||
const uid = headers.get("x-authentik-uid") ?? "";
|
||||
const username = headers.get("x-authentik-username") ?? "";
|
||||
const email = headers.get("x-authentik-email") ?? "";
|
||||
|
|
@ -82,3 +127,25 @@ export class RoleError extends Error {
|
|||
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
210
src/lib/auto-assign.ts
Normal 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
76
src/lib/permissions.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AuthentikUser } from "@/app/api/auth/me/route";
|
||||
import type { AppUser } from "@/lib/auth";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export type UserRole = "admin" | "reviewer" | "viewer";
|
||||
export type UserRole = "admin" | "reviewer" | "viewer" | "editor" | "owner";
|
||||
|
||||
export type UserProfile = {
|
||||
displayName: string;
|
||||
|
|
@ -30,9 +29,10 @@ type UserProfileContextValue = {
|
|||
profile: UserProfile;
|
||||
updateProfile: (updates: Partial<UserProfile>) => void;
|
||||
initials: string;
|
||||
authentikUser: AuthentikUser | null;
|
||||
dbUser: AppUser | null;
|
||||
userId?: string;
|
||||
role: UserRole;
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
};
|
||||
|
|
@ -62,48 +62,35 @@ function saveLocalProfile(profile: Partial<UserProfile>) {
|
|||
}
|
||||
|
||||
export function UserProfileProvider({ children }: { children: React.ReactNode }) {
|
||||
const [authentikUser, setAuthentikUser] = React.useState<AuthentikUser | null>(null);
|
||||
const [dbUser, setDbUser] = React.useState<AppUser | null>(null);
|
||||
const { data: session, status } = useSession();
|
||||
const [localOverrides, setLocalOverrides] = React.useState<Partial<UserProfile>>({});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
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 base: UserProfile = { ...DEFAULT_PROFILE };
|
||||
|
||||
if (authentikUser) {
|
||||
base.displayName = authentikUser.name || authentikUser.username || "";
|
||||
base.email = authentikUser.email || "";
|
||||
base.avatarUrl = authentikUser.avatar || "";
|
||||
if (session?.user) {
|
||||
base.displayName = session.user.displayName || session.user.name || "";
|
||||
base.email = session.user.email || "";
|
||||
base.avatarUrl = session.user.avatarUrl || session.user.image || "";
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...localOverrides,
|
||||
// Authentik-sourced fields take priority for name/email/avatar when present
|
||||
...(authentikUser?.name ? { displayName: authentikUser.name } : {}),
|
||||
...(authentikUser?.email ? { email: authentikUser.email } : {}),
|
||||
...(authentikUser?.avatar ? { avatarUrl: authentikUser.avatar } : {}),
|
||||
...(session?.user?.name ? { displayName: session.user.displayName || session.user.name } : {}),
|
||||
...(session?.user?.email ? { email: session.user.email } : {}),
|
||||
...(session?.user?.avatarUrl ? { avatarUrl: session.user.avatarUrl } : {}),
|
||||
};
|
||||
}, [authentikUser, localOverrides]);
|
||||
}, [session, localOverrides]);
|
||||
|
||||
const updateProfile = React.useCallback((updates: Partial<UserProfile>) => {
|
||||
setLocalOverrides((prev) => {
|
||||
|
|
@ -115,12 +102,14 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
|
|||
|
||||
const initials = React.useMemo(() => getInitials(profile.displayName), [profile.displayName]);
|
||||
|
||||
const isAuthenticated = !!authentikUser;
|
||||
const role: UserRole = dbUser?.role ?? "admin";
|
||||
const userId = session?.user?.id;
|
||||
const role: UserRole = (session?.user?.role as UserRole) ?? "viewer";
|
||||
const orgId = session?.user?.orgId;
|
||||
const orgName = session?.user?.orgName;
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ profile, updateProfile, initials, authentikUser, dbUser, role, isAuthenticated, loading }),
|
||||
[profile, updateProfile, initials, authentikUser, dbUser, role, isAuthenticated, loading]
|
||||
() => ({ profile, updateProfile, initials, userId, role, orgId, orgName, isAuthenticated, loading }),
|
||||
[profile, updateProfile, initials, userId, role, orgId, orgName, isAuthenticated, loading]
|
||||
);
|
||||
|
||||
if (!mounted) return <>{children}</>;
|
||||
|
|
@ -139,9 +128,10 @@ export function useUserProfile() {
|
|||
profile: DEFAULT_PROFILE,
|
||||
updateProfile: () => {},
|
||||
initials: "",
|
||||
authentikUser: null,
|
||||
dbUser: null,
|
||||
role: "admin" as UserRole,
|
||||
userId: undefined,
|
||||
role: "viewer" as UserRole,
|
||||
orgId: undefined,
|
||||
orgName: undefined,
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
};
|
||||
|
|
|
|||
45
src/middleware.ts
Normal file
45
src/middleware.ts
Normal 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
33
src/types/next-auth.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue