From c3e3d66fef9723dc12f0852cd2121df3b9561992 Mon Sep 17 00:00:00 2001 From: Paul R Kartchner Date: Fri, 17 Apr 2026 08:08:10 -0600 Subject: [PATCH] feat: add family-based multi-tenant access control Introduces Family as the tenant boundary so recipes and cookbooks can be scoped per household instead of every user seeing everything. Adds a centralized access filter, an invite/membership UI, a first-login prompt to create a family, and locks down the previously unauthenticated backup routes to admin only. - Family and FamilyMember models with OWNER/MEMBER roles; familyId on Recipe and Cookbook (ON DELETE SET NULL so deleting a family orphans content rather than destroying it). - access.service.ts composes a single WhereInput covering owner, family, PUBLIC visibility, and direct share; admins short-circuit to full access. - recipes/cookbooks routes now require auth, strip client-supplied userId/familyId on create, and gate mutations with canMutate checks. Auto-filter helpers scoped to the same family to prevent cross-tenant leakage via shared tag names. - families.routes.ts exposes list/create/get/rename/delete plus add/remove member, with last-owner protection on removal. - FamilyGate component blocks the authenticated UI with a modal if the user has zero memberships, prompting them to create their first family; Family page provides ongoing management. - backup.routes.ts now requires admin; it had no auth at all before. - Bumps version to 2026.04.008 and documents the monotonic PPP counter in CLAUDE.md. Migration SQL is generated locally but not tracked (per existing .gitignore); apply 20260416010000_add_family_tenant to prod during deploy. Run backfill-family-tenant.ts once post-migration to assign existing content to a default owner's family. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 10 +- packages/api/prisma/schema.prisma | 44 +++- packages/api/src/index.ts | 2 + packages/api/src/routes/backup.routes.ts | 3 + packages/api/src/routes/cookbooks.routes.ts | 128 ++++++++- packages/api/src/routes/families.routes.ts | 237 +++++++++++++++++ packages/api/src/routes/recipes.routes.ts | 152 +++++++---- .../api/src/scripts/backfill-family-tenant.ts | 155 +++++++++++ packages/api/src/services/access.service.ts | 108 ++++++++ packages/api/src/version.ts | 2 +- packages/web/src/App.tsx | 5 + packages/web/src/components/FamilyGate.tsx | 101 ++++++++ packages/web/src/components/UserMenu.tsx | 7 + packages/web/src/pages/Family.tsx | 245 ++++++++++++++++++ packages/web/src/services/api.ts | 63 +++++ packages/web/src/styles/Family.css | 173 +++++++++++++ packages/web/src/styles/FamilyGate.css | 77 ++++++ packages/web/src/version.ts | 2 +- 18 files changed, 1451 insertions(+), 63 deletions(-) create mode 100644 packages/api/src/routes/families.routes.ts create mode 100644 packages/api/src/scripts/backfill-family-tenant.ts create mode 100644 packages/api/src/services/access.service.ts create mode 100644 packages/web/src/components/FamilyGate.tsx create mode 100644 packages/web/src/pages/Family.tsx create mode 100644 packages/web/src/styles/Family.css create mode 100644 packages/web/src/styles/FamilyGate.css diff --git a/CLAUDE.md b/CLAUDE.md index e19248e..9051163 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -279,13 +279,13 @@ Basil includes a complete CI/CD pipeline with Gitea Actions for automated testin Basil uses calendar versioning with the format: `YYYY.MM.PPP` - `YYYY` - Four-digit year (e.g., 2026) - `MM` - Two-digit month with zero-padding (e.g., 01 for January, 12 for December) -- `PPP` - Three-digit patch number with zero-padding that increases with each deployment in a month +- `PPP` - Three-digit patch number with zero-padding that increases with every deployment. **Does not reset at month boundaries** — it is a monotonically increasing counter across the lifetime of the project. ### Examples -- `2026.01.001` - First deployment in January 2026 -- `2026.01.002` - Second deployment in January 2026 -- `2026.02.001` - First deployment in February 2026 (patch resets to 001) -- `2026.02.003` - Third deployment in February 2026 +- `2026.01.006` - Sixth deployment (in January 2026) +- `2026.04.007` - Seventh deployment (in April 2026 — patch continues from previous month, does not reset) +- `2026.04.008` - Eighth deployment (still in April 2026) +- `2026.05.009` - Ninth deployment (in May 2026 — patch continues, does not reset) ### Version Update Process When deploying to production: diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 459b87d..c829df2 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -29,11 +29,45 @@ model User { refreshTokens RefreshToken[] verificationTokens VerificationToken[] mealPlans MealPlan[] + familyMemberships FamilyMember[] @@index([email]) @@index([provider, providerId]) } +enum FamilyRole { + OWNER + MEMBER +} + +model Family { + id String @id @default(cuid()) + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + members FamilyMember[] + recipes Recipe[] + cookbooks Cookbook[] + + @@index([name]) +} + +model FamilyMember { + id String @id @default(cuid()) + userId String + familyId String + role FamilyRole @default(MEMBER) + joinedAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + family Family @relation(fields: [familyId], references: [id], onDelete: Cascade) + + @@unique([userId, familyId]) + @@index([userId]) + @@index([familyId]) +} + model VerificationToken { id String @id @default(cuid()) userId String @@ -91,12 +125,14 @@ model Recipe { cuisine String? categories String[] @default([]) // Changed from single category to array rating Float? - userId String? // Recipe owner + userId String? // Recipe owner (creator) + familyId String? // Owning family (tenant scope) visibility Visibility @default(PRIVATE) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + family Family? @relation(fields: [familyId], references: [id], onDelete: SetNull) sections RecipeSection[] ingredients Ingredient[] instructions Instruction[] @@ -109,6 +145,7 @@ model Recipe { @@index([title]) @@index([cuisine]) @@index([userId]) + @@index([familyId]) @@index([visibility]) } @@ -236,7 +273,8 @@ model Cookbook { name String description String? coverImageUrl String? - userId String? // Cookbook owner + userId String? // Cookbook owner (creator) + familyId String? // Owning family (tenant scope) autoFilterCategories String[] @default([]) // Auto-add recipes matching these categories autoFilterTags String[] @default([]) // Auto-add recipes matching these tags autoFilterCookbookTags String[] @default([]) // Auto-add cookbooks matching these tags @@ -244,6 +282,7 @@ model Cookbook { updatedAt DateTime @updatedAt user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + family Family? @relation(fields: [familyId], references: [id], onDelete: SetNull) recipes CookbookRecipe[] tags CookbookTag[] includedCookbooks CookbookInclusion[] @relation("ParentCookbook") @@ -251,6 +290,7 @@ model Cookbook { @@index([name]) @@index([userId]) + @@index([familyId]) } model CookbookRecipe { diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 1619e2f..3233c6e 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -10,6 +10,7 @@ import tagsRoutes from './routes/tags.routes'; import backupRoutes from './routes/backup.routes'; import authRoutes from './routes/auth.routes'; import mealPlansRoutes from './routes/meal-plans.routes'; +import familiesRoutes from './routes/families.routes'; import './config/passport'; // Initialize passport strategies import { testEmailConfig } from './services/email.service'; import { APP_VERSION } from './version'; @@ -40,6 +41,7 @@ app.use('/api/cookbooks', cookbooksRoutes); app.use('/api/tags', tagsRoutes); app.use('/api/backup', backupRoutes); app.use('/api/meal-plans', mealPlansRoutes); +app.use('/api/families', familiesRoutes); // Health check app.get('/health', (req, res) => { diff --git a/packages/api/src/routes/backup.routes.ts b/packages/api/src/routes/backup.routes.ts index 3614bd5..436aace 100644 --- a/packages/api/src/routes/backup.routes.ts +++ b/packages/api/src/routes/backup.routes.ts @@ -2,10 +2,13 @@ import express, { Request, Response } from 'express'; import path from 'path'; import fs from 'fs/promises'; import { createBackup, restoreBackup, listBackups, deleteBackup } from '../services/backup.service'; +import { requireAuth, requireAdmin } from '../middleware/auth.middleware'; import multer from 'multer'; const router = express.Router(); +router.use(requireAuth, requireAdmin); + // Configure multer for backup file uploads const upload = multer({ dest: '/tmp/basil-restore/', diff --git a/packages/api/src/routes/cookbooks.routes.ts b/packages/api/src/routes/cookbooks.routes.ts index 2e9538c..29b90b9 100644 --- a/packages/api/src/routes/cookbooks.routes.ts +++ b/packages/api/src/routes/cookbooks.routes.ts @@ -2,8 +2,16 @@ import { Router, Request, Response } from 'express'; import multer from 'multer'; import prisma from '../config/database'; import { StorageService } from '../services/storage.service'; +import { + getAccessContext, + buildCookbookAccessFilter, + canMutateCookbook, + getPrimaryFamilyId, +} from '../services/access.service'; +import { requireAuth } from '../middleware/auth.middleware'; const router = Router(); +router.use(requireAuth); const upload = multer({ storage: multer.memoryStorage(), limits: { @@ -57,9 +65,11 @@ async function applyFiltersToExistingRecipes(cookbookId: string) { }); } - // Find matching recipes + // Find matching recipes within the same family (tenant scope). + if (!cookbook.familyId) return; const matchingRecipes = await prisma.recipe.findMany({ where: { + familyId: cookbook.familyId, OR: whereConditions }, select: { id: true } @@ -107,11 +117,13 @@ async function applyFiltersToExistingCookbooks(cookbookId: string) { return; } - // Find matching cookbooks (excluding self) + // Find matching cookbooks within the same family (tenant scope). + if (!cookbook.familyId) return; const matchingCookbooks = await prisma.cookbook.findMany({ where: { AND: [ { id: { not: cookbookId } }, + { familyId: cookbook.familyId }, { tags: { some: { @@ -166,11 +178,14 @@ async function autoAddToParentCookbooks(cookbookId: string) { const cookbookTags = cookbook.tags.map((ct: any) => ct.tag.name); if (cookbookTags.length === 0) return; - // Find parent cookbooks with filters matching this cookbook's tags + // Find parent cookbooks with filters matching this cookbook's tags, + // scoped to the same family. + if (!cookbook.familyId) return; const parentCookbooks = await prisma.cookbook.findMany({ where: { AND: [ { id: { not: cookbookId } }, + { familyId: cookbook.familyId }, { autoFilterCookbookTags: { hasSome: cookbookTags } } ] } @@ -203,6 +218,8 @@ async function autoAddToParentCookbooks(cookbookId: string) { router.get('/', async (req: Request, res: Response) => { try { const { includeChildren = 'false' } = req.query; + const ctx = await getAccessContext(req.user!); + const accessFilter = buildCookbookAccessFilter(ctx); // Get child cookbook IDs to exclude from main listing (unless includeChildren is true) const childCookbookIds = includeChildren === 'true' ? [] : ( @@ -213,8 +230,11 @@ router.get('/', async (req: Request, res: Response) => { ).map((ci: any) => ci.childCookbookId); const cookbooks = await prisma.cookbook.findMany({ - where: includeChildren === 'true' ? {} : { - id: { notIn: childCookbookIds } + where: { + AND: [ + accessFilter, + includeChildren === 'true' ? {} : { id: { notIn: childCookbookIds } }, + ], }, include: { _count: { @@ -256,9 +276,10 @@ router.get('/', async (req: Request, res: Response) => { router.get('/:id', async (req: Request, res: Response) => { try { const { id } = req.params; + const ctx = await getAccessContext(req.user!); - const cookbook = await prisma.cookbook.findUnique({ - where: { id }, + const cookbook = await prisma.cookbook.findFirst({ + where: { AND: [{ id }, buildCookbookAccessFilter(ctx)] }, include: { recipes: { include: { @@ -342,11 +363,15 @@ router.post('/', async (req: Request, res: Response) => { return res.status(400).json({ error: 'Name is required' }); } + const familyId = await getPrimaryFamilyId(req.user!.id); + const cookbook = await prisma.cookbook.create({ data: { name, description, coverImageUrl, + userId: req.user!.id, + familyId, autoFilterCategories: autoFilterCategories || [], autoFilterTags: autoFilterTags || [], autoFilterCookbookTags: autoFilterCookbookTags || [], @@ -388,6 +413,16 @@ router.put('/:id', async (req: Request, res: Response) => { const { id } = req.params; const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags, autoFilterCookbookTags, tags } = req.body; + const ctx = await getAccessContext(req.user!); + const existing = await prisma.cookbook.findUnique({ + where: { id }, + select: { userId: true, familyId: true }, + }); + if (!existing) return res.status(404).json({ error: 'Cookbook not found' }); + if (!canMutateCookbook(ctx, existing)) { + return res.status(403).json({ error: 'Forbidden' }); + } + const updateData: any = {}; if (name !== undefined) updateData.name = name; if (description !== undefined) updateData.description = description; @@ -460,6 +495,15 @@ router.put('/:id', async (req: Request, res: Response) => { router.delete('/:id', async (req: Request, res: Response) => { try { const { id } = req.params; + const ctx = await getAccessContext(req.user!); + const cookbook = await prisma.cookbook.findUnique({ + where: { id }, + select: { userId: true, familyId: true }, + }); + if (!cookbook) return res.status(404).json({ error: 'Cookbook not found' }); + if (!canMutateCookbook(ctx, cookbook)) { + return res.status(403).json({ error: 'Forbidden' }); + } await prisma.cookbook.delete({ where: { id } @@ -476,6 +520,26 @@ router.delete('/:id', async (req: Request, res: Response) => { router.post('/:id/recipes/:recipeId', async (req: Request, res: Response) => { try { const { id, recipeId } = req.params; + const ctx = await getAccessContext(req.user!); + const cookbook = await prisma.cookbook.findUnique({ + where: { id }, + select: { userId: true, familyId: true }, + }); + if (!cookbook) return res.status(404).json({ error: 'Cookbook not found' }); + if (!canMutateCookbook(ctx, cookbook)) { + return res.status(403).json({ error: 'Forbidden' }); + } + // Prevent pulling recipes from other tenants into this cookbook. + const recipe = await prisma.recipe.findUnique({ + where: { id: recipeId }, + select: { userId: true, familyId: true, visibility: true }, + }); + if (!recipe) return res.status(404).json({ error: 'Recipe not found' }); + const sameFamily = !!recipe.familyId && recipe.familyId === cookbook.familyId; + const ownedByUser = recipe.userId === ctx.userId; + if (ctx.role !== 'ADMIN' && !sameFamily && !ownedByUser) { + return res.status(403).json({ error: 'Cannot add recipe from a different tenant' }); + } // Check if recipe is already in cookbook const existing = await prisma.cookbookRecipe.findUnique({ @@ -509,6 +573,15 @@ router.post('/:id/recipes/:recipeId', async (req: Request, res: Response) => { router.delete('/:id/recipes/:recipeId', async (req: Request, res: Response) => { try { const { id, recipeId } = req.params; + const ctx = await getAccessContext(req.user!); + const cookbook = await prisma.cookbook.findUnique({ + where: { id }, + select: { userId: true, familyId: true }, + }); + if (!cookbook) return res.status(404).json({ error: 'Cookbook not found' }); + if (!canMutateCookbook(ctx, cookbook)) { + return res.status(403).json({ error: 'Forbidden' }); + } await prisma.cookbookRecipe.delete({ where: { @@ -536,6 +609,26 @@ router.post('/:id/cookbooks/:childCookbookId', async (req: Request, res: Respons return res.status(400).json({ error: 'Cannot add cookbook to itself' }); } + const ctx = await getAccessContext(req.user!); + const parent = await prisma.cookbook.findUnique({ + where: { id }, + select: { userId: true, familyId: true }, + }); + if (!parent) return res.status(404).json({ error: 'Cookbook not found' }); + if (!canMutateCookbook(ctx, parent)) { + return res.status(403).json({ error: 'Forbidden' }); + } + const child = await prisma.cookbook.findUnique({ + where: { id: childCookbookId }, + select: { userId: true, familyId: true }, + }); + if (!child) return res.status(404).json({ error: 'Cookbook not found' }); + const sameFamily = !!child.familyId && child.familyId === parent.familyId; + const ownedByUser = child.userId === ctx.userId; + if (ctx.role !== 'ADMIN' && !sameFamily && !ownedByUser) { + return res.status(403).json({ error: 'Cannot nest a cookbook from a different tenant' }); + } + // Check if cookbook is already included const existing = await prisma.cookbookInclusion.findUnique({ where: { @@ -568,6 +661,15 @@ router.post('/:id/cookbooks/:childCookbookId', async (req: Request, res: Respons router.delete('/:id/cookbooks/:childCookbookId', async (req: Request, res: Response) => { try { const { id, childCookbookId } = req.params; + const ctx = await getAccessContext(req.user!); + const parent = await prisma.cookbook.findUnique({ + where: { id }, + select: { userId: true, familyId: true }, + }); + if (!parent) return res.status(404).json({ error: 'Cookbook not found' }); + if (!canMutateCookbook(ctx, parent)) { + return res.status(403).json({ error: 'Forbidden' }); + } await prisma.cookbookInclusion.delete({ where: { @@ -594,10 +696,14 @@ router.post('/:id/image', upload.single('image'), async (req: Request, res: Resp return res.status(400).json({ error: 'No image provided' }); } - // Delete old cover image if it exists + const ctx = await getAccessContext(req.user!); const cookbook = await prisma.cookbook.findUnique({ where: { id } }); + if (!cookbook) return res.status(404).json({ error: 'Cookbook not found' }); + if (!canMutateCookbook(ctx, cookbook)) { + return res.status(403).json({ error: 'Forbidden' }); + } if (cookbook?.coverImageUrl) { await storageService.deleteFile(cookbook.coverImageUrl); @@ -629,10 +735,14 @@ router.post('/:id/image-from-url', async (req: Request, res: Response) => { return res.status(400).json({ error: 'URL is required' }); } - // Delete old cover image if it exists + const ctx = await getAccessContext(req.user!); const cookbook = await prisma.cookbook.findUnique({ where: { id } }); + if (!cookbook) return res.status(404).json({ error: 'Cookbook not found' }); + if (!canMutateCookbook(ctx, cookbook)) { + return res.status(403).json({ error: 'Forbidden' }); + } if (cookbook?.coverImageUrl) { await storageService.deleteFile(cookbook.coverImageUrl); diff --git a/packages/api/src/routes/families.routes.ts b/packages/api/src/routes/families.routes.ts new file mode 100644 index 0000000..5037b55 --- /dev/null +++ b/packages/api/src/routes/families.routes.ts @@ -0,0 +1,237 @@ +import { Router, Request, Response } from 'express'; +import prisma from '../config/database'; +import { requireAuth } from '../middleware/auth.middleware'; +import { FamilyRole } from '@prisma/client'; + +const router = Router(); +router.use(requireAuth); + +async function getMembership(userId: string, familyId: string) { + return prisma.familyMember.findUnique({ + where: { userId_familyId: { userId, familyId } }, + }); +} + +// List the current user's families. +router.get('/', async (req: Request, res: Response) => { + try { + const userId = req.user!.id; + const memberships = await prisma.familyMember.findMany({ + where: { userId }, + include: { + family: { include: { _count: { select: { members: true } } } }, + }, + orderBy: { joinedAt: 'asc' }, + }); + res.json({ + data: memberships.map((m) => ({ + id: m.family.id, + name: m.family.name, + role: m.role, + memberCount: m.family._count.members, + joinedAt: m.joinedAt, + })), + }); + } catch (error) { + console.error('Error fetching families:', error); + res.status(500).json({ error: 'Failed to fetch families' }); + } +}); + +// Create a new family (caller becomes OWNER). +router.post('/', async (req: Request, res: Response) => { + try { + const { name } = req.body; + if (!name || typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ error: 'Name is required' }); + } + const family = await prisma.family.create({ + data: { + name: name.trim(), + members: { create: { userId: req.user!.id, role: 'OWNER' } }, + }, + }); + res.status(201).json({ data: family }); + } catch (error) { + console.error('Error creating family:', error); + res.status(500).json({ error: 'Failed to create family' }); + } +}); + +// Get a family including its members. Must be a member. +router.get('/:id', async (req: Request, res: Response) => { + try { + const userId = req.user!.id; + const membership = await getMembership(userId, req.params.id); + if (!membership && req.user!.role !== 'ADMIN') { + return res.status(404).json({ error: 'Family not found' }); + } + + const family = await prisma.family.findUnique({ + where: { id: req.params.id }, + include: { + members: { + include: { user: { select: { id: true, email: true, name: true, avatar: true } } }, + orderBy: { joinedAt: 'asc' }, + }, + }, + }); + if (!family) return res.status(404).json({ error: 'Family not found' }); + + res.json({ + data: { + id: family.id, + name: family.name, + createdAt: family.createdAt, + updatedAt: family.updatedAt, + myRole: membership?.role ?? null, + members: family.members.map((m) => ({ + userId: m.userId, + email: m.user.email, + name: m.user.name, + avatar: m.user.avatar, + role: m.role, + joinedAt: m.joinedAt, + })), + }, + }); + } catch (error) { + console.error('Error fetching family:', error); + res.status(500).json({ error: 'Failed to fetch family' }); + } +}); + +// Rename a family. OWNER only. +router.put('/:id', async (req: Request, res: Response) => { + try { + const userId = req.user!.id; + const membership = await getMembership(userId, req.params.id); + const isAdmin = req.user!.role === 'ADMIN'; + if (!membership || (membership.role !== 'OWNER' && !isAdmin)) { + return res.status(403).json({ error: 'Owner access required' }); + } + const { name } = req.body; + if (!name || typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ error: 'Name is required' }); + } + const family = await prisma.family.update({ + where: { id: req.params.id }, + data: { name: name.trim() }, + }); + res.json({ data: family }); + } catch (error) { + console.error('Error updating family:', error); + res.status(500).json({ error: 'Failed to update family' }); + } +}); + +// Delete a family. OWNER only. Recipes/cookbooks in this family get familyId=NULL. +router.delete('/:id', async (req: Request, res: Response) => { + try { + const userId = req.user!.id; + const membership = await getMembership(userId, req.params.id); + const isAdmin = req.user!.role === 'ADMIN'; + if (!membership || (membership.role !== 'OWNER' && !isAdmin)) { + return res.status(403).json({ error: 'Owner access required' }); + } + await prisma.family.delete({ where: { id: req.params.id } }); + res.json({ message: 'Family deleted' }); + } catch (error) { + console.error('Error deleting family:', error); + res.status(500).json({ error: 'Failed to delete family' }); + } +}); + +// Add an existing user to a family by email. OWNER only. +router.post('/:id/members', async (req: Request, res: Response) => { + try { + const userId = req.user!.id; + const membership = await getMembership(userId, req.params.id); + const isAdmin = req.user!.role === 'ADMIN'; + if (!membership || (membership.role !== 'OWNER' && !isAdmin)) { + return res.status(403).json({ error: 'Owner access required' }); + } + + const { email, role } = req.body; + if (!email || typeof email !== 'string') { + return res.status(400).json({ error: 'Email is required' }); + } + const invitedRole: FamilyRole = role === 'OWNER' ? 'OWNER' : 'MEMBER'; + + const invitee = await prisma.user.findUnique({ + where: { email: email.toLowerCase() }, + select: { id: true, email: true, name: true, avatar: true }, + }); + if (!invitee) { + return res.status(404).json({ error: 'No user with that email exists on this server' }); + } + + const existing = await getMembership(invitee.id, req.params.id); + if (existing) { + return res.status(409).json({ error: 'User is already a member' }); + } + + const newMember = await prisma.familyMember.create({ + data: { userId: invitee.id, familyId: req.params.id, role: invitedRole }, + }); + + res.status(201).json({ + data: { + userId: invitee.id, + email: invitee.email, + name: invitee.name, + avatar: invitee.avatar, + role: newMember.role, + joinedAt: newMember.joinedAt, + }, + }); + } catch (error) { + console.error('Error adding member:', error); + res.status(500).json({ error: 'Failed to add member' }); + } +}); + +// Remove a member (or leave as self). OWNER can remove anyone; a member can only remove themselves. +router.delete('/:id/members/:userId', async (req: Request, res: Response) => { + try { + const currentUserId = req.user!.id; + const targetUserId = req.params.userId; + const membership = await getMembership(currentUserId, req.params.id); + const isAdmin = req.user!.role === 'ADMIN'; + + if (!membership && !isAdmin) { + return res.status(403).json({ error: 'Not a member of this family' }); + } + + const isOwner = membership?.role === 'OWNER'; + const isSelf = targetUserId === currentUserId; + if (!isOwner && !isSelf && !isAdmin) { + return res.status(403).json({ error: 'Only owners can remove other members' }); + } + + const target = await getMembership(targetUserId, req.params.id); + if (!target) { + return res.status(404).json({ error: 'Member not found' }); + } + + // Don't let the last OWNER leave/be removed — would orphan the family. + if (target.role === 'OWNER') { + const ownerCount = await prisma.familyMember.count({ + where: { familyId: req.params.id, role: 'OWNER' }, + }); + if (ownerCount <= 1) { + return res.status(400).json({ error: 'Cannot remove the last owner; transfer ownership or delete the family first' }); + } + } + + await prisma.familyMember.delete({ + where: { userId_familyId: { userId: targetUserId, familyId: req.params.id } }, + }); + res.json({ message: 'Member removed' }); + } catch (error) { + console.error('Error removing member:', error); + res.status(500).json({ error: 'Failed to remove member' }); + } +}); + +export default router; diff --git a/packages/api/src/routes/recipes.routes.ts b/packages/api/src/routes/recipes.routes.ts index adade8f..ecf9a89 100644 --- a/packages/api/src/routes/recipes.routes.ts +++ b/packages/api/src/routes/recipes.routes.ts @@ -4,9 +4,17 @@ import prisma from '../config/database'; import { StorageService } from '../services/storage.service'; import { ScraperService } from '../services/scraper.service'; import { autoMapIngredients, saveIngredientMappings } from '../services/ingredientMatcher.service'; +import { + getAccessContext, + buildRecipeAccessFilter, + canMutateRecipe, + getPrimaryFamilyId, +} from '../services/access.service'; +import { requireAuth } from '../middleware/auth.middleware'; import { ApiResponse, RecipeImportRequest } from '@basil/shared'; const router = Router(); +router.use(requireAuth); const upload = multer({ storage: multer.memoryStorage(), limits: { @@ -23,7 +31,8 @@ const upload = multer({ const storageService = StorageService.getInstance(); const scraperService = new ScraperService(); -// Helper function to auto-add recipe to cookbooks based on their filters +// Helper function to auto-add recipe to cookbooks based on their filters. +// Scoped to same family to prevent cross-tenant leakage via shared tag names. async function autoAddToCookbooks(recipeId: string) { try { // Get the recipe with its category and tags @@ -43,9 +52,11 @@ async function autoAddToCookbooks(recipeId: string) { const recipeTags = recipe.tags.map((rt: any) => rt.tag.name); const recipeCategories = recipe.categories || []; - // Get all cookbooks with auto-filters + // Get cookbooks in the same family with auto-filters. Skip unscoped recipes. + if (!recipe.familyId) return; const cookbooks = await prisma.cookbook.findMany({ where: { + familyId: recipe.familyId, OR: [ { autoFilterCategories: { isEmpty: false } }, { autoFilterTags: { isEmpty: false } } @@ -107,36 +118,35 @@ router.get('/', async (req, res) => { const limitNum = parseInt(limit as string); const skip = (pageNum - 1) * limitNum; - const where: any = {}; + const ctx = await getAccessContext(req.user!); + const where: any = { AND: [buildRecipeAccessFilter(ctx)] }; if (search) { - where.OR = [ - { title: { contains: search as string, mode: 'insensitive' } }, - { description: { contains: search as string, mode: 'insensitive' } }, - { - tags: { - some: { - tag: { - name: { contains: search as string, mode: 'insensitive' } + where.AND.push({ + OR: [ + { title: { contains: search as string, mode: 'insensitive' } }, + { description: { contains: search as string, mode: 'insensitive' } }, + { + tags: { + some: { + tag: { + name: { contains: search as string, mode: 'insensitive' } + } } } - } - }, - ]; - } - if (cuisine) where.cuisine = cuisine; - if (category) { - where.categories = { - has: category as string - }; + }, + ], + }); } + if (cuisine) where.AND.push({ cuisine }); + if (category) where.AND.push({ categories: { has: category as string } }); if (tag) { - where.tags = { - some: { - tag: { - name: { equals: tag as string, mode: 'insensitive' } - } - } - }; + where.AND.push({ + tags: { + some: { + tag: { name: { equals: tag as string, mode: 'insensitive' } }, + }, + }, + }); } const [recipes, total] = await Promise.all([ @@ -215,8 +225,9 @@ router.get('/', async (req, res) => { // Get single recipe router.get('/:id', async (req, res) => { try { - const recipe = await prisma.recipe.findUnique({ - where: { id: req.params.id }, + const ctx = await getAccessContext(req.user!); + const recipe = await prisma.recipe.findFirst({ + where: { AND: [{ id: req.params.id }, buildRecipeAccessFilter(ctx)] }, include: { sections: { orderBy: { order: 'asc' }, @@ -285,11 +296,17 @@ router.get('/:id', async (req, res) => { router.post('/', async (req, res) => { try { const { title, description, sections, ingredients, instructions, tags, ...recipeData } = req.body; + // Strip any client-supplied ownership — always derive server-side. + delete recipeData.userId; + delete recipeData.familyId; + const familyId = await getPrimaryFamilyId(req.user!.id); const recipe = await prisma.recipe.create({ data: { title, description, + userId: req.user!.id, + familyId, ...recipeData, sections: sections ? { @@ -361,7 +378,20 @@ router.post('/', async (req, res) => { // Update recipe router.put('/:id', async (req, res) => { try { + const ctx = await getAccessContext(req.user!); + const existing = await prisma.recipe.findUnique({ + where: { id: req.params.id }, + select: { userId: true, familyId: true, visibility: true }, + }); + if (!existing) return res.status(404).json({ error: 'Recipe not found' }); + if (!canMutateRecipe(ctx, existing)) { + return res.status(403).json({ error: 'Forbidden' }); + } + const { sections, ingredients, instructions, tags, ...recipeData } = req.body; + // Block client from reassigning ownership via update. + delete recipeData.userId; + delete recipeData.familyId; // Only delete relations that are being updated (not undefined) if (sections !== undefined) { @@ -465,20 +495,23 @@ router.put('/:id', async (req, res) => { // Delete recipe router.delete('/:id', async (req, res) => { try { + const ctx = await getAccessContext(req.user!); // Get recipe to delete associated images const recipe = await prisma.recipe.findUnique({ where: { id: req.params.id }, include: { images: true }, }); + if (!recipe) return res.status(404).json({ error: 'Recipe not found' }); + if (!canMutateRecipe(ctx, recipe)) { + return res.status(403).json({ error: 'Forbidden' }); + } - if (recipe) { - // Delete images from storage - if (recipe.imageUrl) { - await storageService.deleteFile(recipe.imageUrl); - } - for (const image of recipe.images) { - await storageService.deleteFile(image.url); - } + // Delete images from storage + if (recipe.imageUrl) { + await storageService.deleteFile(recipe.imageUrl); + } + for (const image of recipe.images) { + await storageService.deleteFile(image.url); } await prisma.recipe.delete({ where: { id: req.params.id } }); @@ -505,16 +538,20 @@ router.post('/:id/images', upload.single('image'), async (req, res) => { return res.status(400).json({ error: 'No image provided' }); } + const ctx = await getAccessContext(req.user!); + const existingRecipe = await prisma.recipe.findUnique({ + where: { id: req.params.id }, + select: { imageUrl: true, userId: true, familyId: true, visibility: true }, + }); + if (!existingRecipe) return res.status(404).json({ error: 'Recipe not found' }); + if (!canMutateRecipe(ctx, existingRecipe)) { + return res.status(403).json({ error: 'Forbidden' }); + } + console.log('Saving file to storage...'); const imageUrl = await storageService.saveFile(req.file, 'recipes'); console.log('File saved, URL:', imageUrl); - // Get existing recipe to delete old image - const existingRecipe = await prisma.recipe.findUnique({ - where: { id: req.params.id }, - select: { imageUrl: true }, - }); - // Delete old image from storage if it exists if (existingRecipe?.imageUrl) { console.log('Deleting old image:', existingRecipe.imageUrl); @@ -550,12 +587,17 @@ router.post('/:id/images', upload.single('image'), async (req, res) => { // Delete recipe image router.delete('/:id/image', async (req, res) => { try { + const ctx = await getAccessContext(req.user!); const recipe = await prisma.recipe.findUnique({ where: { id: req.params.id }, - select: { imageUrl: true }, + select: { imageUrl: true, userId: true, familyId: true, visibility: true }, }); + if (!recipe) return res.status(404).json({ error: 'Recipe not found' }); + if (!canMutateRecipe(ctx, recipe)) { + return res.status(403).json({ error: 'Forbidden' }); + } - if (!recipe?.imageUrl) { + if (!recipe.imageUrl) { return res.status(404).json({ error: 'No image to delete' }); } @@ -606,6 +648,16 @@ router.post('/:id/ingredient-mappings', async (req, res) => { return res.status(400).json({ error: 'Mappings must be an array' }); } + const ctx = await getAccessContext(req.user!); + const recipe = await prisma.recipe.findUnique({ + where: { id: req.params.id }, + select: { userId: true, familyId: true, visibility: true }, + }); + if (!recipe) return res.status(404).json({ error: 'Recipe not found' }); + if (!canMutateRecipe(ctx, recipe)) { + return res.status(403).json({ error: 'Forbidden' }); + } + await saveIngredientMappings(mappings); res.json({ message: 'Mappings updated successfully' }); @@ -618,6 +670,16 @@ router.post('/:id/ingredient-mappings', async (req, res) => { // Regenerate ingredient-instruction mappings router.post('/:id/regenerate-mappings', async (req, res) => { try { + const ctx = await getAccessContext(req.user!); + const recipe = await prisma.recipe.findUnique({ + where: { id: req.params.id }, + select: { userId: true, familyId: true, visibility: true }, + }); + if (!recipe) return res.status(404).json({ error: 'Recipe not found' }); + if (!canMutateRecipe(ctx, recipe)) { + return res.status(403).json({ error: 'Forbidden' }); + } + await autoMapIngredients(req.params.id); res.json({ message: 'Mappings regenerated successfully' }); diff --git a/packages/api/src/scripts/backfill-family-tenant.ts b/packages/api/src/scripts/backfill-family-tenant.ts new file mode 100644 index 0000000..96a1ee4 --- /dev/null +++ b/packages/api/src/scripts/backfill-family-tenant.ts @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * Backfill default families for existing data. + * + * For every user, ensure they have a personal Family (as OWNER). + * Any Recipe or Cookbook that they own (userId = them) but has no familyId + * is assigned to that family. + * + * Orphan content (userId IS NULL) is assigned to --owner (default: first ADMIN user) + * so existing legacy records don't disappear behind the access filter. + * + * Idempotent — safe to re-run. + * + * Usage: + * npx tsx src/scripts/backfill-family-tenant.ts + * npx tsx src/scripts/backfill-family-tenant.ts --owner admin@basil.local + * npx tsx src/scripts/backfill-family-tenant.ts --dry-run + */ + +import { PrismaClient, User, Family } from '@prisma/client'; + +const prisma = new PrismaClient(); + +interface Options { + ownerEmail?: string; + dryRun: boolean; +} + +function parseArgs(): Options { + const args = process.argv.slice(2); + const opts: Options = { dryRun: false }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--dry-run') opts.dryRun = true; + else if (args[i] === '--owner' && args[i + 1]) { + opts.ownerEmail = args[++i]; + } + } + return opts; +} + +async function ensurePersonalFamily(user: User, dryRun: boolean): Promise { + const existing = await prisma.familyMember.findFirst({ + where: { userId: user.id, role: 'OWNER' }, + include: { family: true }, + }); + if (existing) return existing.family; + + const name = `${user.name || user.email.split('@')[0]}'s Family`; + if (dryRun) { + console.log(` [dry-run] would create Family "${name}" for ${user.email}`); + return { id: '', name, createdAt: new Date(), updatedAt: new Date() }; + } + + const family = await prisma.family.create({ + data: { + name, + members: { + create: { userId: user.id, role: 'OWNER' }, + }, + }, + }); + console.log(` Created Family "${family.name}" (${family.id}) for ${user.email}`); + return family; +} + +async function main() { + const opts = parseArgs(); + console.log(`\n🌿 Family tenant backfill${opts.dryRun ? ' [DRY RUN]' : ''}\n`); + + // 1. Pick legacy owner for orphan records. + let legacyOwner: User | null = null; + if (opts.ownerEmail) { + legacyOwner = await prisma.user.findUnique({ where: { email: opts.ownerEmail.toLowerCase() } }); + if (!legacyOwner) { + console.error(`❌ No user with email ${opts.ownerEmail}`); + process.exit(1); + } + } else { + legacyOwner = await prisma.user.findFirst({ + where: { role: 'ADMIN' }, + orderBy: { createdAt: 'asc' }, + }); + } + + if (!legacyOwner) { + console.warn('⚠️ No admin user found; orphan recipes/cookbooks will be left with familyId = NULL'); + } else { + console.log(`Legacy owner for orphan content: ${legacyOwner.email}\n`); + } + + // 2. Ensure every user has a personal family. + const users = await prisma.user.findMany({ orderBy: { createdAt: 'asc' } }); + console.log(`Processing ${users.length} user(s):`); + const userFamily = new Map(); + for (const u of users) { + const fam = await ensurePersonalFamily(u, opts.dryRun); + userFamily.set(u.id, fam); + } + + // 3. Backfill Recipe.familyId and Cookbook.familyId. + const targets = [ + { label: 'Recipe', model: prisma.recipe }, + { label: 'Cookbook', model: prisma.cookbook }, + ] as const; + + let totalUpdated = 0; + + for (const { label, model } of targets) { + // Owned content without a familyId — assign to owner's family. + const ownedRows: { id: string; userId: string | null }[] = await (model as any).findMany({ + where: { familyId: null, userId: { not: null } }, + select: { id: true, userId: true }, + }); + + for (const row of ownedRows) { + const fam = userFamily.get(row.userId!); + if (!fam) continue; + if (!opts.dryRun) { + await (model as any).update({ where: { id: row.id }, data: { familyId: fam.id } }); + } + totalUpdated++; + } + console.log(` ${label}: ${ownedRows.length} owned row(s) assigned to owner's family`); + + // Orphan content — assign to legacy owner's family if configured. + if (legacyOwner) { + const legacyFam = userFamily.get(legacyOwner.id)!; + const orphans: { id: string }[] = await (model as any).findMany({ + where: { familyId: null, userId: null }, + select: { id: true }, + }); + for (const row of orphans) { + if (!opts.dryRun) { + await (model as any).update({ + where: { id: row.id }, + data: { familyId: legacyFam.id, userId: legacyOwner.id }, + }); + } + totalUpdated++; + } + console.log(` ${label}: ${orphans.length} orphan row(s) assigned to ${legacyOwner.email}'s family`); + } + } + + console.log(`\n✅ Backfill complete (${totalUpdated} row(s) ${opts.dryRun ? 'would be ' : ''}updated)\n`); +} + +main() + .catch((err) => { + console.error('❌ Backfill failed:', err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/packages/api/src/services/access.service.ts b/packages/api/src/services/access.service.ts new file mode 100644 index 0000000..c7fc206 --- /dev/null +++ b/packages/api/src/services/access.service.ts @@ -0,0 +1,108 @@ +import type { Prisma, User } from '@prisma/client'; +import prisma from '../config/database'; + +export interface AccessContext { + userId: string; + role: 'USER' | 'ADMIN'; + familyIds: string[]; +} + +export async function getAccessContext(user: User): Promise { + const memberships = await prisma.familyMember.findMany({ + where: { userId: user.id }, + select: { familyId: true }, + }); + return { + userId: user.id, + role: user.role, + familyIds: memberships.map((m) => m.familyId), + }; +} + +export function buildRecipeAccessFilter(ctx: AccessContext): Prisma.RecipeWhereInput { + if (ctx.role === 'ADMIN') return {}; + return { + OR: [ + { userId: ctx.userId }, + { familyId: { in: ctx.familyIds } }, + { visibility: 'PUBLIC' }, + { sharedWith: { some: { userId: ctx.userId } } }, + ], + }; +} + +export function buildCookbookAccessFilter(ctx: AccessContext): Prisma.CookbookWhereInput { + if (ctx.role === 'ADMIN') return {}; + return { + OR: [ + { userId: ctx.userId }, + { familyId: { in: ctx.familyIds } }, + ], + }; +} + +type RecipeAccessSubject = { + userId: string | null; + familyId: string | null; + visibility: 'PRIVATE' | 'SHARED' | 'PUBLIC'; +}; + +type CookbookAccessSubject = { + userId: string | null; + familyId: string | null; +}; + +export function canReadRecipe( + ctx: AccessContext, + recipe: RecipeAccessSubject, + sharedUserIds: string[] = [], +): boolean { + if (ctx.role === 'ADMIN') return true; + if (recipe.userId === ctx.userId) return true; + if (recipe.familyId && ctx.familyIds.includes(recipe.familyId)) return true; + if (recipe.visibility === 'PUBLIC') return true; + if (sharedUserIds.includes(ctx.userId)) return true; + return false; +} + +export function canMutateRecipe( + ctx: AccessContext, + recipe: RecipeAccessSubject, +): boolean { + if (ctx.role === 'ADMIN') return true; + if (recipe.userId === ctx.userId) return true; + if (recipe.familyId && ctx.familyIds.includes(recipe.familyId)) return true; + return false; +} + +export function canReadCookbook( + ctx: AccessContext, + cookbook: CookbookAccessSubject, +): boolean { + if (ctx.role === 'ADMIN') return true; + if (cookbook.userId === ctx.userId) return true; + if (cookbook.familyId && ctx.familyIds.includes(cookbook.familyId)) return true; + return false; +} + +export function canMutateCookbook( + ctx: AccessContext, + cookbook: CookbookAccessSubject, +): boolean { + return canReadCookbook(ctx, cookbook); +} + +export async function getPrimaryFamilyId(userId: string): Promise { + const owner = await prisma.familyMember.findFirst({ + where: { userId, role: 'OWNER' }, + orderBy: { joinedAt: 'asc' }, + select: { familyId: true }, + }); + if (owner) return owner.familyId; + const any = await prisma.familyMember.findFirst({ + where: { userId }, + orderBy: { joinedAt: 'asc' }, + select: { familyId: true }, + }); + return any?.familyId ?? null; +} diff --git a/packages/api/src/version.ts b/packages/api/src/version.ts index 9afb94d..d824e17 100644 --- a/packages/api/src/version.ts +++ b/packages/api/src/version.ts @@ -3,4 +3,4 @@ * Example: 2026.01.002 (January 2026, patch 2), 2026.02.003 (February 2026, patch 3) * Month and patch are zero-padded. Patch increments with each deployment in a month. */ -export const APP_VERSION = '2026.01.006'; +export const APP_VERSION = '2026.04.008'; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index ad58622..1690dda 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -4,6 +4,7 @@ import { ThemeProvider } from './contexts/ThemeContext'; import ProtectedRoute from './components/ProtectedRoute'; import UserMenu from './components/UserMenu'; import ThemeToggle from './components/ThemeToggle'; +import FamilyGate from './components/FamilyGate'; import Login from './pages/Login'; import Register from './pages/Register'; import AuthCallback from './pages/AuthCallback'; @@ -16,6 +17,7 @@ import RecipeImport from './pages/RecipeImport'; import NewRecipe from './pages/NewRecipe'; import UnifiedEditRecipe from './pages/UnifiedEditRecipe'; import CookingMode from './pages/CookingMode'; +import Family from './pages/Family'; import { APP_VERSION } from './version'; import './App.css'; @@ -24,6 +26,7 @@ function App() { +
@@ -64,6 +67,7 @@ function App() { } /> } /> } /> + } />
@@ -74,6 +78,7 @@ function App() {
+
diff --git a/packages/web/src/components/FamilyGate.tsx b/packages/web/src/components/FamilyGate.tsx new file mode 100644 index 0000000..cbbd09b --- /dev/null +++ b/packages/web/src/components/FamilyGate.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState, FormEvent, ReactNode } from 'react'; +import { familiesApi } from '../services/api'; +import { useAuth } from '../contexts/AuthContext'; +import '../styles/FamilyGate.css'; + +interface FamilyGateProps { + children: ReactNode; +} + +type CheckState = 'idle' | 'checking' | 'needs-family' | 'ready'; + +export default function FamilyGate({ children }: FamilyGateProps) { + const { isAuthenticated, loading: authLoading, logout } = useAuth(); + const [state, setState] = useState('idle'); + const [name, setName] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (authLoading) return; + if (!isAuthenticated) { + setState('idle'); + return; + } + let cancelled = false; + (async () => { + setState('checking'); + try { + const res = await familiesApi.list(); + if (cancelled) return; + const count = res.data?.length ?? 0; + setState(count === 0 ? 'needs-family' : 'ready'); + } catch { + if (!cancelled) setState('ready'); + } + })(); + return () => { cancelled = true; }; + }, [isAuthenticated, authLoading]); + + async function handleCreate(e: FormEvent) { + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + setSubmitting(true); + setError(null); + try { + await familiesApi.create(trimmed); + setState('ready'); + } catch (e: any) { + setError(e?.response?.data?.error || 'Failed to create family'); + } finally { + setSubmitting(false); + } + } + + const showModal = isAuthenticated && state === 'needs-family'; + + return ( + <> + {children} + {showModal && ( +
+
+

Create your family

+

+ To keep recipes organized and shareable, every account belongs to + a family. Name yours to get started — you can invite others later. +

+
+ + setName(e.target.value)} + placeholder="e.g. Smith Family" + autoFocus + disabled={submitting} + required + /> + {error &&
{error}
} +
+ + +
+
+
+
+ )} + + ); +} diff --git a/packages/web/src/components/UserMenu.tsx b/packages/web/src/components/UserMenu.tsx index acbd0f7..f3f08a4 100644 --- a/packages/web/src/components/UserMenu.tsx +++ b/packages/web/src/components/UserMenu.tsx @@ -96,6 +96,13 @@ const UserMenu: React.FC = () => { > My Cookbooks + setIsOpen(false)} + > + Family + {isAdmin && ( <>
diff --git a/packages/web/src/pages/Family.tsx b/packages/web/src/pages/Family.tsx new file mode 100644 index 0000000..7889c79 --- /dev/null +++ b/packages/web/src/pages/Family.tsx @@ -0,0 +1,245 @@ +import { useEffect, useState, FormEvent } from 'react'; +import { + familiesApi, + FamilySummary, + FamilyDetail, + FamilyMemberInfo, +} from '../services/api'; +import { useAuth } from '../contexts/AuthContext'; +import '../styles/Family.css'; + +export default function Family() { + const { user } = useAuth(); + const [families, setFamilies] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [newFamilyName, setNewFamilyName] = useState(''); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteRole, setInviteRole] = useState<'MEMBER' | 'OWNER'>('MEMBER'); + const [busy, setBusy] = useState(false); + + async function loadFamilies() { + setError(null); + try { + const res = await familiesApi.list(); + const list = res.data ?? []; + setFamilies(list); + if (!selectedId && list.length > 0) setSelectedId(list[0].id); + if (selectedId && !list.find((f) => f.id === selectedId)) { + setSelectedId(list[0]?.id ?? null); + } + } catch (e: any) { + setError(e?.response?.data?.error || e?.message || 'Failed to load families'); + } + } + + async function loadDetail(id: string) { + try { + const res = await familiesApi.get(id); + setDetail(res.data ?? null); + } catch (e: any) { + setError(e?.response?.data?.error || e?.message || 'Failed to load family'); + setDetail(null); + } + } + + useEffect(() => { + (async () => { + setLoading(true); + await loadFamilies(); + setLoading(false); + })(); + }, []); + + useEffect(() => { + if (selectedId) loadDetail(selectedId); + else setDetail(null); + }, [selectedId]); + + async function handleCreateFamily(e: FormEvent) { + e.preventDefault(); + if (!newFamilyName.trim()) return; + setBusy(true); + setError(null); + try { + const res = await familiesApi.create(newFamilyName.trim()); + setNewFamilyName(''); + if (res.data) setSelectedId(res.data.id); + await loadFamilies(); + } catch (e: any) { + setError(e?.response?.data?.error || 'Failed to create family'); + } finally { + setBusy(false); + } + } + + async function handleInvite(e: FormEvent) { + e.preventDefault(); + if (!selectedId || !inviteEmail.trim()) return; + setBusy(true); + setError(null); + try { + await familiesApi.addMember(selectedId, inviteEmail.trim(), inviteRole); + setInviteEmail(''); + setInviteRole('MEMBER'); + await loadDetail(selectedId); + await loadFamilies(); + } catch (e: any) { + setError(e?.response?.data?.error || 'Failed to add member'); + } finally { + setBusy(false); + } + } + + async function handleRemoveMember(member: FamilyMemberInfo) { + if (!selectedId) return; + const isSelf = member.userId === user?.id; + const confirmMsg = isSelf + ? `Leave "${detail?.name}"?` + : `Remove ${member.name || member.email} from this family?`; + if (!confirm(confirmMsg)) return; + setBusy(true); + setError(null); + try { + await familiesApi.removeMember(selectedId, member.userId); + await loadFamilies(); + if (isSelf) { + setSelectedId(null); + } else { + await loadDetail(selectedId); + } + } catch (e: any) { + setError(e?.response?.data?.error || 'Failed to remove member'); + } finally { + setBusy(false); + } + } + + async function handleDeleteFamily() { + if (!selectedId || !detail) return; + if (!confirm(`Delete family "${detail.name}"? Recipes and cookbooks in this family will lose their family assignment (they won't be deleted).`)) return; + setBusy(true); + setError(null); + try { + await familiesApi.remove(selectedId); + setSelectedId(null); + await loadFamilies(); + } catch (e: any) { + setError(e?.response?.data?.error || 'Failed to delete family'); + } finally { + setBusy(false); + } + } + + if (loading) return
Loading…
; + + const isOwner = detail?.myRole === 'OWNER'; + + return ( +
+

Families

+ {error &&
{error}
} + +
+
+ + +
+
+ +
+ + +
+ {!detail &&

Select a family to see its members.

} + {detail && ( + <> +
+

{detail.name}

+ {isOwner && ( + + )} +
+ +

Members

+ + + + + + {detail.members.map((m) => ( + + + + + + + ))} + +
NameEmailRole
{m.name || '—'}{m.email}{m.role} + {(isOwner || m.userId === user?.id) && ( + + )} +
+ + {isOwner && ( + <> +

Invite a member

+

User must already have a Basil account on this server.

+
+ setInviteEmail(e.target.value)} + disabled={busy} + required + /> + + +
+ + )} + + )} +
+
+
+ ); +} diff --git a/packages/web/src/services/api.ts b/packages/web/src/services/api.ts index d6be18b..6756a91 100644 --- a/packages/web/src/services/api.ts +++ b/packages/web/src/services/api.ts @@ -237,4 +237,67 @@ export const mealPlansApi = { }, }; +export type FamilyRole = 'OWNER' | 'MEMBER'; + +export interface FamilySummary { + id: string; + name: string; + role: FamilyRole; + memberCount: number; + joinedAt: string; +} + +export interface FamilyMemberInfo { + userId: string; + email: string; + name: string | null; + avatar: string | null; + role: FamilyRole; + joinedAt: string; +} + +export interface FamilyDetail { + id: string; + name: string; + createdAt: string; + updatedAt: string; + myRole: FamilyRole | null; + members: FamilyMemberInfo[]; +} + +export const familiesApi = { + list: async (): Promise> => { + const response = await api.get('/families'); + return response.data; + }, + create: async (name: string): Promise> => { + const response = await api.post('/families', { name }); + return response.data; + }, + get: async (id: string): Promise> => { + const response = await api.get(`/families/${id}`); + return response.data; + }, + rename: async (id: string, name: string): Promise> => { + const response = await api.put(`/families/${id}`, { name }); + return response.data; + }, + remove: async (id: string): Promise> => { + const response = await api.delete(`/families/${id}`); + return response.data; + }, + addMember: async ( + familyId: string, + email: string, + role: FamilyRole = 'MEMBER', + ): Promise> => { + const response = await api.post(`/families/${familyId}/members`, { email, role }); + return response.data; + }, + removeMember: async (familyId: string, userId: string): Promise> => { + const response = await api.delete(`/families/${familyId}/members/${userId}`); + return response.data; + }, +}; + export default api; diff --git a/packages/web/src/styles/Family.css b/packages/web/src/styles/Family.css new file mode 100644 index 0000000..f5f7bbd --- /dev/null +++ b/packages/web/src/styles/Family.css @@ -0,0 +1,173 @@ +.family-page { + padding: 1rem 0; +} + +.family-page h2 { + margin-bottom: 1rem; + color: var(--text-primary); +} + +.family-page h3, +.family-page h4 { + color: var(--text-primary); +} + +.family-error { + background-color: #ffebee; + color: #d32f2f; + border: 1px solid #f5c2c7; + border-radius: 4px; + padding: 0.75rem 1rem; + margin-bottom: 1rem; +} + +.family-create { + margin-bottom: 1.5rem; +} + +.family-create-form { + display: flex; + gap: 0.75rem; + align-items: flex-end; + flex-wrap: wrap; +} + +.family-create-form label { + display: flex; + flex-direction: column; + gap: 0.35rem; + flex: 1 1 260px; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.family-create-form input, +.family-invite-form input, +.family-invite-form select { + padding: 0.6rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--bg-secondary); + color: var(--text-primary); + font-size: 1rem; +} + +.family-layout { + display: grid; + grid-template-columns: 260px 1fr; + gap: 1.5rem; +} + +@media (max-width: 720px) { + .family-layout { + grid-template-columns: 1fr; + } +} + +.family-list h3, +.family-detail h3 { + margin-top: 0; +} + +.family-list ul { + list-style: none; + padding: 0; + margin: 0; +} + +.family-list li { + margin-bottom: 0.5rem; +} + +.family-list li button { + width: 100%; + text-align: left; + padding: 0.75rem 1rem; + border: 1px solid var(--border-color); + border-radius: 6px; + background-color: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 0.25rem; + transition: border-color 0.2s, background-color 0.2s; +} + +.family-list li button:hover { + border-color: var(--brand-primary); + background-color: var(--bg-tertiary); +} + +.family-list li.active button { + border-color: var(--brand-primary); + background-color: var(--bg-tertiary); + box-shadow: inset 3px 0 0 var(--brand-primary); +} + +.family-meta { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.family-detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.family-members { + width: 100%; + border-collapse: collapse; + margin-bottom: 1.5rem; +} + +.family-members th, +.family-members td { + text-align: left; + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--border-light); + color: var(--text-primary); +} + +.family-members th { + color: var(--text-secondary); + font-weight: 600; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.family-invite-form { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.family-invite-form input[type="email"] { + flex: 1 1 240px; +} + +.family-page button.danger { + background-color: #d32f2f; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.9rem; +} + +.family-page button.danger:hover { + background-color: #b71c1c; +} + +.family-members button { + padding: 0.4rem 0.8rem; + font-size: 0.85rem; +} + +.muted { + color: var(--text-secondary); + font-style: italic; +} diff --git a/packages/web/src/styles/FamilyGate.css b/packages/web/src/styles/FamilyGate.css new file mode 100644 index 0000000..2717b5b --- /dev/null +++ b/packages/web/src/styles/FamilyGate.css @@ -0,0 +1,77 @@ +.family-gate-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + padding: 1rem; +} + +.family-gate-modal { + background: var(--bg-secondary); + color: var(--text-primary); + border-radius: 8px; + max-width: 440px; + width: 100%; + padding: 1.75rem; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25); +} + +.family-gate-modal h2 { + margin: 0 0 0.5rem; + color: var(--brand-primary); +} + +.family-gate-modal p { + margin: 0 0 1.25rem; + color: var(--text-secondary); + line-height: 1.45; +} + +.family-gate-modal label { + display: block; + font-size: 0.9rem; + color: var(--text-secondary); + margin-bottom: 0.35rem; +} + +.family-gate-modal input { + width: 100%; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--bg-primary); + color: var(--text-primary); + font-size: 1rem; + margin-bottom: 1rem; + box-sizing: border-box; +} + +.family-gate-error { + background-color: #ffebee; + color: #d32f2f; + border: 1px solid #f5c2c7; + border-radius: 4px; + padding: 0.5rem 0.75rem; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.family-gate-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.family-gate-secondary { + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.family-gate-secondary:hover { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} diff --git a/packages/web/src/version.ts b/packages/web/src/version.ts index 9afb94d..d824e17 100644 --- a/packages/web/src/version.ts +++ b/packages/web/src/version.ts @@ -3,4 +3,4 @@ * Example: 2026.01.002 (January 2026, patch 2), 2026.02.003 (February 2026, patch 3) * Month and patch are zero-padded. Patch increments with each deployment in a month. */ -export const APP_VERSION = '2026.01.006'; +export const APP_VERSION = '2026.04.008';