diff --git a/.env.example b/.env.example
index 1c8f9b7..076d3cf 100644
--- a/.env.example
+++ b/.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=""
diff --git a/package-lock.json b/package-lock.json
index 5285992..787ae63 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index e93c6ae..378d748 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 440346a..14cd900 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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
- avatarUrl String @default("")
- role String @default("viewer")
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id @default(cuid())
+ 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,25 +191,26 @@ model ResponseCard {
howHeardOther String?
serviceAttended String?
- // Monday.com / Workflow fields
- followUp String?
- notes String?
- serviceTime String?
- planningCenter String?
- iSaidYesBookSent Boolean @default(false)
- ftGuestLetterSent Boolean @default(false)
- firstTimeGuestDate DateTime?
- salvationDate DateTime?
+ followUp String?
+ notes String?
+ serviceTime String?
+ planningCenter String?
+ iSaidYesBookSent Boolean @default(false)
+ ftGuestLetterSent Boolean @default(false)
+ firstTimeGuestDate DateTime?
+ salvationDate DateTime?
- // Assignment / Review workflow
- assignedToId String?
- assignedById String?
- assignedAt DateTime?
- reviewedById String?
- reviewedAt DateTime?
- reviewNotes String?
+ assignedToId String?
+ assignedById String?
+ assignedAt DateTime?
+ reviewedById String?
+ reviewedAt DateTime?
+ reviewNotes String?
+
+ assignedTo User? @relation("AssignedCards", fields: [assignedToId], references: [id])
+ assignedBy User? @relation("AssignorCards", fields: [assignedById], references: [id])
+ reviewedBy User? @relation("ReviewedCards", fields: [reviewedById], references: [id])
- // Meta
sourceFile String?
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,19 +238,25 @@ model ResponseCard {
@@index([mondayItemId])
@@index([assignedToId])
@@index([reviewedById])
+ @@index([organizationId])
+ @@index([locationId])
+ @@index([collectionDayId])
}
model ProcessingJob {
- id String @id @default(cuid())
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- fileName String
- filePath String
- status String @default("queued")
- totalPages Int @default(0)
- processed Int @default(0)
- error String?
- cardIds Json?
+ id String @id @default(cuid())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ fileName String
+ filePath String
+ status String @default("queued")
+ totalPages Int @default(0)
+ 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])
+}
diff --git a/prisma/seed-migration.ts b/prisma/seed-migration.ts
new file mode 100644
index 0000000..f6b58bb
--- /dev/null
+++ b/prisma/seed-migration.ts
@@ -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();
+ });
diff --git a/src/app/(auth)/invite/[token]/page.tsx b/src/app/(auth)/invite/[token]/page.tsx
new file mode 100644
index 0000000..0258064
--- /dev/null
+++ b/src/app/(auth)/invite/[token]/page.tsx
@@ -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 (
+
+
+
Verifying invitation...
+
+ );
+ }
+
+ if (status === "invalid") {
+ return (
+
+
+
Invalid Invitation
+
+ This invitation link is invalid, expired, or has already been used.
+
+
router.push("/login")}
+ >
+ Go to Login
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
You're Invited
+
+ You've been invited to join as a {invitation?.role} .
+
+
+ Invitation for: {invitation?.email}
+
+
+
Accept & Create Account
+
+
+ );
+}
diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx
new file mode 100644
index 0000000..6eac8cf
--- /dev/null
+++ b/src/app/(auth)/layout.tsx
@@ -0,0 +1,12 @@
+export default function AuthLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx
new file mode 100644
index 0000000..baf717d
--- /dev/null
+++ b/src/app/(auth)/login/page.tsx
@@ -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 (
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
Welcome back
+
+ Sign in to your Echo OCR account
+
+
+
+ {(error || formError) && (
+
+ {formError || "Authentication failed. Please try again."}
+
+ )}
+
+
+
+ {hasSSO && (
+ <>
+
+
+ or
+
+
+
+
signIn("authentik", { callbackUrl })}
+ >
+ Sign in with SSO
+
+ >
+ )}
+
+
+ Don't have an account?{" "}
+
+ Sign up with an invite
+
+
+
+ );
+}
diff --git a/src/app/(auth)/setup/page.tsx b/src/app/(auth)/setup/page.tsx
new file mode 100644
index 0000000..4b9abbb
--- /dev/null
+++ b/src/app/(auth)/setup/page.tsx
@@ -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) {
+ 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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
Set up Echo OCR
+
+ Configure your instance in a few steps
+
+
+
+
+ {STEPS.map((step, i) => {
+ const done = currentStep > i;
+ const active = currentStep === i;
+ return (
+
+
+ {done ? : i + 1}
+
+
+ {step.label}
+
+ {i < STEPS.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {currentStep === 0 && (
+
+ )}
+
+ {currentStep === 1 && (
+
+ )}
+
+ {currentStep === 2 && (
+
+ )}
+
+ );
+}
diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx
new file mode 100644
index 0000000..9a173eb
--- /dev/null
+++ b/src/app/(auth)/signup/page.tsx
@@ -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 (
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
Create account
+
+ {token
+ ? "Complete your account setup"
+ : "You need an invitation to sign up"}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ Already have an account?{" "}
+
+ Sign in
+
+
+
+ );
+}
diff --git a/src/app/cards/[id]/page.tsx b/src/app/(dashboard)/cards/[id]/page.tsx
similarity index 99%
rename from src/app/cards/[id]/page.tsx
rename to src/app/(dashboard)/cards/[id]/page.tsx
index a15eb28..db4625d 100644
--- a/src/app/cards/[id]/page.tsx
+++ b/src/app/(dashboard)/cards/[id]/page.tsx
@@ -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([]);
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() {
- Assigned to {card.assignedToId === dbUser?.id ? "you" : (card.assignedToId)}
+ Assigned to {card.assignedToId === userId ? "you" : (card.assignedToId)}
{card.assignedAt && (
<> on {new Date(card.assignedAt).toLocaleDateString()}>
)}
diff --git a/src/app/page.tsx b/src/app/(dashboard)/cards/page.tsx
similarity index 100%
rename from src/app/page.tsx
rename to src/app/(dashboard)/cards/page.tsx
diff --git a/src/app/(dashboard)/events/[id]/page.tsx b/src/app/(dashboard)/events/[id]/page.tsx
new file mode 100644
index 0000000..1b36d59
--- /dev/null
+++ b/src/app/(dashboard)/events/[id]/page.tsx
@@ -0,0 +1,17 @@
+import { CalendarDays } from "lucide-react";
+import { Header } from "@/components/layout/header";
+
+export default function EventDetailPage() {
+ return (
+
+
+
+ Event detail view coming soon.
+
+
+ );
+}
diff --git a/src/app/(dashboard)/events/page.tsx b/src/app/(dashboard)/events/page.tsx
new file mode 100644
index 0000000..a8c4584
--- /dev/null
+++ b/src/app/(dashboard)/events/page.tsx
@@ -0,0 +1,22 @@
+import { CalendarDays } from "lucide-react";
+import { Header } from "@/components/layout/header";
+
+export default function EventsPage() {
+ return (
+
+
+
+
+
No collection days yet
+
+ Collection days will be available once organizations and locations are
+ configured. Check back after setup is complete.
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx
new file mode 100644
index 0000000..500ec94
--- /dev/null
+++ b/src/app/(dashboard)/layout.tsx
@@ -0,0 +1,9 @@
+import { AppShell } from "@/components/layout/app-shell";
+
+export default function DashboardLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return {children} ;
+}
diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx
new file mode 100644
index 0000000..35fa1f3
--- /dev/null
+++ b/src/app/(dashboard)/page.tsx
@@ -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)[key] === value);
+ return match?._count.id ?? 0;
+}
+
+export default function DashboardHomePage() {
+ const [stats, setStats] = useState(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 (
+
+
+
+
+ {summaryCards.map((card) =>
+ loading ? (
+
+ ) : (
+
+
+
+ {card.label}
+
+
+
+
+ {card.value.toLocaleString()}
+
+
+ )
+ )}
+
+
+
+
Quick Actions
+
+ {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 = (
+ <>
+
+
+
{action.label}
+
+ {action.description}
+
+
+
+ >
+ );
+
+ if (action.href) {
+ return (
+
+ {inner}
+
+ );
+ }
+ return (
+
+ {inner}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/people/page.tsx b/src/app/(dashboard)/people/page.tsx
new file mode 100644
index 0000000..f1ebee6
--- /dev/null
+++ b/src/app/(dashboard)/people/page.tsx
@@ -0,0 +1,22 @@
+import { Users } from "lucide-react";
+import { Header } from "@/components/layout/header";
+
+export default function PeoplePage() {
+ return (
+
+
+
+
+
People directory
+
+ A deduplicated contact list aggregated from your response cards will
+ appear here once the people module is built out.
+
+
+
+ );
+}
diff --git a/src/app/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx
similarity index 61%
rename from src/app/profile/page.tsx
rename to src/app/(dashboard)/profile/page.tsx
index d39791b..afeff6a 100644
--- a/src/app/profile/page.tsx
+++ b/src/app/(dashboard)/profile/page.tsx
@@ -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 = {
+ 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 (
+
+ document.querySelector('input[type="file"]')?.click()}
+ >
+
+ Upload
+
+ {form.avatarUrl && (
+
+
+ Remove
+
)}
- {!avatarFromAuthentik && (
-
- document.querySelector('input[type="file"]')?.click()}
- >
-
- Upload
-
- {form.avatarUrl && (
-
-
- Remove
-
- )}
-
- )}
- {avatarFromAuthentik && (
-
- Update your avatar in{" "}
-
- Authentik
-
-
- )}
- {!avatarFromAuthentik && (
-
- JPG, PNG or WebP. Max 2 MB.
-
- )}
+
+ JPG, PNG or WebP. Max 2 MB.
+
@@ -213,9 +184,7 @@ export default function ProfilePage() {
Personal Information
- {isAuthenticated
- ? "Some fields are synced from Authentik"
- : "Update your name, email, and other details"}
+ Update your name, email, and other details
@@ -224,54 +193,29 @@ export default function ProfilePage() {
Display Name
- {nameFromAuthentik && (
- SSO
- )}
handleChange("displayName", e.target.value)}
placeholder="Your name"
- readOnly={nameFromAuthentik}
- className={nameFromAuthentik ? "bg-muted/40 cursor-default" : ""}
/>
Email
- {emailFromAuthentik && (
- SSO
- )}
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"
/>
- {isAuthenticated && authentikUser?.groups && authentikUser.groups.length > 0 && (
-
-
-
- Groups
- SSO
-
-
- {authentikUser.groups.map((group) => (
-
- {group}
-
- ))}
-
-
- )}
-
diff --git a/src/app/(dashboard)/reports/page.tsx b/src/app/(dashboard)/reports/page.tsx
new file mode 100644
index 0000000..b91ec4d
--- /dev/null
+++ b/src/app/(dashboard)/reports/page.tsx
@@ -0,0 +1,22 @@
+import { BarChart3 } from "lucide-react";
+import { Header } from "@/components/layout/header";
+
+export default function ReportsPage() {
+ return (
+
+
+
+
+
Reports & Analytics
+
+ Detailed reports, trends over time, and data exports will be available
+ here in a future update.
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/settings/locations/page.tsx b/src/app/(dashboard)/settings/locations/page.tsx
new file mode 100644
index 0000000..defaf57
--- /dev/null
+++ b/src/app/(dashboard)/settings/locations/page.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { MapPin } from "lucide-react";
+import { Header } from "@/components/layout/header";
+
+export default function LocationsSettingsPage() {
+ return (
+
+
+
+ Location management will be available after the org model is set up.
+
+
+ );
+}
diff --git a/src/app/(dashboard)/settings/organization/page.tsx b/src/app/(dashboard)/settings/organization/page.tsx
new file mode 100644
index 0000000..4bf6e4e
--- /dev/null
+++ b/src/app/(dashboard)/settings/organization/page.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { Building2 } from "lucide-react";
+import { Header } from "@/components/layout/header";
+
+export default function OrganizationSettingsPage() {
+ return (
+
+
+
+ Organization settings will be available after the org model is set up.
+
+
+ );
+}
diff --git a/src/app/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx
similarity index 100%
rename from src/app/settings/page.tsx
rename to src/app/(dashboard)/settings/page.tsx
diff --git a/src/app/(dashboard)/settings/users/page.tsx b/src/app/(dashboard)/settings/users/page.tsx
new file mode 100644
index 0000000..cbe68dc
--- /dev/null
+++ b/src/app/(dashboard)/settings/users/page.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { UserCog } from "lucide-react";
+import { Header } from "@/components/layout/header";
+
+export default function UsersSettingsPage() {
+ return (
+
+
+
+ User management and invitations will be available after the auth system
+ is set up.
+
+
+ );
+}
diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..86c9f3d
--- /dev/null
+++ b/src/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,3 @@
+import { handlers } from "@/auth";
+
+export const { GET, POST } = handlers;
diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts
new file mode 100644
index 0000000..bb388bf
--- /dev/null
+++ b/src/app/api/auth/register/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/collection-days/route.ts b/src/app/api/collection-days/route.ts
new file mode 100644
index 0000000..d7bfa90
--- /dev/null
+++ b/src/app/api/collection-days/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/invitations/route.ts b/src/app/api/invitations/route.ts
new file mode 100644
index 0000000..6f9cb14
--- /dev/null
+++ b/src/app/api/invitations/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/invitations/verify/route.ts b/src/app/api/invitations/verify/route.ts
new file mode 100644
index 0000000..6dd79f8
--- /dev/null
+++ b/src/app/api/invitations/verify/route.ts
@@ -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,
+ });
+}
diff --git a/src/app/api/locations/route.ts b/src/app/api/locations/route.ts
new file mode 100644
index 0000000..121953c
--- /dev/null
+++ b/src/app/api/locations/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/organizations/route.ts b/src/app/api/organizations/route.ts
new file mode 100644
index 0000000..e53406a
--- /dev/null
+++ b/src/app/api/organizations/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/setup/route.ts b/src/app/api/setup/route.ts
new file mode 100644
index 0000000..f594855
--- /dev/null
+++ b/src/app/api/setup/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index c959b51..d2dbe79 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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);
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 8748872..d6065c3 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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 (
-
-
-
-
-
-
- {children}
-
-
-
-
+ {children}
);
diff --git a/src/auth.ts b/src/auth.ts
new file mode 100644
index 0000000..daf82d7
--- /dev/null
+++ b/src/auth.ts
@@ -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;
+ },
+ },
+});
diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx
new file mode 100644
index 0000000..875d294
--- /dev/null
+++ b/src/components/layout/app-shell.tsx
@@ -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 (
+
+ );
+}
+
+export function AppShell({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
new file mode 100644
index 0000000..d996335
--- /dev/null
+++ b/src/components/layout/sidebar.tsx
@@ -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({
+ 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 (
+ {children}
+ );
+}
+
+function NavItem({
+ href,
+ label,
+ icon: Icon,
+ active,
+ collapsed,
+}: {
+ href: string;
+ label: string;
+ icon: React.ComponentType<{ className?: string }>;
+ active: boolean;
+ collapsed: boolean;
+}) {
+ const content = (
+
+
+ {!collapsed && {label} }
+
+ );
+
+ if (collapsed) {
+ return (
+
+ }>{content}
+
+ {label}
+
+
+ );
+ }
+
+ return content;
+}
+
+export function Sidebar() {
+ const pathname = usePathname();
+ const { collapsed, toggle } = useSidebar();
+
+ return (
+
+
+
+ {navItems.map((item) => (
+
+ ))}
+
+
+
+
+ {bottomItems.map((item) => (
+
+ ))}
+
+ {collapsed ? (
+
+ ) : (
+
+ )}
+ {!collapsed && Collapse }
+
+
+
+ );
+}
diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx
index ba21947..5390d02 100644
--- a/src/components/layout/top-bar.tsx
+++ b/src/components/layout/top-bar.tsx
@@ -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 (