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. +

+ +
+ ); + } + + return ( +
+
+ +
+ +

You're Invited

+

+ You've been invited to join as a {invitation?.role}. +

+

+ Invitation for: {invitation?.email} +

+ + + +
+ ); +} 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 ( +
+
+
{children}
+
+ ); +} 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."} +
+ )} + +
+
+ +
+ + setEmail(e.target.value)} + required + autoComplete="email" + className="pl-10" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + className="pl-10" + /> +
+
+ + +
+ + {hasSSO && ( + <> +
+ + or + +
+ + + + )} + +

+ 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 && ( +
+

+ Create the owner account for this instance. +

+
+ + setAccount((p) => ({ ...p, displayName: e.target.value }))} + required + placeholder="John Smith" + /> +
+
+ + setAccount((p) => ({ ...p, email: e.target.value }))} + required + placeholder="you@church.org" + /> +
+
+ + setAccount((p) => ({ ...p, password: e.target.value }))} + required + minLength={8} + placeholder="Min 8 characters" + /> +
+
+ + setAccount((p) => ({ ...p, confirmPassword: e.target.value }))} + required + minLength={8} + placeholder="Confirm password" + /> +
+ +
+ )} + + {currentStep === 1 && ( +
+

+ Set up your organization. +

+
+ + setOrg((p) => ({ ...p, name: e.target.value }))} + required + placeholder="Grace Community Church" + /> +
+
+ + setOrg((p) => ({ ...p, type: e.target.value }))} + placeholder="church" + /> +
+
+ + setOrg((p) => ({ ...p, timezone: e.target.value }))} + placeholder="America/Chicago" + /> +
+ +
+ )} + + {currentStep === 2 && ( +
+

+ Add your first location or campus. +

+
+ + setLocation((p) => ({ ...p, name: e.target.value }))} + required + placeholder="Main Campus" + /> +
+
+ + setLocation((p) => ({ ...p, address: e.target.value }))} + placeholder="123 Church St, City, ST 12345" + /> +
+ +
+ )} +
+ ); +} 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} +
+ )} + +
+
+ +
+ + update("displayName", e.target.value)} + required + className="pl-10" + /> +
+
+ +
+ +
+ + update("email", e.target.value)} + required + autoComplete="email" + className="pl-10" + /> +
+
+ +
+ +
+ + update("password", e.target.value)} + required + minLength={8} + autoComplete="new-password" + className="pl-10" + /> +
+
+ +
+ +
+ + update("confirmPassword", e.target.value)} + required + minLength={8} + autoComplete="new-password" + className="pl-10" + /> +
+
+ + +
+ +

+ 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 ( + + ); + })} +
+
+
+ ); +} 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 (
@@ -126,8 +118,8 @@ export default function ProfilePage() {

- Signed in via Authentik as {authentikUser?.username}. - Name, email, and avatar are managed by your identity provider. + Signed in as {profile.displayName || profile.email}. + Role: {role}

)} @@ -137,9 +129,7 @@ export default function ProfilePage() { Photo - {avatarFromAuthentik - ? "Managed by Authentik" - : "Your profile picture is visible in the header"} + Your profile picture is visible in the header @@ -152,60 +142,41 @@ export default function ProfilePage() { {initials || } - {!avatarFromAuthentik && ( - + +
+
+ + {form.avatarUrl && ( + )}
- {!avatarFromAuthentik && ( -
- - {form.avatarUrl && ( - - )} -
- )} - {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() { handleChange("displayName", e.target.value)} placeholder="Your name" - readOnly={nameFromAuthentik} - className={nameFromAuthentik ? "bg-muted/40 cursor-default" : ""} />
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 && ( -
- -
- {authentikUser.groups.map((group) => ( - - {group} - - ))} -
-
- )} -