Initial commit: Kinship Directory application
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
yarn-error.log
|
||||
.yarn/cache
|
||||
.yarn/install-state.gz
|
||||
.yarn/unplugged
|
||||
@@ -0,0 +1,3 @@
|
||||
MONGODB_URI=mongodb://127.0.0.1:27017
|
||||
MONGODB_DB=kinship_directory
|
||||
AUTH_SECRET=replace-with-at-least-32-random-characters
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
yarn-error.log
|
||||
.yarn/cache
|
||||
.yarn/install-state.gz
|
||||
.yarn/unplugged
|
||||
.DS_Store
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
FROM node:22-alpine AS dependencies
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
RUN corepack enable && yarn install
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN corepack enable && corepack prepare yarn@1.22.22 --activate && yarn build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
RUN corepack enable && corepack prepare yarn@1.22.22 --activate
|
||||
EXPOSE 3000
|
||||
CMD ["yarn", "start"]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Kinship Directory
|
||||
|
||||
A private, multi-tenant community directory built with Next.js, Node.js, Tailwind CSS, and MongoDB.
|
||||
|
||||
## Features
|
||||
|
||||
- Organization-level tenant isolation on every directory and image query
|
||||
- Globally unique account email, limiting each person to one organization
|
||||
- Family-level write authorization, including for administrators
|
||||
- Parent/adult and under-18 child profiles
|
||||
- Family and individual photo uploads stored in MongoDB
|
||||
- Admin-controlled organization name, logo, app icon, colors, and typography
|
||||
- Invite-code member registration
|
||||
- Responsive, installable Progressive Web App
|
||||
|
||||
## Run with Docker
|
||||
|
||||
Docker is the quickest option because it starts both the web application and MongoDB.
|
||||
|
||||
```bash
|
||||
AUTH_SECRET="$(openssl rand -hex 32)" docker compose up --build
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000), choose **Create your directory**, and register the first organization administrator.
|
||||
|
||||
## Run with Node.js
|
||||
|
||||
Requirements: Node.js 22+, Yarn 1.22.22, and MongoDB 7+.
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Set a random `AUTH_SECRET` of at least 32 characters in `.env.local` before deployment.
|
||||
|
||||
## Install on iPhone
|
||||
|
||||
1. Open the deployed HTTPS site in Safari and sign in.
|
||||
2. Tap **Share**.
|
||||
3. Tap **Add to Home Screen**.
|
||||
|
||||
The uploaded admin app icon is used for the favicon, PWA manifest, and iPhone home-screen icon. Use a square 512 x 512 PNG for best results.
|
||||
|
||||
## Security model
|
||||
|
||||
Account emails are normalized to lowercase and protected by a global unique MongoDB index. Directory reads always include the session organization ID. Family updates do not accept a family ID from the browser; they derive it from the signed, HTTP-only session cookie and require both family and organization IDs to match. Branding endpoints require the organization admin role. Mutating requests validate their origin, and uploaded files are size- and MIME-restricted.
|
||||
|
||||
For production, terminate TLS at a reverse proxy, back up the MongoDB volume, and rotate `AUTH_SECRET` using your hosting provider's secret manager.
|
||||
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
MONGODB_URI: mongodb://mongo:27017
|
||||
MONGODB_DB: kinship_directory
|
||||
AUTH_SECRET: ${AUTH_SECRET:-local-development-secret-change-in-production}
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
mongo:
|
||||
image: mongo:8
|
||||
volumes:
|
||||
- directory_data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.runCommand({ ping: 1 }).ok"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
directory_data:
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
HOST="root@192.168.11.200"
|
||||
PASSWORD="etrnlee1"
|
||||
SSH="sshpass -p '$PASSWORD' ssh -o StrictHostKeyChecking=no"
|
||||
SCP="sshpass -p '$PASSWORD' scp -o StrictHostKeyChecking=no"
|
||||
APP_DIR="/root/kinship-directory"
|
||||
|
||||
echo "=== Kinship Directory Deployment to Unraid ==="
|
||||
echo "Uploading files to Unraid server..."
|
||||
|
||||
# Upload the entire directory
|
||||
$SCP -r /Users/markelmasri/Projects/directory/* $HOST:$APP_DIR/
|
||||
|
||||
echo "Building Docker image..."
|
||||
$SSH $HOST "cd $APP_DIR && docker build -t kinship-directory:latest ."
|
||||
|
||||
echo "Generating AUTH_SECRET..."
|
||||
AUTH_SECRET=$($SSH $HOST "openssl rand -hex 32")
|
||||
|
||||
echo "Creating docker-compose.yml with generated secret..."
|
||||
$SSH $HOST "cd $APP_DIR && cat > docker-compose.yml << 'COMPOSE_EOF'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
kinship-directory:
|
||||
image: kinship-directory:latest
|
||||
container_name: kinship-directory
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- \"3001:3000\"
|
||||
environment:
|
||||
- MONGODB_URI=mongodb://kinship-mongo:27017
|
||||
- MONGODB_DB=kinship_directory
|
||||
- AUTH_SECRET=${AUTH_SECRET}
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
kinship-mongo:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- kinship-net
|
||||
|
||||
kinship-mongo:
|
||||
image: mongo:8
|
||||
container_name: kinship-mongo
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /mnt/user/appdata/kinship-mongo/data:/data/db
|
||||
healthcheck:
|
||||
test: [\"CMD\", \"mongosh\", \"--quiet\", \"--eval\", \"db.runCommand({ ping: 1 }).ok\"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
networks:
|
||||
- kinship-net
|
||||
|
||||
networks:
|
||||
kinship-net:
|
||||
driver: bridge
|
||||
COMPOSE_EOF"
|
||||
|
||||
echo "Starting Kinship Directory..."
|
||||
$SSH $HOST "cd $APP_DIR && docker compose up -d"
|
||||
|
||||
echo "Adding SWAG reverse proxy configuration..."
|
||||
$SSH $HOST "cp $APP_DIR/directory.base.jeditemple.com.subdomain.conf /config/nginx/proxy-confs/ 2>/dev/null || ($SCP $APP_DIR/directory.base.jeditemple.com.subdomain.conf $HOST:/tmp/ && $SSH $HOST 'cp /tmp/directory.base.jeditemple.com.subdomain.conf /config/nginx/proxy-confs/')"
|
||||
|
||||
echo "Restarting SWAG to apply reverse proxy config..."
|
||||
$SSH $HOST "docker restart swag"
|
||||
|
||||
echo ""
|
||||
echo "=== Deployment Complete ==="
|
||||
echo "Application URL: https://directory.base.jeditemple.com"
|
||||
echo "AUTH_SECRET: $AUTH_SECRET"
|
||||
echo ""
|
||||
echo "Save this AUTH_SECRET in your docker-compose.yml if you rebuild:"
|
||||
echo "export AUTH_SECRET=$AUTH_SECRET"
|
||||
@@ -0,0 +1,60 @@
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
server_name directory.base.jeditemple.com.*;
|
||||
|
||||
include /config/nginx/ssl.conf;
|
||||
|
||||
client_max_body_size 10m;
|
||||
|
||||
location / {
|
||||
include /config/nginx/proxy.conf;
|
||||
include /config/nginx/resolver.conf;
|
||||
set $upstream_app kinship-directory;
|
||||
set $upstream_port 3000;
|
||||
set $upstream_proto http;
|
||||
proxy_pass $upstream_proto://$upstream_app:$upstream_port;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
include /config/nginx/proxy.conf;
|
||||
include /config/nginx/resolver.conf;
|
||||
set $upstream_app kinship-directory;
|
||||
set $upstream_port 3000;
|
||||
set $upstream_proto http;
|
||||
proxy_pass $upstream_proto://$upstream_app:$upstream_port;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
location /_next/ {
|
||||
include /config/nginx/proxy.conf;
|
||||
include /config/nginx/resolver.conf;
|
||||
set $upstream_app kinship-directory;
|
||||
set $upstream_port 3000;
|
||||
set $upstream_proto http;
|
||||
proxy_pass $upstream_proto://$upstream_app:$upstream_port;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
kinship-directory:
|
||||
image: kinship-directory:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: kinship-directory
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3000"
|
||||
environment:
|
||||
- MONGODB_URI=mongodb://kinship-mongo:27017
|
||||
- MONGODB_DB=kinship_directory
|
||||
- AUTH_SECRET=${AUTH_SECRET}
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_BASE_URL=https://directory.base.jeditemple.com
|
||||
depends_on:
|
||||
kinship-mongo:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- kinship-net
|
||||
|
||||
kinship-mongo:
|
||||
image: mongo:8
|
||||
container_name: kinship-mongo
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /mnt/user/appdata/kinship-mongo/data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.runCommand({ ping: 1 }).ok"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
networks:
|
||||
- kinship-net
|
||||
|
||||
networks:
|
||||
kinship-net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,5 @@
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const compat = new FlatCompat({ baseDirectory: import.meta.dirname });
|
||||
|
||||
export default [...compat.extends("next/core-web-vitals", "next/typescript")];
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
serverActions: { bodySizeLimit: "6mb" },
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "kinship-directory",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"import:contacts": "node scripts/import-contacts.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.2",
|
||||
"jose": "^6.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"mongodb": "^6.19.0",
|
||||
"next": "^15.5.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@types/node": "^22.18.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-config-next": "^15.5.2",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
const CACHE = "kinship-shell-v1";
|
||||
const SHELL = ["/", "/login", "/register"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))));
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET" || new URL(event.request.url).pathname.startsWith("/api/")) return;
|
||||
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((response) => response || caches.match("/"))));
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
function parseCsv(text) {
|
||||
const rows = []; let row = []; let field = ""; let quoted = false;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const char = text[index];
|
||||
if (char === '"') {
|
||||
if (quoted && text[index + 1] === '"') { field += '"'; index++; } else quoted = !quoted;
|
||||
} else if (char === "," && !quoted) { row.push(field); field = ""; }
|
||||
else if ((char === "\n" || char === "\r") && !quoted) {
|
||||
if (char === "\r" && text[index + 1] === "\n") index++;
|
||||
row.push(field); if (row.some(Boolean)) rows.push(row); row = []; field = "";
|
||||
} else field += char;
|
||||
}
|
||||
if (field || row.length) { row.push(field); rows.push(row); }
|
||||
const headers = rows.shift().map((header) => header.replace(/^\uFEFF/, "").trim());
|
||||
return rows.map((values) => Object.fromEntries(headers.map((header, index) => [header, (values[index] || "").trim()])));
|
||||
}
|
||||
|
||||
const value = (row, name) => row[name] || "";
|
||||
const personId = (key, role) => createHash("sha256").update(`${key}:${role}`).digest("hex").slice(0, 24);
|
||||
function person(row, key, role, number, lastName) {
|
||||
const prefix = `${role} ${number}`;
|
||||
const firstName = value(row, `${prefix} First Name`);
|
||||
if (!firstName) return null;
|
||||
return {
|
||||
id: personId(key, `${role}-${number}`), firstName, lastName,
|
||||
birthday: value(row, `${prefix} Birthday`) || undefined,
|
||||
email: (value(row, `${prefix} Email Address`) || value(row, `${prefix} Email Address `)).toLowerCase() || undefined,
|
||||
phone: value(row, `${prefix} Mobile Phone Number`) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function additionalAdults(row, key, lastName) {
|
||||
const match = value(row, "Additional Details").match(/Additional adult household members:\s*([^;]+)/i);
|
||||
if (!match) return [];
|
||||
return match[1].split(",").map((name) => name.trim()).filter(Boolean).map((firstName, index) => ({
|
||||
id: personId(key, `additional-${index}`), firstName, lastName,
|
||||
}));
|
||||
}
|
||||
|
||||
const [joinCode, csvPath] = process.argv.slice(2);
|
||||
if (!joinCode || !csvPath) {
|
||||
console.error("Usage: yarn import:contacts <organization-join-code> <csv-file>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new MongoClient(process.env.MONGODB_URI || "mongodb://127.0.0.1:27017");
|
||||
await client.connect();
|
||||
try {
|
||||
const database = client.db(process.env.MONGODB_DB || "kinship_directory");
|
||||
const organization = await database.collection("organizations").findOne({ joinCode: joinCode.toUpperCase() });
|
||||
if (!organization) throw new Error(`Organization ${joinCode} was not found.`);
|
||||
const rows = parseCsv(await readFile(csvPath, "utf8"));
|
||||
const users = await database.collection("users").find({ organizationId: organization._id }).toArray();
|
||||
const familyByEmail = new Map(users.map((user) => [String(user.email).toLowerCase(), user.familyId]));
|
||||
let created = 0; let updated = 0; let adultsImported = 0; let childrenImported = 0;
|
||||
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const lastName = value(row, "Family Last Name");
|
||||
if (!lastName) continue;
|
||||
const key = `${joinCode.toUpperCase()}:${index + 2}:${lastName}:${value(row, "Address")}`;
|
||||
const adults = [person(row, key, "Adult", 1, lastName), person(row, key, "Adult", 2, lastName)].filter(Boolean);
|
||||
adults.push(...additionalAdults(row, key, lastName));
|
||||
const children = [1, 2, 3, 4].map((number) => person(row, key, "Child", number, lastName)).filter(Boolean);
|
||||
const accountFamilyId = adults.map((adult) => adult.email?.toLowerCase()).filter(Boolean).map((email) => familyByEmail.get(email)).find(Boolean);
|
||||
const address = [value(row, "Address"), value(row, "Address Line 2")].filter(Boolean).join(", ");
|
||||
const document = {
|
||||
organizationId: organization._id,
|
||||
familyName: lastName,
|
||||
address: address || undefined,
|
||||
city: value(row, "City") || undefined,
|
||||
state: value(row, "State") || undefined,
|
||||
postalCode: value(row, "ZIP Code") || undefined,
|
||||
homePhone: value(row, "Family Phone") || undefined,
|
||||
adults, children,
|
||||
importKey: key,
|
||||
importDetails: value(row, "Additional Details") || undefined,
|
||||
anniversary: value(row, "Anniversary Date") || undefined,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const existing = accountFamilyId
|
||||
? await database.collection("families").findOne({ _id: accountFamilyId, organizationId: organization._id })
|
||||
: await database.collection("families").findOne({ organizationId: organization._id, importKey: key });
|
||||
if (existing) {
|
||||
await database.collection("families").updateOne({ _id: existing._id }, { $set: document }); updated++;
|
||||
} else {
|
||||
await database.collection("families").insertOne({ ...document, createdAt: new Date() }); created++;
|
||||
}
|
||||
adultsImported += adults.length; childrenImported += children.length;
|
||||
}
|
||||
console.log(JSON.stringify({ organization: organization.name, rows: rows.length, created, updated, adults: adultsImported, children: childrenImported }, null, 2));
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { BrandingForm } from "@/components/BrandingForm";
|
||||
import { AdminUsers } from "@/components/AdminUsers";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import { db, mapOrganization, objectId } from "@/lib/db";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await requireAdmin(); const organizationId = objectId(session.organizationId); if (!organizationId) notFound();
|
||||
const organization = await (await db()).collection("organizations").findOne({ _id: organizationId }); if (!organization) notFound();
|
||||
const users = await (await db()).collection("users").find({ organizationId }).sort({ name: 1 }).toArray();
|
||||
const serializedUsers = users.map((user) => ({ id: user._id.toHexString(), name: String(user.name), email: String(user.email), role: user.role as "admin" | "member", isCurrent: user._id.toHexString() === session.id }));
|
||||
return <main className="mx-auto max-w-6xl px-5 py-10 sm:px-6 sm:py-14"><p className="text-sm font-bold uppercase tracking-[.18em] text-[var(--accent)]">Administration</p><h1 className="font-warm mt-2 text-4xl font-bold text-[var(--brand)] sm:text-5xl">Set the tone.</h1><p className="mb-9 mt-3 text-[#64706b]">Customize the directory, manage administrators, and invite your community.</p><BrandingForm organization={mapOrganization(organization)}/><AdminUsers initialUsers={serializedUsers}/></main>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ObjectId } from "mongodb";
|
||||
import { requireSession } from "@/lib/auth";
|
||||
import { db, mapFamily } from "@/lib/db";
|
||||
import { DirectorySearch } from "@/components/DirectorySearch";
|
||||
|
||||
export default async function DirectoryPage() {
|
||||
const session = await requireSession();
|
||||
const families = await (await db()).collection("families").find({ organizationId: new ObjectId(session.organizationId) }).sort({ familyName: 1 }).toArray();
|
||||
return <main className="mx-auto max-w-7xl px-5 py-10 sm:px-6 sm:py-14"><p className="text-sm font-bold uppercase tracking-[.18em] text-[var(--accent)]">Private community</p><h1 className="font-warm mt-2 text-4xl font-bold tracking-tight text-[var(--brand)] sm:text-5xl">Find your people.</h1><p className="mt-3 max-w-xl text-[#64706b]">Browse the families in your organization. Contact details stay visible only to signed-in members.</p><DirectorySearch families={families.map(mapFamily)} ownFamilyId={session.familyId}/></main>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { FamilyEditor } from "@/components/FamilyEditor";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import { db, mapFamily, objectId } from "@/lib/db";
|
||||
|
||||
export default async function AdminEditFamilyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireAdmin();
|
||||
const { id } = await params;
|
||||
const familyId = objectId(id); const organizationId = objectId(session.organizationId);
|
||||
if (!familyId || !organizationId) notFound();
|
||||
const family = await (await db()).collection("families").findOne({ _id: familyId, organizationId });
|
||||
if (!family) notFound();
|
||||
return <main className="mx-auto max-w-5xl px-5 py-10 sm:px-6 sm:py-14"><p className="text-sm font-bold uppercase tracking-[.18em] text-[var(--accent)]">Administrator editing</p><h1 className="font-warm mt-2 text-4xl font-bold text-[var(--brand)] sm:text-5xl">Edit {family.familyName}.</h1><p className="mb-9 mt-3 text-[#64706b]">Changes are applied to this family within your organization.</p><FamilyEditor initialFamily={mapFamily(family)}/></main>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Baby, Cake, Mail, MapPin, Pencil, Phone, UsersRound } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { requireSession } from "@/lib/auth";
|
||||
import { db, mapFamily, objectId } from "@/lib/db";
|
||||
|
||||
function formatBirthday(value: string): string {
|
||||
const parts = value.includes("-") ? value.split("-").slice(1) : value.split("/").slice(0, 2);
|
||||
const month = Number(parts[0]); const day = Number(parts[1]);
|
||||
if (!month || !day) return value;
|
||||
return new Date(2000, month - 1, day).toLocaleDateString(undefined, { month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
function PersonCard({ person, child = false }: { person: ReturnType<typeof mapFamily>["adults"][number]; child?: boolean }) {
|
||||
return <div className="flex gap-4 rounded-2xl border border-black/6 bg-white p-4">
|
||||
{person.photoId ? <img src={`/api/assets/${person.photoId}`} alt="" className="size-16 rounded-2xl object-cover"/> : <div className="grid size-16 shrink-0 place-items-center rounded-2xl bg-[var(--surface)] text-lg font-bold text-[var(--brand)]">{person.firstName[0]}{person.lastName[0]}</div>}
|
||||
<div className="min-w-0"><h3 className="font-bold">{person.firstName} {person.lastName}</h3>{child && <p className="text-xs font-bold uppercase tracking-wider text-[var(--accent)]">Child</p>}<div className="mt-2 space-y-1 text-sm text-[#65706c]">{person.email && <a className="flex items-center gap-2 hover:underline" href={`mailto:${person.email}`}><Mail size={14}/>{person.email}</a>}{person.phone && <a className="flex items-center gap-2" href={`tel:${person.phone}`}><Phone size={14}/>{person.phone}</a>}{person.birthday && <p className="flex items-center gap-2"><Cake size={14}/>{formatBirthday(person.birthday)}</p>}</div></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default async function FamilyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireSession();
|
||||
const { id } = await params;
|
||||
const familyId = objectId(id); const organizationId = objectId(session.organizationId);
|
||||
if (!familyId || !organizationId) notFound();
|
||||
const raw = await (await db()).collection("families").findOne({ _id: familyId, organizationId });
|
||||
if (!raw) notFound();
|
||||
const family = mapFamily(raw); const own = family.id === session.familyId; const canEdit = own || session.role === "admin";
|
||||
return <main className="mx-auto max-w-5xl px-5 py-10 sm:px-6 sm:py-14">
|
||||
<Link href="/directory" className="text-sm font-bold text-[var(--brand)]">← Back to directory</Link>
|
||||
<div className="mt-6 overflow-hidden rounded-[2rem] bg-[var(--brand)] text-white card-shadow"><div className="grid min-h-64 md:grid-cols-[.9fr_1.1fr]">{family.photoId ? <img src={`/api/assets/${family.photoId}`} alt={`${family.familyName} family`} className="h-64 w-full object-cover md:h-full"/> : <div className="grid min-h-52 place-items-center bg-white/8"><UsersRound size={64} strokeWidth={1}/></div>}<div className="flex flex-col justify-center p-7 sm:p-10"><div className="flex items-start justify-between gap-4"><div><p className="text-xs font-bold uppercase tracking-[.2em] text-white/60">Meet the</p><h1 className="font-warm mt-2 text-4xl font-bold sm:text-5xl">{family.familyName}</h1></div>{canEdit && <Link href={own ? "/family/edit" : `/family/${family.id}/edit`} aria-label={`Edit ${family.familyName}`} className="rounded-full bg-white p-3 text-[var(--brand)]"><Pencil size={18}/></Link>}</div>{family.address && <p className="mt-6 flex items-start gap-2 text-white/75"><MapPin className="mt-1 shrink-0" size={17}/><span>{family.address}<br/>{family.city}, {family.state} {family.postalCode}</span></p>}{family.homePhone && <a href={`tel:${family.homePhone}`} className="mt-3 flex items-center gap-2 text-white/75"><Phone size={17}/>{family.homePhone}</a>}</div></div></div>
|
||||
<section className="mt-10"><h2 className="mb-4 flex items-center gap-2 text-lg font-bold text-[var(--brand)]"><UsersRound size={20}/> Adults</h2><div className="grid gap-4 sm:grid-cols-2">{family.adults.map((person) => <PersonCard key={person.id} person={person}/>)}</div></section>
|
||||
{family.children.length > 0 && <section className="mt-9"><h2 className="mb-4 flex items-center gap-2 text-lg font-bold text-[var(--brand)]"><Baby size={20}/> Children</h2><div className="grid gap-4 sm:grid-cols-2">{family.children.map((person) => <PersonCard key={person.id} person={person} child/>)}</div></section>}
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { FamilyEditor } from "@/components/FamilyEditor";
|
||||
import { requireSession } from "@/lib/auth";
|
||||
import { db, mapFamily, objectId } from "@/lib/db";
|
||||
|
||||
export default async function EditFamilyPage() {
|
||||
const session = await requireSession();
|
||||
const familyId = objectId(session.familyId); const organizationId = objectId(session.organizationId);
|
||||
if (!familyId || !organizationId) notFound();
|
||||
const family = await (await db()).collection("families").findOne({ _id: familyId, organizationId });
|
||||
if (!family) notFound();
|
||||
return <main className="mx-auto max-w-5xl px-5 py-10 sm:px-6 sm:py-14"><p className="text-sm font-bold uppercase tracking-[.18em] text-[var(--accent)]">Your household</p><h1 className="font-warm mt-2 text-4xl font-bold text-[var(--brand)] sm:text-5xl">Make it feel like home.</h1><p className="mb-9 mt-3 text-[#64706b]">Only your family can change these details.</p><FamilyEditor initialFamily={mapFamily(family)}/></main>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { HeartHandshake, House, Palette, UserRoundPen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { requireSession } from "@/lib/auth";
|
||||
import { db, mapOrganization, objectId } from "@/lib/db";
|
||||
import { ServiceWorker } from "@/components/ServiceWorker";
|
||||
|
||||
export default async function ProtectedLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireSession();
|
||||
const organizationId = objectId(session.organizationId);
|
||||
const rawOrganization = organizationId ? await (await db()).collection("organizations").findOne({ _id: organizationId }) : null;
|
||||
if (!rawOrganization) notFound();
|
||||
const organization = mapOrganization(rawOrganization);
|
||||
const fontClass = `font-${organization.theme.font}`;
|
||||
const style = { "--brand": organization.theme.primary, "--accent": organization.theme.accent, "--surface": organization.theme.surface } as React.CSSProperties;
|
||||
|
||||
return <div className={`min-h-screen ${fontClass}`} style={style}>
|
||||
<ServiceWorker />
|
||||
<header className="sticky top-0 z-30 border-b border-black/5 bg-white/90 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-18 max-w-7xl items-center gap-5 px-4 sm:px-6">
|
||||
<Link href="/directory" className="mr-auto flex min-w-0 items-center gap-3 font-bold">
|
||||
{organization.logoId ? <img src="/api/brand/logo" alt="" className="size-10 rounded-xl object-contain"/> : <span className="grid size-10 shrink-0 place-items-center rounded-xl bg-[var(--brand)] text-white"><HeartHandshake size={20}/></span>}
|
||||
<span className="truncate">{organization.name}</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-1 text-sm font-bold">
|
||||
<Link href="/directory" className="flex items-center gap-2 rounded-full px-3 py-2 hover:bg-black/5"><House size={17}/><span className="desktop-only">Directory</span></Link>
|
||||
<Link href="/family/edit" className="flex items-center gap-2 rounded-full px-3 py-2 hover:bg-black/5"><UserRoundPen size={17}/><span className="desktop-only">My family</span></Link>
|
||||
{session.role === "admin" && <Link href="/admin" className="flex items-center gap-2 rounded-full px-3 py-2 hover:bg-black/5"><Palette size={17}/><span className="desktop-only">Admin</span></Link>}
|
||||
<form action="/api/auth/logout" method="post"><button className="ml-1 rounded-full border border-black/10 px-3 py-2 hover:bg-black/5">Sign out</button></form>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { db, objectId } from "@/lib/db";
|
||||
import { clean, isSameOrigin } from "@/lib/validation";
|
||||
|
||||
const hexColor = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
if (!isSameOrigin(request)) return NextResponse.json({ error: "Invalid request origin." }, { status: 403 });
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const organizationId = objectId(session.organizationId);
|
||||
if (!organizationId) return NextResponse.json({ error: "Invalid session" }, { status: 401 });
|
||||
const body = await request.json();
|
||||
const primary = clean(body.primary, 7);
|
||||
const accent = clean(body.accent, 7);
|
||||
const surface = clean(body.surface, 7);
|
||||
const font = ["modern", "warm", "classic"].includes(body.font) ? body.font : "warm";
|
||||
if (![primary, accent, surface].every((color) => hexColor.test(color))) {
|
||||
return NextResponse.json({ error: "Theme colors must be valid hex colors." }, { status: 400 });
|
||||
}
|
||||
const database = await db();
|
||||
const logoId = objectId(body.logoId); const faviconId = objectId(body.faviconId);
|
||||
const imageIds = [logoId, faviconId].filter((id) => id !== null);
|
||||
if (imageIds.length) {
|
||||
const imageCount = await database.collection("images").countDocuments({ _id: { $in: imageIds }, organizationId });
|
||||
if (imageCount !== new Set(imageIds.map((id) => id.toHexString())).size) return NextResponse.json({ error: "A selected image is not part of this organization." }, { status: 403 });
|
||||
}
|
||||
await database.collection("organizations").updateOne(
|
||||
{ _id: organizationId },
|
||||
{ $set: {
|
||||
name: clean(body.name, 120),
|
||||
logoId: logoId?.toHexString(),
|
||||
faviconId: faviconId?.toHexString(),
|
||||
theme: { primary, accent, surface, font },
|
||||
updatedAt: new Date(),
|
||||
} },
|
||||
);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { db, objectId } from "@/lib/db";
|
||||
import { isSameOrigin } from "@/lib/validation";
|
||||
|
||||
export async function PUT(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
if (!isSameOrigin(request)) return NextResponse.json({ error: "Invalid request origin." }, { status: 403 });
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const { id } = await context.params;
|
||||
if (id === session.id) return NextResponse.json({ error: "You cannot change your own administrator role." }, { status: 400 });
|
||||
const userId = objectId(id); const organizationId = objectId(session.organizationId);
|
||||
if (!userId || !organizationId) return NextResponse.json({ error: "Invalid user." }, { status: 400 });
|
||||
const body = await request.json();
|
||||
const role = body.role === "admin" ? "admin" : body.role === "member" ? "member" : null;
|
||||
if (!role) return NextResponse.json({ error: "Invalid role." }, { status: 400 });
|
||||
const result = await (await db()).collection("users").updateOne({ _id: userId, organizationId }, { $set: { role, updatedAt: new Date() } });
|
||||
if (!result.matchedCount) return NextResponse.json({ error: "User not found." }, { status: 404 });
|
||||
return NextResponse.json({ ok: true, role });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { db, objectId } from "@/lib/db";
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession();
|
||||
if (!session) return new NextResponse(null, { status: 401 });
|
||||
const { id } = await context.params;
|
||||
const imageId = objectId(id);
|
||||
const organizationId = objectId(session.organizationId);
|
||||
if (!imageId || !organizationId) return new NextResponse(null, { status: 404 });
|
||||
const image = await (await db()).collection("images").findOne({ _id: imageId, organizationId });
|
||||
if (!image) return new NextResponse(null, { status: 404 });
|
||||
return new NextResponse(new Uint8Array(image.data.buffer), {
|
||||
headers: {
|
||||
"Content-Type": image.contentType,
|
||||
"Cache-Control": "private, max-age=86400",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { compare } from "bcryptjs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { isSameOrigin, isSecureRequest, normalizeEmail } from "@/lib/validation";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isSameOrigin(request)) return NextResponse.json({ error: "Invalid request origin." }, { status: 403 });
|
||||
const { email: rawEmail, password } = await request.json();
|
||||
const email = normalizeEmail(rawEmail);
|
||||
const database = await db();
|
||||
const user = await database.collection("users").findOne({ email });
|
||||
if (!user || !await compare(String(password || ""), String(user.passwordHash))) {
|
||||
return NextResponse.json({ error: "Email or password is incorrect." }, { status: 401 });
|
||||
}
|
||||
await createSession({
|
||||
id: user._id.toHexString(),
|
||||
organizationId: user.organizationId.toHexString(),
|
||||
familyId: user.familyId.toHexString(),
|
||||
role: user.role,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
}, isSecureRequest(request));
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { clearSession } from "@/lib/auth";
|
||||
import { isSameOrigin } from "@/lib/validation";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isSameOrigin(request)) return new NextResponse(null, { status: 403 });
|
||||
await clearSession();
|
||||
return NextResponse.redirect(new URL("/login", request.url), 303);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { hash } from "bcryptjs";
|
||||
import { randomBytes } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { clean, isSameOrigin, isSecureRequest, normalizeEmail } from "@/lib/validation";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isSameOrigin(request)) return NextResponse.json({ error: "Invalid request origin." }, { status: 403 });
|
||||
let database: Awaited<ReturnType<typeof db>> | undefined;
|
||||
let createdOrganizationId: import("mongodb").ObjectId | undefined;
|
||||
let createdFamilyId: import("mongodb").ObjectId | undefined;
|
||||
let createdUserId: import("mongodb").ObjectId | undefined;
|
||||
try {
|
||||
const body = await request.json();
|
||||
let name = clean(body.name, 100);
|
||||
const email = normalizeEmail(body.email);
|
||||
const password = String(body.password || "");
|
||||
const familyName = clean(body.familyName, 80);
|
||||
const organizationName = clean(body.organizationName, 120);
|
||||
const joinCode = clean(body.joinCode, 20).toUpperCase();
|
||||
if (!email.includes("@") || password.length < 8) {
|
||||
return NextResponse.json({ error: "Enter a valid email and a password of at least 8 characters." }, { status: 400 });
|
||||
}
|
||||
if (organizationName && (!name || !familyName)) {
|
||||
return NextResponse.json({ error: "Enter your name and family display name." }, { status: 400 });
|
||||
}
|
||||
if (!organizationName && !joinCode) {
|
||||
return NextResponse.json({ error: "Enter your organization invite code." }, { status: 400 });
|
||||
}
|
||||
|
||||
database = await db();
|
||||
if (await database.collection("users").findOne({ email })) {
|
||||
return NextResponse.json({ error: "That email already belongs to an organization." }, { status: 409 });
|
||||
}
|
||||
|
||||
let organizationId: import("mongodb").ObjectId;
|
||||
let familyId: import("mongodb").ObjectId;
|
||||
let role: "admin" | "member" = "member";
|
||||
if (organizationName) {
|
||||
role = "admin";
|
||||
const organization = await database.collection("organizations").insertOne({
|
||||
name: organizationName,
|
||||
joinCode: randomBytes(8).toString("hex").toUpperCase(),
|
||||
theme: { primary: "#173f35", accent: "#df9b63", surface: "#f7f4ed", font: "warm" },
|
||||
createdAt: new Date(),
|
||||
});
|
||||
createdOrganizationId = organization.insertedId;
|
||||
organizationId = organization.insertedId;
|
||||
const family = await database.collection("families").insertOne({
|
||||
organizationId,
|
||||
familyName,
|
||||
adults: [{ id: crypto.randomUUID(), firstName: name.split(" ")[0], lastName: name.split(" ").slice(1).join(" ") || familyName, email }],
|
||||
children: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
createdFamilyId = family.insertedId;
|
||||
familyId = family.insertedId;
|
||||
} else {
|
||||
const organization = await database.collection("organizations").findOne({ joinCode });
|
||||
if (!organization) return NextResponse.json({ error: "That invite code was not found." }, { status: 404 });
|
||||
organizationId = organization._id;
|
||||
const escapedEmail = email.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const matches = await database.collection("families").find({
|
||||
organizationId,
|
||||
"adults.email": { $regex: `^${escapedEmail}$`, $options: "i" },
|
||||
}).limit(2).toArray();
|
||||
if (matches.length === 0) {
|
||||
return NextResponse.json({ error: "That email is not listed in this organization's directory. Ask an administrator to add it first." }, { status: 404 });
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return NextResponse.json({ error: "That email appears on more than one family. Ask an administrator to correct the directory." }, { status: 409 });
|
||||
}
|
||||
const adult = matches[0].adults.find((person: { email?: string }) => person.email?.toLowerCase() === email);
|
||||
if (!adult) return NextResponse.json({ error: "The directory profile could not be matched." }, { status: 409 });
|
||||
name = clean(`${adult.firstName} ${adult.lastName}`, 100);
|
||||
familyId = matches[0]._id;
|
||||
}
|
||||
const user = await database.collection("users").insertOne({
|
||||
organizationId,
|
||||
familyId,
|
||||
role,
|
||||
name,
|
||||
email,
|
||||
passwordHash: await hash(password, 12),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
createdUserId = user.insertedId;
|
||||
await createSession({
|
||||
id: user.insertedId.toHexString(),
|
||||
organizationId: organizationId.toHexString(),
|
||||
familyId: familyId.toHexString(),
|
||||
role,
|
||||
email,
|
||||
name,
|
||||
}, isSecureRequest(request));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error("Registration failed:", error);
|
||||
if (database && createdUserId) await database.collection("users").deleteOne({ _id: createdUserId });
|
||||
if (database && createdFamilyId) await database.collection("families").deleteOne({ _id: createdFamilyId });
|
||||
if (database && createdOrganizationId) await database.collection("organizations").deleteOne({ _id: createdOrganizationId });
|
||||
const duplicate = error instanceof Error && error.message.includes("E11000");
|
||||
return NextResponse.json({ error: duplicate ? "That email is already registered." : "Registration could not be completed." }, { status: duplicate ? 409 : 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { db, objectId } from "@/lib/db";
|
||||
|
||||
const fallback = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180"><rect width="180" height="180" rx="38" fill="#173f35"/><path d="M45 96c0-25 20-45 45-45s45 20 45 45v35H45V96Z" fill="#f7f4ed"/><circle cx="90" cy="62" r="18" fill="#df9b63"/><path d="M64 107h52" stroke="#df9b63" stroke-width="10" stroke-linecap="round"/></svg>`;
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ kind: string }> }) {
|
||||
const session = await getSession();
|
||||
const { kind } = await context.params;
|
||||
if (!session) return new NextResponse(fallback, { headers: { "Content-Type": "image/svg+xml" } });
|
||||
const database = await db();
|
||||
const organizationId = objectId(session.organizationId);
|
||||
const organization = organizationId ? await database.collection("organizations").findOne({ _id: organizationId }) : null;
|
||||
const imageId = objectId(kind === "logo" ? organization?.logoId : organization?.faviconId);
|
||||
const image = imageId ? await database.collection("images").findOne({ _id: imageId, organizationId }) : null;
|
||||
if (!image) return new NextResponse(fallback, { headers: { "Content-Type": "image/svg+xml", "Cache-Control": "no-cache" } });
|
||||
return new NextResponse(new Uint8Array(image.data.buffer), { headers: { "Content-Type": image.contentType, "Cache-Control": "private, max-age=3600" } });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { db, mapFamily, objectId } from "@/lib/db";
|
||||
import { clean, isSameOrigin, parsePeople } from "@/lib/validation";
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
if (!isSameOrigin(request)) return NextResponse.json({ error: "Invalid request origin." }, { status: 403 });
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
const organizationId = objectId(session.organizationId);
|
||||
if (!organizationId) return NextResponse.json({ error: "Invalid session" }, { status: 401 });
|
||||
try {
|
||||
const body = await request.json();
|
||||
const familyId = objectId(session.role === "admin" ? body.id || session.familyId : session.familyId);
|
||||
if (!familyId) return NextResponse.json({ error: "Invalid family." }, { status: 400 });
|
||||
const familyName = clean(body.familyName, 80);
|
||||
const adults = parsePeople(body.adults);
|
||||
const children = parsePeople(body.children, true);
|
||||
if (!familyName || adults.length === 0) {
|
||||
return NextResponse.json({ error: "A family name and at least one adult are required." }, { status: 400 });
|
||||
}
|
||||
const database = await db();
|
||||
const imageIds = [body.photoId, ...adults.map((person) => person.photoId), ...children.map((person) => person.photoId)]
|
||||
.filter(Boolean).map(objectId).filter((id) => id !== null);
|
||||
if (imageIds.length) {
|
||||
const imageScope = session.role === "admin" ? { organizationId } : { organizationId, ownerFamilyId: familyId };
|
||||
const ownedImageCount = await database.collection("images").countDocuments({ _id: { $in: imageIds }, ...imageScope });
|
||||
if (ownedImageCount !== new Set(imageIds.map((id) => id.toHexString())).size) {
|
||||
return NextResponse.json({ error: "One or more selected images do not belong to your family." }, { status: 403 });
|
||||
}
|
||||
}
|
||||
const result = await database.collection("families").findOneAndUpdate(
|
||||
{ _id: familyId, organizationId },
|
||||
{ $set: {
|
||||
familyName,
|
||||
address: clean(body.address, 160),
|
||||
city: clean(body.city, 80),
|
||||
state: clean(body.state, 40),
|
||||
postalCode: clean(body.postalCode, 20),
|
||||
homePhone: clean(body.homePhone, 30),
|
||||
photoId: clean(body.photoId, 50) || undefined,
|
||||
adults,
|
||||
children,
|
||||
updatedAt: new Date(),
|
||||
} },
|
||||
{ returnDocument: "after" },
|
||||
);
|
||||
if (!result) return NextResponse.json({ error: "Family not found." }, { status: 404 });
|
||||
return NextResponse.json({ family: mapFamily(result) });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : "Unable to save family." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { db, objectId } from "@/lib/db";
|
||||
import { isSameOrigin } from "@/lib/validation";
|
||||
|
||||
const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/x-icon"]);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isSameOrigin(request)) return NextResponse.json({ error: "Invalid request origin." }, { status: 403 });
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
const organizationId = objectId(session.organizationId);
|
||||
if (!organizationId) return NextResponse.json({ error: "Invalid session" }, { status: 401 });
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File) || !allowedTypes.has(file.type) || file.size > 5 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: "Choose a JPG, PNG, WebP, GIF, or ICO image under 5 MB." }, { status: 400 });
|
||||
}
|
||||
const database = await db();
|
||||
const ownerFamilyId = objectId(session.familyId);
|
||||
const uploadCount = await database.collection("images").countDocuments({ organizationId, ownerFamilyId }, { limit: 101 });
|
||||
if (uploadCount >= 100) return NextResponse.json({ error: "This family has reached its image storage limit." }, { status: 429 });
|
||||
const image = await database.collection("images").insertOne({
|
||||
organizationId,
|
||||
ownerFamilyId,
|
||||
contentType: file.type,
|
||||
data: Buffer.from(await file.arrayBuffer()),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
return NextResponse.json({ id: image.insertedId.toHexString() });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--brand: #173f35;
|
||||
--accent: #df9b63;
|
||||
--surface: #f7f4ed;
|
||||
--ink: #17211e;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
.font-warm { font-family: Georgia, "Times New Roman", serif; }
|
||||
.font-classic { font-family: "Palatino Linotype", Palatino, serif; }
|
||||
.font-modern { font-family: Arial, Helvetica, sans-serif; }
|
||||
.paper-grid {
|
||||
background-image: linear-gradient(rgba(23, 63, 53, .045) 1px, transparent 1px), linear-gradient(90deg, rgba(23, 63, 53, .045) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
}
|
||||
.card-shadow { box-shadow: 0 18px 50px rgba(31, 49, 43, .09); }
|
||||
.focus-ring:focus { outline: 3px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: 2px; }
|
||||
.field {
|
||||
width: 100%; border: 1px solid #d9ded9; border-radius: .8rem; background: white;
|
||||
padding: .72rem .85rem; color: #17211e; transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.field:focus { border-color: var(--brand); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 12%, transparent); }
|
||||
.field-search { padding-left: 3rem; }
|
||||
.field-select {
|
||||
appearance: none;
|
||||
min-height: 2.9rem;
|
||||
padding-right: 2.5rem;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2358645f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-position: right .85rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1rem;
|
||||
}
|
||||
.label { display: block; margin-bottom: .35rem; color: #58645f; font-size: .77rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: .45rem; border: 0; border-radius: 999px;
|
||||
background: var(--brand); color: white; padding: .72rem 1.15rem; font-weight: 700; cursor: pointer;
|
||||
transition: transform .15s, filter .15s;
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(1.08); transform: translateY(-1px); }
|
||||
.btn-secondary {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: .4rem; border: 1px solid #d7ddd9;
|
||||
border-radius: 999px; background: white; padding: .65rem 1rem; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
@media (max-width: 640px) { .desktop-only { display: none; } }
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: "Kinship Directory", template: "%s | Kinship" },
|
||||
description: "A private, beautiful directory for your community.",
|
||||
applicationName: "Kinship Directory",
|
||||
appleWebApp: { capable: true, statusBarStyle: "default", title: "Kinship" },
|
||||
icons: { icon: "/api/brand/favicon", apple: "/api/brand/favicon" },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#173f35" };
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return <html lang="en"><body>{children}</body></html>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { HeartHandshake } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { AuthForm } from "@/components/AuthForm";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <main className="paper-grid grid min-h-screen place-items-center px-5 py-12"><div className="card-shadow w-full max-w-md rounded-[2rem] border border-white bg-white/95 p-7 sm:p-10"><Link href="/" className="mb-9 flex items-center gap-3 font-bold"><span className="grid size-10 place-items-center rounded-2xl bg-[#173f35] text-white"><HeartHandshake size={20}/></span> Kinship</Link><h1 className="font-warm text-4xl font-bold text-[#173f35]">Welcome back.</h1><p className="mb-8 mt-2 text-[#68736f]">Your community is just a sign-in away.</p><AuthForm mode="login"/><p className="mt-7 text-center text-sm text-[#68736f]">New here? <Link className="font-bold text-[#173f35] underline" href="/register">Create an account</Link></p></div></main>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Kinship Community Directory",
|
||||
short_name: "Kinship",
|
||||
description: "Your private community directory",
|
||||
start_url: "/directory",
|
||||
display: "standalone",
|
||||
background_color: "#f7f4ed",
|
||||
theme_color: "#173f35",
|
||||
icons: [
|
||||
{ src: "/api/brand/favicon", sizes: "any", purpose: "any" },
|
||||
{ src: "/api/brand/favicon", sizes: "any", purpose: "maskable" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ArrowRight, HeartHandshake, LockKeyhole, Palette, Smartphone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="min-h-screen overflow-hidden bg-[#f7f4ed]">
|
||||
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
|
||||
<div className="flex items-center gap-3 font-bold"><span className="grid size-10 place-items-center rounded-2xl bg-[#173f35] text-white"><HeartHandshake size={21}/></span> Kinship</div>
|
||||
<Link href="/login" className="btn-secondary">Sign in</Link>
|
||||
</nav>
|
||||
<section className="relative mx-auto grid max-w-7xl items-center gap-16 px-6 py-16 lg:grid-cols-[1.05fr_.95fr] lg:py-24">
|
||||
<div>
|
||||
<p className="mb-5 text-sm font-bold uppercase tracking-[.2em] text-[#9a603a]">More than names in a list</p>
|
||||
<h1 className="font-warm max-w-3xl text-5xl leading-[1.03] font-bold tracking-tight text-[#173f35] sm:text-7xl">Keep your community close.</h1>
|
||||
<p className="mt-7 max-w-xl text-lg leading-8 text-[#56625e]">A private, welcoming home for family photos, contact details, and the people who make your organization feel connected.</p>
|
||||
<div className="mt-9 flex flex-wrap gap-3"><Link href="/register" className="btn-primary">Create your directory <ArrowRight size={17}/></Link><Link href="/login" className="btn-secondary">I already belong</Link></div>
|
||||
<div className="mt-12 grid max-w-xl grid-cols-3 gap-3 text-sm text-[#56625e]">
|
||||
<div><LockKeyhole className="mb-2 text-[#9a603a]" size={20}/><strong className="block text-[#17211e]">Private</strong>Tenant isolated</div>
|
||||
<div><Palette className="mb-2 text-[#9a603a]" size={20}/><strong className="block text-[#17211e]">Personal</strong>Your own theme</div>
|
||||
<div><Smartphone className="mb-2 text-[#9a603a]" size={20}/><strong className="block text-[#17211e]">Installable</strong>iPhone ready</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mx-auto w-full max-w-lg">
|
||||
<div className="absolute -inset-16 rounded-full bg-[#e8c39f]/45 blur-3xl" />
|
||||
<div className="card-shadow relative rotate-2 rounded-[2rem] bg-white p-5">
|
||||
<div className="h-56 rounded-[1.4rem] bg-[linear-gradient(135deg,#173f35,#4f7c6d)] p-8 text-white"><p className="text-sm opacity-70">Welcome home</p><p className="font-warm mt-2 text-4xl">The Anderson Family</p><div className="mt-12 flex -space-x-3"><span className="size-14 rounded-full border-4 border-[#315d50] bg-[#df9b63]"/><span className="size-14 rounded-full border-4 border-[#315d50] bg-[#f3d8a6]"/><span className="size-14 rounded-full border-4 border-[#315d50] bg-[#a9c7be]"/></div></div>
|
||||
<div className="grid grid-cols-2 gap-3 p-3 pt-6"><div className="rounded-xl bg-[#f7f4ed] p-4"><p className="text-xs uppercase tracking-wider text-[#78827e]">Household</p><p className="mt-1 font-bold">2 adults · 2 children</p></div><div className="rounded-xl bg-[#f7f4ed] p-4"><p className="text-xs uppercase tracking-wider text-[#78827e]">Member since</p><p className="mt-1 font-bold">2024</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { HeartHandshake } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { AuthForm } from "@/components/AuthForm";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return <main className="paper-grid grid min-h-screen place-items-center px-5 py-12"><div className="card-shadow w-full max-w-lg rounded-[2rem] border border-white bg-white/95 p-7 sm:p-10"><Link href="/" className="mb-8 flex items-center gap-3 font-bold"><span className="grid size-10 place-items-center rounded-2xl bg-[#173f35] text-white"><HeartHandshake size={20}/></span> Kinship</Link><h1 className="font-warm text-4xl font-bold text-[#173f35]">Build your directory.</h1><p className="mb-8 mt-2 text-[#68736f]">Start a new organization or join one with an invite code.</p><AuthForm mode="register"/><p className="mt-7 text-center text-sm text-[#68736f]">Already registered? <Link className="font-bold text-[#173f35] underline" href="/login">Sign in</Link></p></div></main>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheck, UserRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
type AdminUser = { id: string; name: string; email: string; role: "admin" | "member"; isCurrent: boolean };
|
||||
|
||||
export function AdminUsers({ initialUsers }: { initialUsers: AdminUser[] }) {
|
||||
const [users, setUsers] = useState(initialUsers); const [message, setMessage] = useState(""); const [pendingId, setPendingId] = useState("");
|
||||
async function changeRole(user: AdminUser) {
|
||||
const role = user.role === "admin" ? "member" : "admin";
|
||||
setPendingId(user.id); setMessage("");
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${user.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ role }) });
|
||||
const result = await response.json();
|
||||
if (!response.ok) { setMessage(result.error); return; }
|
||||
setUsers(users.map((entry) => entry.id === user.id ? { ...entry, role } : entry));
|
||||
setMessage(`${user.name} is now ${role === "admin" ? "an administrator" : "a member"}.`);
|
||||
} catch {
|
||||
setMessage("The role could not be changed. Try again.");
|
||||
} finally {
|
||||
setPendingId("");
|
||||
}
|
||||
}
|
||||
return <section className="mt-8 rounded-[1.75rem] bg-white p-6 card-shadow sm:p-8"><div><h2 className="font-warm text-2xl font-bold text-[var(--brand)]">Registered accounts</h2><p className="mt-1 text-sm text-[#697570]">Administrators can edit every family and manage organization branding.</p></div><div className="mt-6 divide-y divide-black/6">{users.map((user) => <div key={user.id} className="flex flex-wrap items-center gap-4 py-4"><span className={`grid size-11 place-items-center rounded-xl ${user.role === "admin" ? "bg-[var(--brand)] text-white" : "bg-[var(--surface)] text-[var(--brand)]"}`}>{user.role === "admin" ? <ShieldCheck size={20}/> : <UserRound size={20}/>}</span><div className="mr-auto min-w-0"><p className="font-bold">{user.name}{user.isCurrent && <span className="ml-2 text-xs font-normal text-[#697570]">You</span>}</p><p className="truncate text-sm text-[#697570]">{user.email}</p></div><span className="rounded-full bg-[var(--surface)] px-3 py-1 text-xs font-bold uppercase tracking-wider text-[var(--brand)]">{user.role}</span>{!user.isCurrent && <button type="button" disabled={pendingId === user.id} onClick={() => changeRole(user)} className="btn-secondary text-sm">{pendingId === user.id ? "Saving..." : user.role === "admin" ? "Make member" : "Make admin"}</button>}</div>)}</div>{message && <p className="mt-4 rounded-xl bg-[var(--surface)] p-3 text-sm font-medium">{message}</p>}</section>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, LoaderCircle } from "lucide-react";
|
||||
|
||||
export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [joinExisting, setJoinExisting] = useState(mode === "register");
|
||||
|
||||
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); setPending(true); setError("");
|
||||
try {
|
||||
const data = Object.fromEntries(new FormData(event.currentTarget));
|
||||
const response = await fetch(`/api/auth/${mode}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
|
||||
const result = await response.json();
|
||||
if (!response.ok) { setError(result.error); return; }
|
||||
router.push("/directory"); router.refresh();
|
||||
} catch {
|
||||
setError("The request could not be completed. Check your connection and try again.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
{mode === "register" && <div className="rounded-2xl bg-[#f1eee6] p-1"><div className="flex rounded-full bg-white p-1 text-sm font-bold"><button type="button" onClick={() => { setJoinExisting(true); setError(""); }} className={`flex-1 rounded-full px-3 py-2 ${joinExisting ? "bg-[#173f35] text-white" : ""}`}>Join my directory</button><button type="button" onClick={() => { setJoinExisting(false); setError(""); }} className={`flex-1 rounded-full px-3 py-2 ${!joinExisting ? "bg-[#173f35] text-white" : ""}`}>Start an organization</button></div></div>}
|
||||
{mode === "register" && !joinExisting && <>
|
||||
<div><label className="label" htmlFor="name">Your full name</label><input className="field" id="name" name="name" autoComplete="name" required /></div>
|
||||
<div><label className="label" htmlFor="familyName">Family display name</label><input className="field" id="familyName" name="familyName" placeholder="The Anderson Family" required /></div>
|
||||
</>}
|
||||
<div><label className="label" htmlFor="email">Email address</label><input className="field" id="email" name="email" type="email" autoComplete="email" required /></div>
|
||||
{mode === "register" && joinExisting && <div><label className="label" htmlFor="joinCode">Organization invite code</label><input className="field uppercase" id="joinCode" name="joinCode" autoComplete="off" required /><p className="mt-2 text-xs leading-5 text-[#68736f]">Your email must already appear on an adult profile in the directory.</p></div>}
|
||||
{mode === "register" && !joinExisting && <div><label className="label" htmlFor="organizationName">Organization name</label><input className="field" id="organizationName" name="organizationName" placeholder="Grace Community" required /></div>}
|
||||
<div><label className="label" htmlFor="password">{mode === "register" ? "Create password" : "Password"}</label><input className="field" id="password" name="password" type="password" autoComplete={mode === "login" ? "current-password" : "new-password"} minLength={8} required /></div>
|
||||
{error && <p role="alert" className="rounded-xl bg-red-50 p-3 text-sm font-medium text-red-700">{error}</p>}
|
||||
<button disabled={pending} className="btn-primary w-full">{pending ? <LoaderCircle className="animate-spin" size={18}/> : <>{mode === "login" ? "Sign in" : "Create account"}<ArrowRight size={17}/></>}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Copy, Save } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type { Organization } from "@/lib/types";
|
||||
import { ImageUploader } from "./ImageUploader";
|
||||
|
||||
export function BrandingForm({ organization }: { organization: Organization }) {
|
||||
const router = useRouter(); const [form, setForm] = useState(organization); const [pending, setPending] = useState(false); const [message, setMessage] = useState(""); const [copied, setCopied] = useState(false);
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault(); setPending(true); setMessage("");
|
||||
try {
|
||||
const response = await fetch("/api/admin/branding", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...form, ...form.theme }) });
|
||||
const result = await response.json(); setMessage(response.ok ? "Brand settings saved." : result.error); if (response.ok) router.refresh();
|
||||
} catch {
|
||||
setMessage("Brand settings could not be saved. Check your connection and try again.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
async function copyCode() { await navigator.clipboard.writeText(form.joinCode); setCopied(true); setTimeout(() => setCopied(false), 1500); }
|
||||
return <form onSubmit={save} className="grid gap-7 lg:grid-cols-[1fr_320px]">
|
||||
<div className="space-y-7"><section className="rounded-[1.75rem] bg-white p-6 card-shadow sm:p-8"><h2 className="font-warm text-2xl font-bold text-[var(--brand)]">Identity</h2><div className="mt-6"><label className="label">Organization name</label><input className="field" required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })}/></div><div className="mt-6 grid gap-6 sm:grid-cols-2"><div><label className="label">Header logo</label><ImageUploader label="Upload logo" value={form.logoId} onChange={(logoId) => setForm({ ...form, logoId })}/></div><div><label className="label">App & favicon</label><ImageUploader label="Upload square app icon" value={form.faviconId} onChange={(faviconId) => setForm({ ...form, faviconId })}/><p className="mt-2 text-xs text-[#697570]">Use a square PNG, ideally 512 x 512 pixels.</p></div></div></section>
|
||||
<section className="rounded-[1.75rem] bg-white p-6 card-shadow sm:p-8"><h2 className="font-warm text-2xl font-bold text-[var(--brand)]">Theme</h2><p className="mt-1 text-sm text-[#697570]">Choose colors that reflect your organization.</p><div className="mt-6 grid gap-5 sm:grid-cols-3">{(["primary", "accent", "surface"] as const).map((key) => <label key={key}><span className="label">{key}</span><span className="flex items-center gap-2 rounded-xl border border-black/10 p-2"><input type="color" className="size-10 cursor-pointer border-0 bg-transparent" value={form.theme[key]} onChange={(event) => setForm({ ...form, theme: { ...form.theme, [key]: event.target.value } })}/><span className="text-sm font-mono">{form.theme[key]}</span></span></label>)}</div><div className="mt-6"><label className="label">Typography</label><select className="field" value={form.theme.font} onChange={(event) => setForm({ ...form, theme: { ...form.theme, font: event.target.value as Organization["theme"]["font"] } })}><option value="warm">Warm & welcoming</option><option value="modern">Modern & clear</option><option value="classic">Classic & traditional</option></select></div></section>
|
||||
</div>
|
||||
<aside className="space-y-6"><section className="rounded-[1.75rem] bg-[var(--brand)] p-6 text-white card-shadow"><p className="text-xs font-bold uppercase tracking-[.18em] text-white/60">Member invite code</p><p className="mt-3 font-mono text-3xl font-bold tracking-widest">{form.joinCode}</p><button type="button" onClick={copyCode} className="mt-5 flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold hover:bg-white/20">{copied ? <Check size={16}/> : <Copy size={16}/>} {copied ? "Copied" : "Copy code"}</button><p className="mt-4 text-sm leading-6 text-white/65">Share this code privately. New members use it when creating their account.</p></section><button disabled={pending} className="btn-primary w-full"><Save size={17}/>{pending ? "Saving..." : "Save branding"}</button>{message && <p className="text-center text-sm font-medium">{message}</p>}</aside>
|
||||
</form>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useDeferredValue, useState } from "react";
|
||||
import { Baby, MapPin, Search, UsersRound } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Family } from "@/lib/types";
|
||||
|
||||
function Initials({ family }: { family: Family }) {
|
||||
return <div className="grid h-full place-items-center bg-[color-mix(in_srgb,var(--brand)_88%,white)] text-4xl font-bold text-white">{family.familyName.slice(0, 2).toUpperCase()}</div>;
|
||||
}
|
||||
|
||||
export function DirectorySearch({ families, ownFamilyId }: { families: Family[]; ownFamilyId: string }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const deferredQuery = useDeferredValue(query.toLowerCase());
|
||||
const visible = families.filter((family) => `${family.familyName} ${family.adults.map((adult) => `${adult.firstName} ${adult.lastName}`).join(" ")} ${family.city || ""}`.toLowerCase().includes(deferredQuery));
|
||||
return <>
|
||||
<div className="relative mt-8 max-w-2xl"><Search className="pointer-events-none absolute top-1/2 left-4 -translate-y-1/2 text-[#7a8581]" size={20}/><input value={query} onChange={(event) => setQuery(event.target.value)} className="field field-search card-shadow py-4 text-base" placeholder="Search families, people, or city..." aria-label="Search directory"/></div>
|
||||
<div className="mt-9 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visible.map((family) => <Link key={family.id} href={`/family/${family.id}`} className="group card-shadow overflow-hidden rounded-[1.5rem] bg-white transition hover:-translate-y-1">
|
||||
<div className="relative h-48 overflow-hidden">{family.photoId ? <img src={`/api/assets/${family.photoId}`} alt={`${family.familyName} family`} className="h-full w-full object-cover transition duration-500 group-hover:scale-105"/> : <Initials family={family}/>} {family.id === ownFamilyId && <span className="absolute top-3 right-3 rounded-full bg-white/90 px-3 py-1 text-xs font-bold text-[var(--brand)]">Your family</span>}</div>
|
||||
<div className="p-5"><h2 className="font-warm text-2xl font-bold text-[var(--brand)]">{family.familyName}</h2><p className="mt-1 truncate text-sm text-[#64706b]">{family.adults.map((person) => person.firstName).join(" & ")}</p><div className="mt-5 flex gap-4 text-xs font-bold uppercase tracking-wider text-[#7a8581]"><span className="flex items-center gap-1"><UsersRound size={15}/>{family.adults.length} adult{family.adults.length !== 1 && "s"}</span>{family.children.length > 0 && <span className="flex items-center gap-1"><Baby size={15}/>{family.children.length}</span>}</div>{family.city && <p className="mt-3 flex items-center gap-1 text-sm text-[#64706b]"><MapPin size={14}/>{family.city}{family.state ? `, ${family.state}` : ""}</p>}</div>
|
||||
</Link>)}
|
||||
</div>
|
||||
{visible.length === 0 && <div className="mt-16 text-center text-[#697570]"><Search className="mx-auto mb-3"/>No families match your search.</div>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Save, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type { Family, Person } from "@/lib/types";
|
||||
import { ImageUploader } from "./ImageUploader";
|
||||
|
||||
const blankPerson = (): Person => ({ id: crypto.randomUUID(), firstName: "", lastName: "", email: "", phone: "", birthday: "" });
|
||||
const usRegions = [
|
||||
["AL", "Alabama"], ["AK", "Alaska"], ["AZ", "Arizona"], ["AR", "Arkansas"], ["CA", "California"], ["CO", "Colorado"], ["CT", "Connecticut"], ["DE", "Delaware"], ["DC", "District of Columbia"], ["FL", "Florida"], ["GA", "Georgia"], ["HI", "Hawaii"], ["ID", "Idaho"], ["IL", "Illinois"], ["IN", "Indiana"], ["IA", "Iowa"], ["KS", "Kansas"], ["KY", "Kentucky"], ["LA", "Louisiana"], ["ME", "Maine"], ["MD", "Maryland"], ["MA", "Massachusetts"], ["MI", "Michigan"], ["MN", "Minnesota"], ["MS", "Mississippi"], ["MO", "Missouri"], ["MT", "Montana"], ["NE", "Nebraska"], ["NV", "Nevada"], ["NH", "New Hampshire"], ["NJ", "New Jersey"], ["NM", "New Mexico"], ["NY", "New York"], ["NC", "North Carolina"], ["ND", "North Dakota"], ["OH", "Ohio"], ["OK", "Oklahoma"], ["OR", "Oregon"], ["PA", "Pennsylvania"], ["RI", "Rhode Island"], ["SC", "South Carolina"], ["SD", "South Dakota"], ["TN", "Tennessee"], ["TX", "Texas"], ["UT", "Utah"], ["VT", "Vermont"], ["VA", "Virginia"], ["WA", "Washington"], ["WV", "West Virginia"], ["WI", "Wisconsin"], ["WY", "Wyoming"], ["AS", "American Samoa"], ["GU", "Guam"], ["MP", "Northern Mariana Islands"], ["PR", "Puerto Rico"], ["VI", "U.S. Virgin Islands"],
|
||||
] as const;
|
||||
const canadianRegions = [["AB", "Alberta"], ["BC", "British Columbia"], ["MB", "Manitoba"], ["NB", "New Brunswick"], ["NL", "Newfoundland and Labrador"], ["NS", "Nova Scotia"], ["NT", "Northwest Territories"], ["NU", "Nunavut"], ["ON", "Ontario"], ["PE", "Prince Edward Island"], ["QC", "Quebec"], ["SK", "Saskatchewan"], ["YT", "Yukon"]] as const;
|
||||
|
||||
function PersonFields({ person, onChange, onRemove }: { person: Person; onChange: (person: Person) => void; onRemove: () => void }) {
|
||||
return <div className="rounded-2xl border border-black/8 bg-white p-4 sm:p-5"><div className="flex gap-4"><ImageUploader compact label="Photo" value={person.photoId} onChange={(photoId) => onChange({ ...person, photoId })}/><div className="grid flex-1 gap-3 sm:grid-cols-2"><div><label className="label">First name</label><input className="field" value={person.firstName} onChange={(event) => onChange({ ...person, firstName: event.target.value })}/></div><div><label className="label">Last name</label><input className="field" value={person.lastName} onChange={(event) => onChange({ ...person, lastName: event.target.value })}/></div></div><button type="button" aria-label="Remove person" onClick={onRemove} className="self-start rounded-full p-2 text-[#9a5b50] hover:bg-red-50"><Trash2 size={18}/></button></div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3"><div><label className="label">Email</label><input className="field" type="email" value={person.email || ""} onChange={(event) => onChange({ ...person, email: event.target.value })}/></div><div><label className="label">Phone</label><input className="field" type="tel" value={person.phone || ""} onChange={(event) => onChange({ ...person, phone: event.target.value })}/></div><div><label className="label">Birthday</label><input className="field" type="text" placeholder="MM/DD or YYYY-MM-DD" value={person.birthday || ""} onChange={(event) => onChange({ ...person, birthday: event.target.value })}/></div></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function FamilyEditor({ initialFamily }: { initialFamily: Family }) {
|
||||
const router = useRouter();
|
||||
const [family, setFamily] = useState(initialFamily);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
function updatePerson(group: "adults" | "children", index: number, person?: Person) {
|
||||
const next = [...family[group]];
|
||||
if (person) next[index] = person; else next.splice(index, 1);
|
||||
setFamily({ ...family, [group]: next });
|
||||
}
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault(); setPending(true); setMessage("");
|
||||
try {
|
||||
const response = await fetch("/api/family", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(family) });
|
||||
const result = await response.json();
|
||||
if (response.ok) { setFamily(result.family); setMessage("Your family profile has been saved."); router.refresh(); } else setMessage(result.error);
|
||||
} catch {
|
||||
setMessage("Your changes could not be saved. Check your connection and try again.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
return <form onSubmit={save} className="space-y-9">
|
||||
<section className="grid gap-6 rounded-[1.75rem] bg-white p-5 card-shadow sm:p-7 md:grid-cols-[220px_1fr]"><ImageUploader label="Upload a family photo" value={family.photoId} onChange={(photoId) => setFamily({ ...family, photoId })}/><div><div><label className="label">Family display name</label><input className="field text-lg font-bold" required value={family.familyName} onChange={(event) => setFamily({ ...family, familyName: event.target.value })}/></div><div className="mt-4 grid gap-3 sm:grid-cols-2"><div className="sm:col-span-2"><label className="label">Street address</label><input className="field" value={family.address || ""} onChange={(event) => setFamily({ ...family, address: event.target.value })}/></div><div><label className="label">City</label><input className="field" value={family.city || ""} onChange={(event) => setFamily({ ...family, city: event.target.value })}/></div><div className="grid grid-cols-2 gap-3"><div><label className="label">State / Province</label><select className="field field-select" value={family.state || ""} onChange={(event) => setFamily({ ...family, state: event.target.value })}><option value="">Select</option><optgroup label="United States">{usRegions.map(([code, name]) => <option key={code} value={code}>{name}</option>)}</optgroup><optgroup label="Canada">{canadianRegions.map(([code, name]) => <option key={code} value={code}>{name}</option>)}</optgroup></select></div><div><label className="label">Postal code</label><input className="field" value={family.postalCode || ""} onChange={(event) => setFamily({ ...family, postalCode: event.target.value })}/></div></div><div><label className="label">Home phone</label><input className="field" type="tel" value={family.homePhone || ""} onChange={(event) => setFamily({ ...family, homePhone: event.target.value })}/></div></div></div></section>
|
||||
<section><div className="mb-4 flex items-center justify-between"><div><h2 className="font-warm text-2xl font-bold text-[var(--brand)]">Parents & adults</h2><p className="text-sm text-[#66716d]">At least one adult is required.</p></div><button type="button" className="btn-secondary" onClick={() => setFamily({ ...family, adults: [...family.adults, blankPerson()] })}><Plus size={17}/> Add</button></div><div className="space-y-4">{family.adults.map((person, index) => <PersonFields key={person.id} person={person} onChange={(next) => updatePerson("adults", index, next)} onRemove={() => updatePerson("adults", index)}/>)}</div></section>
|
||||
<section><div className="mb-4 flex items-center justify-between"><div><h2 className="font-warm text-2xl font-bold text-[var(--brand)]">Children</h2><p className="text-sm text-[#66716d]">Full birth dates are checked to ensure children are under 18.</p></div><button type="button" className="btn-secondary" onClick={() => setFamily({ ...family, children: [...family.children, blankPerson()] })}><Plus size={17}/> Add</button></div><div className="space-y-4">{family.children.map((person, index) => <PersonFields key={person.id} person={person} onChange={(next) => updatePerson("children", index, next)} onRemove={() => updatePerson("children", index)}/>)}</div></section>
|
||||
<div className="sticky bottom-4 flex items-center justify-between gap-4 rounded-2xl border border-black/10 bg-white/95 p-4 shadow-xl backdrop-blur"><p className={`text-sm font-medium ${message.includes("saved") ? "text-green-700" : "text-red-700"}`}>{message}</p><button disabled={pending} className="btn-primary shrink-0"><Save size={17}/>{pending ? "Saving..." : "Save family"}</button></div>
|
||||
</form>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Camera, LoaderCircle, Upload } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export function ImageUploader({ value, onChange, label, compact = false }: { value?: string; onChange: (id: string) => void; label: string; compact?: boolean }) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
async function upload(file?: File) {
|
||||
if (!file) return;
|
||||
setPending(true); setError("");
|
||||
try {
|
||||
const body = new FormData(); body.append("file", file);
|
||||
const response = await fetch("/api/upload", { method: "POST", body });
|
||||
const result = await response.json();
|
||||
if (response.ok) onChange(result.id); else setError(result.error);
|
||||
} catch {
|
||||
setError("The image could not be uploaded. Check your connection and try again.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
return <div>
|
||||
<button type="button" onClick={() => input.current?.click()} className={`${compact ? "size-20" : "h-44 w-full"} relative grid overflow-hidden rounded-2xl border-2 border-dashed border-black/15 bg-[var(--surface)] place-items-center text-center text-sm font-bold text-[var(--brand)] hover:border-[var(--brand)]`}>
|
||||
{value ? <img src={`/api/assets/${value}`} alt="Uploaded preview" className="absolute inset-0 h-full w-full object-cover"/> : <span className="p-3">{compact ? <Camera className="mx-auto"/> : <><Upload className="mx-auto mb-2"/> {label}</>}</span>}
|
||||
{pending && <span className="absolute inset-0 grid place-items-center bg-white/80"><LoaderCircle className="animate-spin"/></span>}
|
||||
</button>
|
||||
<input ref={input} className="hidden" type="file" accept="image/jpeg,image/png,image/webp,image/gif" onChange={(event) => upload(event.target.files?.[0])}/>
|
||||
{error && <p className="mt-1 text-xs text-red-700">{error}</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ServiceWorker() {
|
||||
useEffect(() => {
|
||||
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js");
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db, objectId } from "./db";
|
||||
import type { SessionUser } from "./types";
|
||||
|
||||
const COOKIE = "kinship_session";
|
||||
function secret() {
|
||||
const value = process.env.AUTH_SECRET;
|
||||
if (process.env.NODE_ENV === "production" && (!value || value.length < 32)) {
|
||||
throw new Error("AUTH_SECRET must contain at least 32 characters in production");
|
||||
}
|
||||
return new TextEncoder().encode(value || "development-only-secret-do-not-deploy");
|
||||
}
|
||||
|
||||
export async function createSession(user: SessionUser, secure = process.env.NODE_ENV === "production") {
|
||||
const token = await new SignJWT({ ...user })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("30d")
|
||||
.sign(secret());
|
||||
const store = await cookies();
|
||||
store.set(COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearSession() {
|
||||
const store = await cookies();
|
||||
store.delete(COOKIE);
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<SessionUser | null> {
|
||||
const token = (await cookies()).get(COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, secret());
|
||||
const userId = objectId(payload.id);
|
||||
if (!userId) return null;
|
||||
const user = await (await db()).collection("users").findOne({ _id: userId });
|
||||
if (!user || user.organizationId.toHexString() !== payload.organizationId || user.familyId.toHexString() !== payload.familyId) return null;
|
||||
return {
|
||||
id: user._id.toHexString(),
|
||||
organizationId: user.organizationId.toHexString(),
|
||||
familyId: user.familyId.toHexString(),
|
||||
role: user.role,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireSession(): Promise<SessionUser> {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function requireAdmin(): Promise<SessionUser> {
|
||||
const session = await requireSession();
|
||||
if (session.role !== "admin") redirect("/directory");
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MongoClient, ObjectId, type Db, type Document } from "mongodb";
|
||||
import type { Family, Organization, Person, Theme } from "./types";
|
||||
|
||||
const globalMongo = globalThis as typeof globalThis & {
|
||||
mongoClient?: Promise<MongoClient>;
|
||||
mongoIndexed?: boolean;
|
||||
};
|
||||
|
||||
export async function db(): Promise<Db> {
|
||||
const uri = process.env.MONGODB_URI;
|
||||
if (!uri) throw new Error("MONGODB_URI is not configured");
|
||||
const clientPromise = globalMongo.mongoClient ?? new MongoClient(uri).connect();
|
||||
globalMongo.mongoClient = clientPromise;
|
||||
const client = await clientPromise;
|
||||
const database = client.db(process.env.MONGODB_DB || "kinship_directory");
|
||||
if (!globalMongo.mongoIndexed) {
|
||||
await Promise.all([
|
||||
database.collection("users").createIndex({ email: 1 }, { unique: true }),
|
||||
database.collection("families").createIndex({ organizationId: 1, familyName: 1 }),
|
||||
database.collection("images").createIndex({ organizationId: 1 }),
|
||||
database.collection("organizations").createIndex({ joinCode: 1 }, { unique: true }),
|
||||
]);
|
||||
globalMongo.mongoIndexed = true;
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
const stringId = (value: unknown) => value instanceof ObjectId ? value.toHexString() : String(value);
|
||||
|
||||
export function mapPerson(person: Document): Person {
|
||||
return {
|
||||
id: String(person.id),
|
||||
firstName: String(person.firstName || ""),
|
||||
lastName: String(person.lastName || ""),
|
||||
email: person.email ? String(person.email) : undefined,
|
||||
phone: person.phone ? String(person.phone) : undefined,
|
||||
birthday: person.birthday ? String(person.birthday) : undefined,
|
||||
photoId: person.photoId ? String(person.photoId) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFamily(doc: Document): Family {
|
||||
return {
|
||||
id: stringId(doc._id),
|
||||
organizationId: stringId(doc.organizationId),
|
||||
familyName: String(doc.familyName),
|
||||
address: doc.address ? String(doc.address) : undefined,
|
||||
city: doc.city ? String(doc.city) : undefined,
|
||||
state: doc.state ? String(doc.state) : undefined,
|
||||
postalCode: doc.postalCode ? String(doc.postalCode) : undefined,
|
||||
homePhone: doc.homePhone ? String(doc.homePhone) : undefined,
|
||||
photoId: doc.photoId ? String(doc.photoId) : undefined,
|
||||
adults: Array.isArray(doc.adults) ? doc.adults.map(mapPerson) : [],
|
||||
children: Array.isArray(doc.children) ? doc.children.map(mapPerson) : [],
|
||||
updatedAt: doc.updatedAt instanceof Date ? doc.updatedAt.toISOString() : new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const defaultTheme: Theme = { primary: "#173f35", accent: "#df9b63", surface: "#f7f4ed", font: "warm" };
|
||||
|
||||
export function mapOrganization(doc: Document): Organization {
|
||||
return {
|
||||
id: stringId(doc._id),
|
||||
name: String(doc.name),
|
||||
joinCode: String(doc.joinCode),
|
||||
logoId: doc.logoId ? String(doc.logoId) : undefined,
|
||||
faviconId: doc.faviconId ? String(doc.faviconId) : undefined,
|
||||
theme: { ...defaultTheme, ...(doc.theme || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
export function objectId(value: unknown): ObjectId | null {
|
||||
return typeof value === "string" && ObjectId.isValid(value) ? new ObjectId(value) : null;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type Theme = {
|
||||
primary: string;
|
||||
accent: string;
|
||||
surface: string;
|
||||
font: "modern" | "warm" | "classic";
|
||||
};
|
||||
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
familyId: string;
|
||||
role: "admin" | "member";
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type Person = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
birthday?: string;
|
||||
photoId?: string;
|
||||
};
|
||||
|
||||
export type Family = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
familyName: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
homePhone?: string;
|
||||
photoId?: string;
|
||||
adults: Person[];
|
||||
children: Person[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Organization = {
|
||||
id: string;
|
||||
name: string;
|
||||
joinCode: string;
|
||||
logoId?: string;
|
||||
faviconId?: string;
|
||||
theme: Theme;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Person } from "./types";
|
||||
|
||||
export const clean = (value: unknown, max = 120) => String(value || "").trim().slice(0, max);
|
||||
export const normalizeEmail = (value: unknown) => clean(value, 254).toLowerCase();
|
||||
|
||||
export function isSameOrigin(request: Request): boolean {
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin) return true;
|
||||
try {
|
||||
const originUrl = new URL(origin);
|
||||
const host = request.headers.get("x-forwarded-host") || request.headers.get("host");
|
||||
const protocol = request.headers.get("x-forwarded-proto");
|
||||
return originUrl.host === host && (!protocol || originUrl.protocol === `${protocol}:`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSecureRequest(request: Request): boolean {
|
||||
const protocol = request.headers.get("x-forwarded-proto");
|
||||
return protocol ? protocol === "https" : new URL(request.url).protocol === "https:";
|
||||
}
|
||||
|
||||
export function isUnder18(birthday: string): boolean {
|
||||
const slashDate = birthday.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
const date = slashDate
|
||||
? new Date(Number(slashDate[3]), Number(slashDate[1]) - 1, Number(slashDate[2]))
|
||||
: new Date(`${birthday}T00:00:00`);
|
||||
if (Number.isNaN(date.getTime())) return false;
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - date.getFullYear();
|
||||
const beforeBirthday = today.getMonth() < date.getMonth() ||
|
||||
(today.getMonth() === date.getMonth() && today.getDate() < date.getDate());
|
||||
if (beforeBirthday) age--;
|
||||
return age >= 0 && age < 18;
|
||||
}
|
||||
|
||||
function isValidBirthday(birthday: string): boolean {
|
||||
if (!birthday) return true;
|
||||
const monthDay = birthday.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{4}))?$/);
|
||||
if (monthDay) {
|
||||
const month = Number(monthDay[1]); const day = Number(monthDay[2]); const year = Number(monthDay[3] || 2000);
|
||||
const date = new Date(year, month - 1, day);
|
||||
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
|
||||
}
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(birthday) && !Number.isNaN(new Date(`${birthday}T00:00:00`).getTime());
|
||||
}
|
||||
|
||||
export function parsePeople(value: unknown, children = false): Person[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.slice(0, 20).map((raw, index) => {
|
||||
const person = raw as Record<string, unknown>;
|
||||
const birthday = clean(person.birthday, 10);
|
||||
if (!isValidBirthday(birthday)) throw new Error(`Person ${index + 1} has an invalid birthday.`);
|
||||
const hasBirthYear = /^\d{4}-/.test(birthday) || /^\d{1,2}\/\d{1,2}\/\d{4}$/.test(birthday);
|
||||
if (children && hasBirthYear && !isUnder18(birthday)) throw new Error(`Child ${index + 1} must be under 18.`);
|
||||
return {
|
||||
id: clean(person.id, 50) || crypto.randomUUID(),
|
||||
firstName: clean(person.firstName, 60),
|
||||
lastName: clean(person.lastName, 60),
|
||||
email: normalizeEmail(person.email) || undefined,
|
||||
phone: clean(person.phone, 30) || undefined,
|
||||
birthday: birthday || undefined,
|
||||
photoId: clean(person.photoId, 50) || undefined,
|
||||
};
|
||||
}).filter((person) => person.firstName && person.lastName);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user