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:
@@ -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() {
|
||||
<Router>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<FamilyGate>
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className="container">
|
||||
@@ -64,6 +67,7 @@ function App() {
|
||||
<Route path="/recipes/:id/cook" element={<ProtectedRoute><CookingMode /></ProtectedRoute>} />
|
||||
<Route path="/recipes/new" element={<ProtectedRoute><NewRecipe /></ProtectedRoute>} />
|
||||
<Route path="/recipes/import" element={<ProtectedRoute><RecipeImport /></ProtectedRoute>} />
|
||||
<Route path="/family" element={<ProtectedRoute><Family /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
@@ -74,6 +78,7 @@ function App() {
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</FamilyGate>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</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
|
||||
</Link>
|
||||
<Link
|
||||
to="/family"
|
||||
className="user-menu-link"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Family
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<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;
|
||||
|
||||
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)
|
||||
* 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