feat: add family-based multi-tenant access control
Some checks failed
Basil CI/CD Pipeline / Code Linting (push) Successful in 3m18s
Basil CI/CD Pipeline / Web Tests (push) Successful in 3m31s
Basil CI/CD Pipeline / Security Scanning (push) Has been cancelled
Basil CI/CD Pipeline / API Tests (push) Failing after 3m56s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 3m11s
Basil CI/CD Pipeline / Trigger Deployment (push) Has been cancelled
Basil CI/CD Pipeline / Build All Packages (push) Has been cancelled
Basil CI/CD Pipeline / E2E Tests (push) Has been cancelled
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been cancelled
Some checks failed
Basil CI/CD Pipeline / Code Linting (push) Successful in 3m18s
Basil CI/CD Pipeline / Web Tests (push) Successful in 3m31s
Basil CI/CD Pipeline / Security Scanning (push) Has been cancelled
Basil CI/CD Pipeline / API Tests (push) Failing after 3m56s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 3m11s
Basil CI/CD Pipeline / Trigger Deployment (push) Has been cancelled
Basil CI/CD Pipeline / Build All Packages (push) Has been cancelled
Basil CI/CD Pipeline / E2E Tests (push) Has been cancelled
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
10
CLAUDE.md
10
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`
|
Basil uses calendar versioning with the format: `YYYY.MM.PPP`
|
||||||
- `YYYY` - Four-digit year (e.g., 2026)
|
- `YYYY` - Four-digit year (e.g., 2026)
|
||||||
- `MM` - Two-digit month with zero-padding (e.g., 01 for January, 12 for December)
|
- `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
|
### Examples
|
||||||
- `2026.01.001` - First deployment in January 2026
|
- `2026.01.006` - Sixth deployment (in January 2026)
|
||||||
- `2026.01.002` - Second deployment in January 2026
|
- `2026.04.007` - Seventh deployment (in April 2026 — patch continues from previous month, does not reset)
|
||||||
- `2026.02.001` - First deployment in February 2026 (patch resets to 001)
|
- `2026.04.008` - Eighth deployment (still in April 2026)
|
||||||
- `2026.02.003` - Third deployment in February 2026
|
- `2026.05.009` - Ninth deployment (in May 2026 — patch continues, does not reset)
|
||||||
|
|
||||||
### Version Update Process
|
### Version Update Process
|
||||||
When deploying to production:
|
When deploying to production:
|
||||||
|
|||||||
@@ -29,11 +29,45 @@ model User {
|
|||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
verificationTokens VerificationToken[]
|
verificationTokens VerificationToken[]
|
||||||
mealPlans MealPlan[]
|
mealPlans MealPlan[]
|
||||||
|
familyMemberships FamilyMember[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@index([provider, providerId])
|
@@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 {
|
model VerificationToken {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
@@ -91,12 +125,14 @@ model Recipe {
|
|||||||
cuisine String?
|
cuisine String?
|
||||||
categories String[] @default([]) // Changed from single category to array
|
categories String[] @default([]) // Changed from single category to array
|
||||||
rating Float?
|
rating Float?
|
||||||
userId String? // Recipe owner
|
userId String? // Recipe owner (creator)
|
||||||
|
familyId String? // Owning family (tenant scope)
|
||||||
visibility Visibility @default(PRIVATE)
|
visibility Visibility @default(PRIVATE)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
family Family? @relation(fields: [familyId], references: [id], onDelete: SetNull)
|
||||||
sections RecipeSection[]
|
sections RecipeSection[]
|
||||||
ingredients Ingredient[]
|
ingredients Ingredient[]
|
||||||
instructions Instruction[]
|
instructions Instruction[]
|
||||||
@@ -109,6 +145,7 @@ model Recipe {
|
|||||||
@@index([title])
|
@@index([title])
|
||||||
@@index([cuisine])
|
@@index([cuisine])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([familyId])
|
||||||
@@index([visibility])
|
@@index([visibility])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +273,8 @@ model Cookbook {
|
|||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
coverImageUrl 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
|
autoFilterCategories String[] @default([]) // Auto-add recipes matching these categories
|
||||||
autoFilterTags String[] @default([]) // Auto-add recipes matching these tags
|
autoFilterTags String[] @default([]) // Auto-add recipes matching these tags
|
||||||
autoFilterCookbookTags String[] @default([]) // Auto-add cookbooks matching these tags
|
autoFilterCookbookTags String[] @default([]) // Auto-add cookbooks matching these tags
|
||||||
@@ -244,6 +282,7 @@ model Cookbook {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
family Family? @relation(fields: [familyId], references: [id], onDelete: SetNull)
|
||||||
recipes CookbookRecipe[]
|
recipes CookbookRecipe[]
|
||||||
tags CookbookTag[]
|
tags CookbookTag[]
|
||||||
includedCookbooks CookbookInclusion[] @relation("ParentCookbook")
|
includedCookbooks CookbookInclusion[] @relation("ParentCookbook")
|
||||||
@@ -251,6 +290,7 @@ model Cookbook {
|
|||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([familyId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model CookbookRecipe {
|
model CookbookRecipe {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import tagsRoutes from './routes/tags.routes';
|
|||||||
import backupRoutes from './routes/backup.routes';
|
import backupRoutes from './routes/backup.routes';
|
||||||
import authRoutes from './routes/auth.routes';
|
import authRoutes from './routes/auth.routes';
|
||||||
import mealPlansRoutes from './routes/meal-plans.routes';
|
import mealPlansRoutes from './routes/meal-plans.routes';
|
||||||
|
import familiesRoutes from './routes/families.routes';
|
||||||
import './config/passport'; // Initialize passport strategies
|
import './config/passport'; // Initialize passport strategies
|
||||||
import { testEmailConfig } from './services/email.service';
|
import { testEmailConfig } from './services/email.service';
|
||||||
import { APP_VERSION } from './version';
|
import { APP_VERSION } from './version';
|
||||||
@@ -40,6 +41,7 @@ app.use('/api/cookbooks', cookbooksRoutes);
|
|||||||
app.use('/api/tags', tagsRoutes);
|
app.use('/api/tags', tagsRoutes);
|
||||||
app.use('/api/backup', backupRoutes);
|
app.use('/api/backup', backupRoutes);
|
||||||
app.use('/api/meal-plans', mealPlansRoutes);
|
app.use('/api/meal-plans', mealPlansRoutes);
|
||||||
|
app.use('/api/families', familiesRoutes);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import express, { Request, Response } from 'express';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import { createBackup, restoreBackup, listBackups, deleteBackup } from '../services/backup.service';
|
import { createBackup, restoreBackup, listBackups, deleteBackup } from '../services/backup.service';
|
||||||
|
import { requireAuth, requireAdmin } from '../middleware/auth.middleware';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(requireAuth, requireAdmin);
|
||||||
|
|
||||||
// Configure multer for backup file uploads
|
// Configure multer for backup file uploads
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
dest: '/tmp/basil-restore/',
|
dest: '/tmp/basil-restore/',
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ import { Router, Request, Response } from 'express';
|
|||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import prisma from '../config/database';
|
import prisma from '../config/database';
|
||||||
import { StorageService } from '../services/storage.service';
|
import { StorageService } from '../services/storage.service';
|
||||||
|
import {
|
||||||
|
getAccessContext,
|
||||||
|
buildCookbookAccessFilter,
|
||||||
|
canMutateCookbook,
|
||||||
|
getPrimaryFamilyId,
|
||||||
|
} from '../services/access.service';
|
||||||
|
import { requireAuth } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: {
|
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({
|
const matchingRecipes = await prisma.recipe.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
familyId: cookbook.familyId,
|
||||||
OR: whereConditions
|
OR: whereConditions
|
||||||
},
|
},
|
||||||
select: { id: true }
|
select: { id: true }
|
||||||
@@ -107,11 +117,13 @@ async function applyFiltersToExistingCookbooks(cookbookId: string) {
|
|||||||
return;
|
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({
|
const matchingCookbooks = await prisma.cookbook.findMany({
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{ id: { not: cookbookId } },
|
{ id: { not: cookbookId } },
|
||||||
|
{ familyId: cookbook.familyId },
|
||||||
{
|
{
|
||||||
tags: {
|
tags: {
|
||||||
some: {
|
some: {
|
||||||
@@ -166,11 +178,14 @@ async function autoAddToParentCookbooks(cookbookId: string) {
|
|||||||
const cookbookTags = cookbook.tags.map((ct: any) => ct.tag.name);
|
const cookbookTags = cookbook.tags.map((ct: any) => ct.tag.name);
|
||||||
if (cookbookTags.length === 0) return;
|
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({
|
const parentCookbooks = await prisma.cookbook.findMany({
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{ id: { not: cookbookId } },
|
{ id: { not: cookbookId } },
|
||||||
|
{ familyId: cookbook.familyId },
|
||||||
{ autoFilterCookbookTags: { hasSome: cookbookTags } }
|
{ autoFilterCookbookTags: { hasSome: cookbookTags } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -203,6 +218,8 @@ async function autoAddToParentCookbooks(cookbookId: string) {
|
|||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { includeChildren = 'false' } = req.query;
|
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)
|
// Get child cookbook IDs to exclude from main listing (unless includeChildren is true)
|
||||||
const childCookbookIds = includeChildren === 'true' ? [] : (
|
const childCookbookIds = includeChildren === 'true' ? [] : (
|
||||||
@@ -213,8 +230,11 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
).map((ci: any) => ci.childCookbookId);
|
).map((ci: any) => ci.childCookbookId);
|
||||||
|
|
||||||
const cookbooks = await prisma.cookbook.findMany({
|
const cookbooks = await prisma.cookbook.findMany({
|
||||||
where: includeChildren === 'true' ? {} : {
|
where: {
|
||||||
id: { notIn: childCookbookIds }
|
AND: [
|
||||||
|
accessFilter,
|
||||||
|
includeChildren === 'true' ? {} : { id: { notIn: childCookbookIds } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
@@ -256,9 +276,10 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
router.get('/:id', async (req: Request, res: Response) => {
|
router.get('/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
const ctx = await getAccessContext(req.user!);
|
||||||
|
|
||||||
const cookbook = await prisma.cookbook.findUnique({
|
const cookbook = await prisma.cookbook.findFirst({
|
||||||
where: { id },
|
where: { AND: [{ id }, buildCookbookAccessFilter(ctx)] },
|
||||||
include: {
|
include: {
|
||||||
recipes: {
|
recipes: {
|
||||||
include: {
|
include: {
|
||||||
@@ -342,11 +363,15 @@ router.post('/', async (req: Request, res: Response) => {
|
|||||||
return res.status(400).json({ error: 'Name is required' });
|
return res.status(400).json({ error: 'Name is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const familyId = await getPrimaryFamilyId(req.user!.id);
|
||||||
|
|
||||||
const cookbook = await prisma.cookbook.create({
|
const cookbook = await prisma.cookbook.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
coverImageUrl,
|
coverImageUrl,
|
||||||
|
userId: req.user!.id,
|
||||||
|
familyId,
|
||||||
autoFilterCategories: autoFilterCategories || [],
|
autoFilterCategories: autoFilterCategories || [],
|
||||||
autoFilterTags: autoFilterTags || [],
|
autoFilterTags: autoFilterTags || [],
|
||||||
autoFilterCookbookTags: autoFilterCookbookTags || [],
|
autoFilterCookbookTags: autoFilterCookbookTags || [],
|
||||||
@@ -388,6 +413,16 @@ router.put('/:id', async (req: Request, res: Response) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags, autoFilterCookbookTags, tags } = req.body;
|
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 = {};
|
const updateData: any = {};
|
||||||
if (name !== undefined) updateData.name = name;
|
if (name !== undefined) updateData.name = name;
|
||||||
if (description !== undefined) updateData.description = description;
|
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) => {
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
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({
|
await prisma.cookbook.delete({
|
||||||
where: { id }
|
where: { id }
|
||||||
@@ -476,6 +520,26 @@ router.delete('/:id', async (req: Request, res: Response) => {
|
|||||||
router.post('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
|
router.post('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id, recipeId } = req.params;
|
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
|
// Check if recipe is already in cookbook
|
||||||
const existing = await prisma.cookbookRecipe.findUnique({
|
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) => {
|
router.delete('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id, recipeId } = req.params;
|
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({
|
await prisma.cookbookRecipe.delete({
|
||||||
where: {
|
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' });
|
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
|
// Check if cookbook is already included
|
||||||
const existing = await prisma.cookbookInclusion.findUnique({
|
const existing = await prisma.cookbookInclusion.findUnique({
|
||||||
where: {
|
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) => {
|
router.delete('/:id/cookbooks/:childCookbookId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id, childCookbookId } = req.params;
|
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({
|
await prisma.cookbookInclusion.delete({
|
||||||
where: {
|
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' });
|
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({
|
const cookbook = await prisma.cookbook.findUnique({
|
||||||
where: { id }
|
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) {
|
if (cookbook?.coverImageUrl) {
|
||||||
await storageService.deleteFile(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' });
|
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({
|
const cookbook = await prisma.cookbook.findUnique({
|
||||||
where: { id }
|
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) {
|
if (cookbook?.coverImageUrl) {
|
||||||
await storageService.deleteFile(cookbook.coverImageUrl);
|
await storageService.deleteFile(cookbook.coverImageUrl);
|
||||||
|
|||||||
237
packages/api/src/routes/families.routes.ts
Normal file
237
packages/api/src/routes/families.routes.ts
Normal file
@@ -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;
|
||||||
@@ -4,9 +4,17 @@ import prisma from '../config/database';
|
|||||||
import { StorageService } from '../services/storage.service';
|
import { StorageService } from '../services/storage.service';
|
||||||
import { ScraperService } from '../services/scraper.service';
|
import { ScraperService } from '../services/scraper.service';
|
||||||
import { autoMapIngredients, saveIngredientMappings } from '../services/ingredientMatcher.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';
|
import { ApiResponse, RecipeImportRequest } from '@basil/shared';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: {
|
limits: {
|
||||||
@@ -23,7 +31,8 @@ const upload = multer({
|
|||||||
const storageService = StorageService.getInstance();
|
const storageService = StorageService.getInstance();
|
||||||
const scraperService = new ScraperService();
|
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) {
|
async function autoAddToCookbooks(recipeId: string) {
|
||||||
try {
|
try {
|
||||||
// Get the recipe with its category and tags
|
// 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 recipeTags = recipe.tags.map((rt: any) => rt.tag.name);
|
||||||
const recipeCategories = recipe.categories || [];
|
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({
|
const cookbooks = await prisma.cookbook.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
familyId: recipe.familyId,
|
||||||
OR: [
|
OR: [
|
||||||
{ autoFilterCategories: { isEmpty: false } },
|
{ autoFilterCategories: { isEmpty: false } },
|
||||||
{ autoFilterTags: { isEmpty: false } }
|
{ autoFilterTags: { isEmpty: false } }
|
||||||
@@ -107,9 +118,11 @@ router.get('/', async (req, res) => {
|
|||||||
const limitNum = parseInt(limit as string);
|
const limitNum = parseInt(limit as string);
|
||||||
const skip = (pageNum - 1) * limitNum;
|
const skip = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
const where: any = {};
|
const ctx = await getAccessContext(req.user!);
|
||||||
|
const where: any = { AND: [buildRecipeAccessFilter(ctx)] };
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.AND.push({
|
||||||
|
OR: [
|
||||||
{ title: { contains: search as string, mode: 'insensitive' } },
|
{ title: { contains: search as string, mode: 'insensitive' } },
|
||||||
{ description: { contains: search as string, mode: 'insensitive' } },
|
{ description: { contains: search as string, mode: 'insensitive' } },
|
||||||
{
|
{
|
||||||
@@ -121,22 +134,19 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
}
|
});
|
||||||
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) {
|
if (tag) {
|
||||||
where.tags = {
|
where.AND.push({
|
||||||
|
tags: {
|
||||||
some: {
|
some: {
|
||||||
tag: {
|
tag: { name: { equals: tag as string, mode: 'insensitive' } },
|
||||||
name: { equals: tag as string, mode: 'insensitive' }
|
},
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [recipes, total] = await Promise.all([
|
const [recipes, total] = await Promise.all([
|
||||||
@@ -215,8 +225,9 @@ router.get('/', async (req, res) => {
|
|||||||
// Get single recipe
|
// Get single recipe
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const recipe = await prisma.recipe.findUnique({
|
const ctx = await getAccessContext(req.user!);
|
||||||
where: { id: req.params.id },
|
const recipe = await prisma.recipe.findFirst({
|
||||||
|
where: { AND: [{ id: req.params.id }, buildRecipeAccessFilter(ctx)] },
|
||||||
include: {
|
include: {
|
||||||
sections: {
|
sections: {
|
||||||
orderBy: { order: 'asc' },
|
orderBy: { order: 'asc' },
|
||||||
@@ -285,11 +296,17 @@ router.get('/:id', async (req, res) => {
|
|||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, description, sections, ingredients, instructions, tags, ...recipeData } = req.body;
|
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({
|
const recipe = await prisma.recipe.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
userId: req.user!.id,
|
||||||
|
familyId,
|
||||||
...recipeData,
|
...recipeData,
|
||||||
sections: sections
|
sections: sections
|
||||||
? {
|
? {
|
||||||
@@ -361,7 +378,20 @@ router.post('/', async (req, res) => {
|
|||||||
// Update recipe
|
// Update recipe
|
||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', async (req, res) => {
|
||||||
try {
|
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;
|
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)
|
// Only delete relations that are being updated (not undefined)
|
||||||
if (sections !== undefined) {
|
if (sections !== undefined) {
|
||||||
@@ -465,13 +495,17 @@ router.put('/:id', async (req, res) => {
|
|||||||
// Delete recipe
|
// Delete recipe
|
||||||
router.delete('/:id', async (req, res) => {
|
router.delete('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const ctx = await getAccessContext(req.user!);
|
||||||
// Get recipe to delete associated images
|
// Get recipe to delete associated images
|
||||||
const recipe = await prisma.recipe.findUnique({
|
const recipe = await prisma.recipe.findUnique({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
include: { images: true },
|
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
|
// Delete images from storage
|
||||||
if (recipe.imageUrl) {
|
if (recipe.imageUrl) {
|
||||||
await storageService.deleteFile(recipe.imageUrl);
|
await storageService.deleteFile(recipe.imageUrl);
|
||||||
@@ -479,7 +513,6 @@ router.delete('/:id', async (req, res) => {
|
|||||||
for (const image of recipe.images) {
|
for (const image of recipe.images) {
|
||||||
await storageService.deleteFile(image.url);
|
await storageService.deleteFile(image.url);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.recipe.delete({ where: { id: req.params.id } });
|
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' });
|
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...');
|
console.log('Saving file to storage...');
|
||||||
const imageUrl = await storageService.saveFile(req.file, 'recipes');
|
const imageUrl = await storageService.saveFile(req.file, 'recipes');
|
||||||
console.log('File saved, URL:', imageUrl);
|
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
|
// Delete old image from storage if it exists
|
||||||
if (existingRecipe?.imageUrl) {
|
if (existingRecipe?.imageUrl) {
|
||||||
console.log('Deleting old image:', 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
|
// Delete recipe image
|
||||||
router.delete('/:id/image', async (req, res) => {
|
router.delete('/:id/image', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const ctx = await getAccessContext(req.user!);
|
||||||
const recipe = await prisma.recipe.findUnique({
|
const recipe = await prisma.recipe.findUnique({
|
||||||
where: { id: req.params.id },
|
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' });
|
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' });
|
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);
|
await saveIngredientMappings(mappings);
|
||||||
|
|
||||||
res.json({ message: 'Mappings updated successfully' });
|
res.json({ message: 'Mappings updated successfully' });
|
||||||
@@ -618,6 +670,16 @@ router.post('/:id/ingredient-mappings', async (req, res) => {
|
|||||||
// Regenerate ingredient-instruction mappings
|
// Regenerate ingredient-instruction mappings
|
||||||
router.post('/:id/regenerate-mappings', async (req, res) => {
|
router.post('/:id/regenerate-mappings', async (req, res) => {
|
||||||
try {
|
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);
|
await autoMapIngredients(req.params.id);
|
||||||
|
|
||||||
res.json({ message: 'Mappings regenerated successfully' });
|
res.json({ message: 'Mappings regenerated successfully' });
|
||||||
|
|||||||
155
packages/api/src/scripts/backfill-family-tenant.ts
Normal file
155
packages/api/src/scripts/backfill-family-tenant.ts
Normal file
@@ -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<Family> {
|
||||||
|
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: '<dry-run>', 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<string, Family>();
|
||||||
|
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();
|
||||||
|
});
|
||||||
108
packages/api/src/services/access.service.ts
Normal file
108
packages/api/src/services/access.service.ts
Normal file
@@ -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<AccessContext> {
|
||||||
|
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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -3,4 +3,4 @@
|
|||||||
* Example: 2026.01.002 (January 2026, patch 2), 2026.02.003 (February 2026, patch 3)
|
* 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.
|
* 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';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ThemeProvider } from './contexts/ThemeContext';
|
|||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import UserMenu from './components/UserMenu';
|
import UserMenu from './components/UserMenu';
|
||||||
import ThemeToggle from './components/ThemeToggle';
|
import ThemeToggle from './components/ThemeToggle';
|
||||||
|
import FamilyGate from './components/FamilyGate';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import AuthCallback from './pages/AuthCallback';
|
import AuthCallback from './pages/AuthCallback';
|
||||||
@@ -16,6 +17,7 @@ import RecipeImport from './pages/RecipeImport';
|
|||||||
import NewRecipe from './pages/NewRecipe';
|
import NewRecipe from './pages/NewRecipe';
|
||||||
import UnifiedEditRecipe from './pages/UnifiedEditRecipe';
|
import UnifiedEditRecipe from './pages/UnifiedEditRecipe';
|
||||||
import CookingMode from './pages/CookingMode';
|
import CookingMode from './pages/CookingMode';
|
||||||
|
import Family from './pages/Family';
|
||||||
import { APP_VERSION } from './version';
|
import { APP_VERSION } from './version';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ function App() {
|
|||||||
<Router>
|
<Router>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<FamilyGate>
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@@ -64,6 +67,7 @@ function App() {
|
|||||||
<Route path="/recipes/:id/cook" element={<ProtectedRoute><CookingMode /></ProtectedRoute>} />
|
<Route path="/recipes/:id/cook" element={<ProtectedRoute><CookingMode /></ProtectedRoute>} />
|
||||||
<Route path="/recipes/new" element={<ProtectedRoute><NewRecipe /></ProtectedRoute>} />
|
<Route path="/recipes/new" element={<ProtectedRoute><NewRecipe /></ProtectedRoute>} />
|
||||||
<Route path="/recipes/import" element={<ProtectedRoute><RecipeImport /></ProtectedRoute>} />
|
<Route path="/recipes/import" element={<ProtectedRoute><RecipeImport /></ProtectedRoute>} />
|
||||||
|
<Route path="/family" element={<ProtectedRoute><Family /></ProtectedRoute>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -74,6 +78,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
</FamilyGate>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
101
packages/web/src/components/FamilyGate.tsx
Normal file
101
packages/web/src/components/FamilyGate.tsx
Normal file
@@ -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<CheckState>('idle');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 && (
|
||||||
|
<div className="family-gate-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="family-gate-modal">
|
||||||
|
<h2>Create your family</h2>
|
||||||
|
<p>
|
||||||
|
To keep recipes organized and shareable, every account belongs to
|
||||||
|
a family. Name yours to get started — you can invite others later.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<label htmlFor="family-gate-name">Family name</label>
|
||||||
|
<input
|
||||||
|
id="family-gate-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Smith Family"
|
||||||
|
autoFocus
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{error && <div className="family-gate-error">{error}</div>}
|
||||||
|
<div className="family-gate-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="family-gate-secondary"
|
||||||
|
onClick={logout}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={submitting || !name.trim()}>
|
||||||
|
{submitting ? 'Creating…' : 'Create family'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -96,6 +96,13 @@ const UserMenu: React.FC = () => {
|
|||||||
>
|
>
|
||||||
My Cookbooks
|
My Cookbooks
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/family"
|
||||||
|
className="user-menu-link"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
Family
|
||||||
|
</Link>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<div className="user-menu-divider"></div>
|
<div className="user-menu-divider"></div>
|
||||||
|
|||||||
245
packages/web/src/pages/Family.tsx
Normal file
245
packages/web/src/pages/Family.tsx
Normal file
@@ -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<FamilySummary[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [detail, setDetail] = useState<FamilyDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 <div className="family-page">Loading…</div>;
|
||||||
|
|
||||||
|
const isOwner = detail?.myRole === 'OWNER';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="family-page">
|
||||||
|
<h2>Families</h2>
|
||||||
|
{error && <div className="family-error">{error}</div>}
|
||||||
|
|
||||||
|
<section className="family-create">
|
||||||
|
<form onSubmit={handleCreateFamily} className="family-create-form">
|
||||||
|
<label>
|
||||||
|
Create a new family:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newFamilyName}
|
||||||
|
placeholder="e.g. Smith Family"
|
||||||
|
onChange={(e) => setNewFamilyName(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={busy || !newFamilyName.trim()}>Create</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="family-layout">
|
||||||
|
<aside className="family-list">
|
||||||
|
<h3>Your families</h3>
|
||||||
|
{families.length === 0 && <p className="muted">You're not in any family yet.</p>}
|
||||||
|
<ul>
|
||||||
|
{families.map((f) => (
|
||||||
|
<li key={f.id} className={f.id === selectedId ? 'active' : ''}>
|
||||||
|
<button onClick={() => setSelectedId(f.id)}>
|
||||||
|
<strong>{f.name}</strong>
|
||||||
|
<span className="family-meta">{f.role} · {f.memberCount} member{f.memberCount === 1 ? '' : 's'}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="family-detail">
|
||||||
|
{!detail && <p className="muted">Select a family to see its members.</p>}
|
||||||
|
{detail && (
|
||||||
|
<>
|
||||||
|
<div className="family-detail-header">
|
||||||
|
<h3>{detail.name}</h3>
|
||||||
|
{isOwner && (
|
||||||
|
<button className="danger" onClick={handleDeleteFamily} disabled={busy}>
|
||||||
|
Delete family
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Members</h4>
|
||||||
|
<table className="family-members">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{detail.members.map((m) => (
|
||||||
|
<tr key={m.userId}>
|
||||||
|
<td>{m.name || '—'}</td>
|
||||||
|
<td>{m.email}</td>
|
||||||
|
<td>{m.role}</td>
|
||||||
|
<td>
|
||||||
|
{(isOwner || m.userId === user?.id) && (
|
||||||
|
<button onClick={() => handleRemoveMember(m)} disabled={busy}>
|
||||||
|
{m.userId === user?.id ? 'Leave' : 'Remove'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<>
|
||||||
|
<h4>Invite a member</h4>
|
||||||
|
<p className="muted">User must already have a Basil account on this server.</p>
|
||||||
|
<form onSubmit={handleInvite} className="family-invite-form">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={inviteRole}
|
||||||
|
onChange={(e) => setInviteRole(e.target.value as 'MEMBER' | 'OWNER')}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<option value="MEMBER">Member</option>
|
||||||
|
<option value="OWNER">Owner</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" disabled={busy || !inviteEmail.trim()}>Add</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ApiResponse<FamilySummary[]>> => {
|
||||||
|
const response = await api.get('/families');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
create: async (name: string): Promise<ApiResponse<{ id: string; name: string }>> => {
|
||||||
|
const response = await api.post('/families', { name });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
get: async (id: string): Promise<ApiResponse<FamilyDetail>> => {
|
||||||
|
const response = await api.get(`/families/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
rename: async (id: string, name: string): Promise<ApiResponse<{ id: string; name: string }>> => {
|
||||||
|
const response = await api.put(`/families/${id}`, { name });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
remove: async (id: string): Promise<ApiResponse<void>> => {
|
||||||
|
const response = await api.delete(`/families/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
addMember: async (
|
||||||
|
familyId: string,
|
||||||
|
email: string,
|
||||||
|
role: FamilyRole = 'MEMBER',
|
||||||
|
): Promise<ApiResponse<FamilyMemberInfo>> => {
|
||||||
|
const response = await api.post(`/families/${familyId}/members`, { email, role });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
removeMember: async (familyId: string, userId: string): Promise<ApiResponse<void>> => {
|
||||||
|
const response = await api.delete(`/families/${familyId}/members/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
173
packages/web/src/styles/Family.css
Normal file
173
packages/web/src/styles/Family.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
77
packages/web/src/styles/FamilyGate.css
Normal file
77
packages/web/src/styles/FamilyGate.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -3,4 +3,4 @@
|
|||||||
* Example: 2026.01.002 (January 2026, patch 2), 2026.02.003 (February 2026, patch 3)
|
* 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.
|
* 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';
|
||||||
|
|||||||
Reference in New Issue
Block a user