Initial commit: Kinship Directory application

This commit is contained in:
2026-09-05 10:33:45 -05:00
commit 0eb4240f0a
51 changed files with 4682 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
.next
.git
.env*
yarn-error.log
.yarn/cache
.yarn/install-state.gz
.yarn/unplugged
+3
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
node_modules
.next
.env
.env.local
yarn-error.log
.yarn/cache
.yarn/install-state.gz
.yarn/unplugged
.DS_Store
+23
View File
@@ -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"]
+50
View File
@@ -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.
+27
View File
@@ -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:
+78
View File
@@ -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;
}
}
+41
View File
@@ -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
+5
View File
@@ -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")];
+6
View File
@@ -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.
+9
View File
@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
serverActions: { bodySizeLimit: "6mb" },
},
};
export default nextConfig;
+34
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+17
View File
@@ -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("/"))));
});
+97
View File
@@ -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();
}
+13
View File
@@ -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>;
}
+10
View File
@@ -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>;
}
+35
View File
@@ -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>;
}
+13
View File
@@ -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>;
}
+35
View File
@@ -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>;
}
+40
View File
@@ -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 });
}
+20
View File
@@ -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 });
}
+21
View File
@@ -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",
},
});
}
+25
View File
@@ -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 });
}
+9
View File
@@ -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);
}
+107
View File
@@ -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 });
}
}
+18
View File
@@ -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" } });
}
+53
View File
@@ -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 });
}
}
+31
View File
@@ -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() });
}
+56
View File
@@ -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; } }
+16
View File
@@ -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>;
}
+7
View File
@@ -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>;
}
+17
View File
@@ -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" },
],
};
}
+33
View File
@@ -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>
);
}
+7
View File
@@ -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>;
}
+26
View File
@@ -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>;
}
+43
View File
@@ -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>
);
}
+29
View File
@@ -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>;
}
+26
View File
@@ -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>}
</>;
}
+49
View File
@@ -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>;
}
+32
View File
@@ -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>;
}
+10
View File
@@ -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;
}
+69
View File
@@ -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;
}
+74
View File
@@ -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;
}
+49
View File
@@ -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;
};
+67
View File
@@ -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);
}
+21
View File
@@ -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
+3134
View File
File diff suppressed because it is too large Load Diff