diff --git a/backend/app/routes/households.py b/backend/app/routes/households.py index bbcf2e6..3dce3a8 100644 --- a/backend/app/routes/households.py +++ b/backend/app/routes/households.py @@ -11,7 +11,8 @@ from app.utils.database import ( create_household, get_household, get_user_households, update_household, delete_household, add_member_to_household, - get_user_by_id + remove_member_from_household, get_user_by_id, + get_household_wrapped_keys, update_household_wrapped_keys ) router = APIRouter() @@ -153,3 +154,34 @@ async def leave_household(household_id: str, user: dict = Depends(get_current_us update_user(user["user_id"], {"households": households}) return {"message": "Left household successfully"} + +@router.delete("/{household_id}/members/{member_id}") +async def remove_household_member( + household_id: str, + member_id: str, + user: dict = Depends(get_current_user) +): + """Remove a member from household (owner only)""" + household = get_household(household_id) + if not household: + raise HTTPException(status_code=404, detail="Household not found") + + if household["owner_id"] != user["user_id"]: + raise HTTPException(status_code=403, detail="Only the owner can remove members") + + if member_id == household["owner_id"]: + raise HTTPException(status_code=400, detail="Cannot remove the owner from the household") + + if member_id not in household.get("members", []): + raise HTTPException(status_code=404, detail="Member not found in household") + + if not remove_member_from_household(household_id, member_id): + raise HTTPException(status_code=500, detail="Failed to remove member") + + # Remove member's wrapped encryption key + wrapped_keys = get_household_wrapped_keys(household_id) or {} + if member_id in wrapped_keys: + del wrapped_keys[member_id] + update_household_wrapped_keys(household_id, wrapped_keys) + + return {"message": "Member removed successfully"} diff --git a/backend/app/utils/database.py b/backend/app/utils/database.py index fac3357..dab5476 100644 --- a/backend/app/utils/database.py +++ b/backend/app/utils/database.py @@ -204,6 +204,35 @@ def delete_household(household_id: str) -> bool: except ClientError: return False +def remove_member_from_household(household_id: str, user_id: str) -> bool: + """Remove a member from household""" + household = get_household(household_id) + if not household: + return False + + members = [m for m in household.get("members", []) if m != user_id] + + table = get_table(settings.HOUSEHOLDS_TABLE) + try: + table.update_item( + Key={"household_id": household_id}, + UpdateExpression="SET members = :m, updated_at = :now", + ExpressionAttributeValues={ + ":m": members, + ":now": datetime.utcnow().isoformat() + } + ) + + # Remove household from user's list + db_user = get_user_by_id(user_id) + if db_user: + households = [h for h in db_user.get("households", []) if h != household_id] + update_user(user_id, {"households": households}) + + return True + except ClientError: + return False + # ============================================ # List Operations # ============================================ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8ab2e3c..bf99bb5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -140,7 +140,6 @@ "resolved": "https://registry.npmjs.org/@aws-amplify/core/-/core-6.16.0.tgz", "integrity": "sha512-YpEtvdXcC06/j3PEsQiN/AYiJh3yLK5aPijFY1SbE0rgSLt9iPPalCOh65vDjybe7SW8qdIlctcR/rROMA88ag==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/types": "3.723.0", @@ -693,7 +692,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.723.0.tgz", "integrity": "sha512-9IH90m4bnHogBctVna2FnXaIGVORncfdxcqeEIovOxjIJJyHDmEAtA7B91dAM4sruddTbVzOYnqfPVst3odCbA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -875,7 +873,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.723.0.tgz", "integrity": "sha512-YyN8x4MI/jMb4LpHsLf+VYqvbColMK8aZeGWVk2fTFsmt8lpTYGaGC1yybSwGX42mZ4W8ucu8SAYSbUraJZEjA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -1592,7 +1589,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3317,7 +3313,6 @@ "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.1.tgz", "integrity": "sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -6466,7 +6461,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -6665,7 +6659,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7172,7 +7165,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8001,7 +7993,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10516,7 +10507,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10673,7 +10663,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10686,7 +10675,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10933,7 +10921,6 @@ "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -12263,7 +12250,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -12760,7 +12746,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12855,7 +12840,6 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6f6cf62..31e6672 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,6 +42,13 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { ); } +function AuthRedirect({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuthStore(); + if (isLoading) return ; + if (isAuthenticated) return ; + return <>{children}; +} + function App() { const { isLoading, setLoading } = useAuthStore(); const { theme } = useThemeStore(); @@ -70,8 +77,8 @@ function App() { return ( - {/* Public routes */} - } /> + {/* Public routes - redirect to dashboard if authenticated */} + } /> } /> } /> } /> diff --git a/frontend/src/components/EncryptionProvider.tsx b/frontend/src/components/EncryptionProvider.tsx index 4e1d7a6..81c4a32 100644 --- a/frontend/src/components/EncryptionProvider.tsx +++ b/frontend/src/components/EncryptionProvider.tsx @@ -1,14 +1,14 @@ /** * EncryptionProvider Component * - * Wraps protected routes to handle encryption initialization, - * setup prompt, and unlock flow. - * Encryption is optional - the app loads normally while checking encryption status. + * Initializes encryption silently in the background. + * Does NOT block the UI — encryption unlock happens on-demand + * when a user accesses an encrypted note or tries to encrypt one. */ import { useEffect, useState } from 'react'; import { useEncryption } from '../hooks/useEncryption'; -import EncryptionSetup from './EncryptionSetup'; +import { useUnlockModal } from '../hooks/useUnlockModal'; import UnlockPrompt from './UnlockPrompt'; interface EncryptionProviderProps { @@ -17,78 +17,33 @@ interface EncryptionProviderProps { export default function EncryptionProvider({ children }: EncryptionProviderProps) { const { - isInitialized, - needsSetup, - needsUnlock, - keyData, initializeEncryption, + keyData, } = useEncryption(); - const [showSetup, setShowSetup] = useState(false); - const [setupSkipped, setSetupSkipped] = useState(false); - const [encryptionChecked, setEncryptionChecked] = useState(false); + const { isOpen, close, resolve } = useUnlockModal(); - // Initialize encryption on mount (non-blocking) + // Initialize encryption silently on mount (non-blocking) useEffect(() => { - const checkEncryption = async () => { - try { - await initializeEncryption(); - } catch { - // Encryption check failed - continue without encryption - } finally { - setEncryptionChecked(true); - } - }; - checkEncryption(); + initializeEncryption().catch(() => { + // Encryption check failed - continue without encryption + }); }, [initializeEncryption]); - // Show setup if needed and not skipped (only after encryption check completes) - useEffect(() => { - if (encryptionChecked && needsSetup && !setupSkipped) { - setShowSetup(true); - } - }, [encryptionChecked, needsSetup, setupSkipped]); - - const handleSetupComplete = () => { - setShowSetup(false); - }; - - const handleSetupSkip = () => { - setSetupSkipped(true); - setShowSetup(false); - }; - - const handleUnlock = () => { - // Encryption unlocked - continue to app - }; - - // Wait only for crypto store initialization (synchronous) - if (!isInitialized) { - return null; - } - - // Show setup wizard if needed - if (showSetup && needsSetup) { - return ( - - ); - } - - // Show unlock prompt if needed - if (encryptionChecked && needsUnlock && keyData) { - return ( - - ); - } - - // Render children (the protected content) - return <>{children}; + return ( + <> + {children} + + {/* On-demand unlock modal */} + {isOpen && keyData && ( + + )} + + ); } diff --git a/frontend/src/hooks/useUnlockModal.ts b/frontend/src/hooks/useUnlockModal.ts new file mode 100644 index 0000000..c251b71 --- /dev/null +++ b/frontend/src/hooks/useUnlockModal.ts @@ -0,0 +1,60 @@ +/** + * useUnlockModal Hook + * + * Provides a way to trigger the encryption unlock modal on-demand. + * Used when a user tries to access an encrypted note or encrypt a note. + */ + +import { create } from 'zustand'; + +interface UnlockModalState { + isOpen: boolean; + onSuccess: (() => void) | null; + open: (onSuccess?: () => void) => void; + close: () => void; + resolve: () => void; +} + +export const useUnlockModal = create((set, get) => ({ + isOpen: false, + onSuccess: null, + + open: (onSuccess?: () => void) => { + set({ isOpen: true, onSuccess: onSuccess || null }); + }, + + close: () => { + set({ isOpen: false, onSuccess: null }); + }, + + resolve: () => { + const { onSuccess } = get(); + if (onSuccess) onSuccess(); + set({ isOpen: false, onSuccess: null }); + }, +})); + +/** + * Request encryption unlock. Returns a promise that resolves when unlocked. + * If already unlocked, resolves immediately. + */ +export function requestUnlock(): Promise { + const { useCryptoStore } = require('../stores/cryptoStore'); + const cryptoState = useCryptoStore.getState(); + + if (cryptoState.isUnlocked) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + useUnlockModal.getState().close(); + reject(new Error('Unlock cancelled')); + }, 120000); // 2 min timeout + + useUnlockModal.getState().open(() => { + clearTimeout(timeout); + resolve(); + }); + }); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 5c16223..e1aec2a 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { Plus, ShoppingCart, CheckCircle2, FileText } from 'lucide-react'; +import { Plus, ShoppingCart, CheckCircle2, FileText, Lock } from 'lucide-react'; +import { isNoteEncrypted } from '../utils/encryptionHelpers'; import { useAuthStore, useHouseholdStore, useListsStore, Household, List } from '../stores/store'; import { api } from '../utils/api'; import styles from './DashboardPage.module.css'; @@ -179,7 +180,12 @@ export default function DashboardPage() {
-

{list.title}

+

+ {list.title} + {isNoteEncrypted(list) && ( + + )} +

{checkedCount} of {totalCount} items

diff --git a/frontend/src/pages/HouseholdPage.module.css b/frontend/src/pages/HouseholdPage.module.css index 5ee7e16..14e711a 100644 --- a/frontend/src/pages/HouseholdPage.module.css +++ b/frontend/src/pages/HouseholdPage.module.css @@ -463,6 +463,15 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + display: flex; + align-items: center; + gap: 4px; +} + +.encryptedIcon { + flex-shrink: 0; + opacity: 0.45; + color: var(--color-success, #10b981); } .listDate { @@ -599,6 +608,32 @@ z-index: 1; } +.memberClickable { + cursor: pointer; +} + +.memberClickable:hover { + box-shadow: 0 0 0 2px var(--color-error); +} + +.ownerBadge { + position: absolute; + bottom: -2px; + right: -2px; + background: var(--color-primary); + color: white; + border-radius: 50%; + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; +} + +.memberAvatarSmall { + position: relative; +} + .memberAvatarMore { width: 28px; height: 28px; @@ -803,6 +838,60 @@ cursor: not-allowed; } +/* Remove Member Modal */ +.removeMemberHeader { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.removeMemberIcon { + color: var(--color-error); +} + +.warningBox { + background: var(--color-warning-light, #fef3c7); + border: 1px solid var(--color-warning, #f59e0b); + border-radius: var(--border-radius); + padding: var(--spacing-md); + margin-bottom: var(--spacing-lg); +} + +[data-theme="dark"] .warningBox { + background: rgba(245, 158, 11, 0.15); + border-color: rgba(245, 158, 11, 0.5); +} + +.warningHeader { + display: flex; + align-items: center; + gap: var(--spacing-sm); + color: var(--color-warning, #d97706); + font-weight: 600; + font-size: 0.875rem; + margin-bottom: var(--spacing-sm); +} + +[data-theme="dark"] .warningHeader { + color: #fbbf24; +} + +.warningText { + font-size: 0.8125rem; + line-height: 1.5; + color: var(--color-text); + margin: 0 0 var(--spacing-sm) 0; +} + +.warningNote { + font-size: 0.75rem; + line-height: 1.4; + color: var(--color-text-muted); + margin: 0; + font-style: italic; +} + /* Responsive */ @media (max-width: 768px) { .quickActions { diff --git a/frontend/src/pages/HouseholdPage.tsx b/frontend/src/pages/HouseholdPage.tsx index a5015b1..3da766b 100644 --- a/frontend/src/pages/HouseholdPage.tsx +++ b/frontend/src/pages/HouseholdPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Plus, UserPlus, ShoppingCart, CheckCircle2, FileText, ArrowLeft, Trash2, MoreVertical, LogOut, ArrowUpDown, Pencil } from 'lucide-react'; +import { Plus, UserPlus, ShoppingCart, CheckCircle2, FileText, ArrowLeft, Trash2, MoreVertical, LogOut, ArrowUpDown, Pencil, UserMinus, Shield, AlertTriangle, Lock } from 'lucide-react'; +import { isNoteEncrypted } from '../utils/encryptionHelpers'; import { useHouseholdStore, useListsStore, useAuthStore, Household, List } from '../stores/store'; import { api } from '../utils/api'; import { useShortcutEvent } from '../hooks/useKeyboardShortcuts'; @@ -36,6 +37,9 @@ export default function HouseholdPage() { const [showSortMenu, setShowSortMenu] = useState(false); const [editingName, setEditingName] = useState(false); const [nameValue, setNameValue] = useState(''); + const [showRemoveMemberModal, setShowRemoveMemberModal] = useState(false); + const [selectedMember, setSelectedMember] = useState(null); + const [removingMember, setRemovingMember] = useState(false); const householdLists = lists.filter(l => l.household_id === householdId); @@ -70,6 +74,8 @@ export default function HouseholdPage() { setShowOptionsMenu(false); setShowSortMenu(false); setEditingName(false); + setShowRemoveMemberModal(false); + setSelectedMember(null); }, [])); useEffect(() => { @@ -190,6 +196,31 @@ export default function HouseholdPage() { } }; + const handleMemberClick = (member: any) => { + if (!isOwner) return; + if (member.user_id === user?.user_id) return; + setSelectedMember(member); + setShowRemoveMemberModal(true); + }; + + const handleRemoveMember = async () => { + if (!householdId || !selectedMember) return; + + setRemovingMember(true); + try { + await api.removeMember(householdId, selectedMember.user_id); + // Refresh household data + const household = await api.getHousehold(householdId) as Household; + setCurrentHousehold(household); + setShowRemoveMemberModal(false); + setSelectedMember(null); + } catch (error: any) { + alert(error.message || 'Failed to remove member'); + } finally { + setRemovingMember(false); + } + }; + // Format relative date for display const formatRelativeDate = (dateString: string) => { if (!dateString) return ''; @@ -388,7 +419,12 @@ export default function HouseholdPage() { -

{list.title}

+

+ {list.title} + {isNoteEncrypted(list) && ( + + )} +

{(list.updated_at || list.created_at) && ( @@ -428,16 +464,26 @@ export default function HouseholdPage() {
Members
- {(currentHousehold.members as any[]).slice(0, 5).map((member: any) => ( -
- {typeof member === 'object' ? member.name?.charAt(0) : 'U'} -
- ))} + {(currentHousehold.members as any[]).slice(0, 5).map((member: any) => { + const memberId = typeof member === 'object' ? member.user_id : member; + const memberName = typeof member === 'object' ? member.name : 'Member'; + const isCurrentUser = memberId === user?.user_id; + const isMemberOwner = memberId === currentHousehold.owner_id; + const canRemove = isOwner && !isCurrentUser && !isMemberOwner; + + return ( +
canRemove && handleMemberClick(member)} + > + {memberName?.charAt(0) || 'U'} + {isMemberOwner && } +
+ ); + })} {currentHousehold.members.length > 5 && (
+{currentHousehold.members.length - 5} @@ -547,6 +593,56 @@ export default function HouseholdPage() {
)} + {/* Remove Member Modal */} + {showRemoveMemberModal && selectedMember && ( +
{ setShowRemoveMemberModal(false); setSelectedMember(null); }}> +
e.stopPropagation()}> +
+ +

Remove Member

+
+

+ Remove {selectedMember.name} from this household? +

+ +
+
+ + Important: About Encrypted Data +
+

+ This household uses end-to-end encryption. While {selectedMember.name} will + lose access to future updates, they may retain copies of any data they previously viewed + or downloaded. This is a limitation of client-side encryption - once data has been + decrypted on their device, we cannot remotely delete it. +

+

+ If sensitive information was shared, consider creating a new household with fresh + encryption keys for maximum security. +

+
+ +
+ + +
+
+
+ )} + {/* Click outside to close menus */} {showOptionsMenu && (
setShowOptionsMenu(false)} /> diff --git a/frontend/src/pages/ListPage.module.css b/frontend/src/pages/ListPage.module.css index 3ddd4da..dc3c230 100644 --- a/frontend/src/pages/ListPage.module.css +++ b/frontend/src/pages/ListPage.module.css @@ -168,6 +168,12 @@ border-color: var(--color-warning); } +.actionButton.encryptedActive { + color: var(--color-success, #10b981); + background: rgba(16, 185, 129, 0.1); + border-color: var(--color-success, #10b981); +} + /* Content Area */ .content { flex: 1; diff --git a/frontend/src/pages/ListPage.tsx b/frontend/src/pages/ListPage.tsx index bd8ea6e..4655a07 100644 --- a/frontend/src/pages/ListPage.tsx +++ b/frontend/src/pages/ListPage.tsx @@ -1,6 +1,11 @@ import { useEffect, useState } from 'react'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Trash2, FileText, CheckSquare, ShoppingCart, Pencil } from 'lucide-react'; +import { ArrowLeft, Trash2, FileText, CheckSquare, ShoppingCart, Pencil, Lock, Unlock } from 'lucide-react'; +import { isNoteEncrypted } from '../utils/encryptionHelpers'; +import { useCryptoStore } from '../stores/cryptoStore'; +import { useEncryption } from '../hooks/useEncryption'; +import { useUnlockModal } from '../hooks/useUnlockModal'; +import EncryptionSetup from '../components/EncryptionSetup'; import { useListsStore, List } from '../stores/store'; import { api } from '../utils/api'; import { encryptedApi } from '../utils/encryptedApi'; @@ -19,6 +24,13 @@ export default function ListPage() { const [loading, setLoading] = useState(true); const [editingTitle, setEditingTitle] = useState(false); const [titleValue, setTitleValue] = useState(''); + const [encrypting, setEncrypting] = useState(false); + const [showEncryptionSetup, setShowEncryptionSetup] = useState(false); + const { isUnlocked, hasEncryptionSetup } = useCryptoStore(); + const { keyData } = useEncryption(); + const openUnlockModal = useUnlockModal((s) => s.open); + + const noteIsEncrypted = currentList ? isNoteEncrypted(currentList) : false; useEffect(() => { const loadList = async () => { @@ -136,6 +148,51 @@ export default function ListPage() { } }; + const handleToggleEncryption = async () => { + if (!currentList || !householdId || !listId) return; + + // If encryption not set up, show setup + if (!hasEncryptionSetup) { + setShowEncryptionSetup(true); + return; + } + + // If not unlocked, request unlock first + if (!isUnlocked) { + openUnlockModal(() => { + // After unlock, user can click again + }); + return; + } + + if (noteIsEncrypted) { + // Already encrypted — for now just inform + // TODO: implement decrypt (remove encryption from note) + alert('This note is encrypted. Decryption of individual notes is coming soon.'); + return; + } + + // Encrypt the note: re-save it through encryptedApi which will encrypt + setEncrypting(true); + try { + const updated = await encryptedApi.updateList(listId, householdId, { + title: currentList.title, + content: currentList.content, + items: currentList.items, + }); + setCurrentList(updated); + updateList(listId, householdId, updated); + // Reload to get the encrypted version from server + const reloaded = await encryptedApi.getList(listId, householdId); + setCurrentList(reloaded); + } catch (error) { + console.error('Failed to encrypt note:', error); + alert('Failed to encrypt note. Please try again.'); + } finally { + setEncrypting(false); + } + }; + if (loading) { return (
@@ -192,6 +249,14 @@ export default function ListPage() {
+ @@ -220,6 +285,19 @@ export default function ListPage() { /> )}
+ {/* Encryption Setup Modal */} + {showEncryptionSetup && ( +
+ setShowEncryptionSetup(false)} + onSkip={() => setShowEncryptionSetup(false)} + /> +
+ )}
); } diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index d9f3f6f..fce1c28 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -98,6 +98,12 @@ class ApiClient { }); } + async removeMember(householdId: string, memberId: string) { + return this.request(`/api/households/${householdId}/members/${memberId}`, { + method: 'DELETE', + }); + } + // Lists async getLists(householdId: string) { return this.request(`/api/lists/household/${householdId}`); diff --git a/frontend/src/utils/encryptionHelpers.ts b/frontend/src/utils/encryptionHelpers.ts new file mode 100644 index 0000000..b175a5c --- /dev/null +++ b/frontend/src/utils/encryptionHelpers.ts @@ -0,0 +1,33 @@ +/** + * Encryption helper utilities for per-note encryption. + * Detects encrypted content and provides unlock flow. + */ + +/** + * Check if a string value contains encrypted data. + * Encrypted values are stored as JSON: {"encrypted": true, "data": {...}} + */ +export function isEncryptedValue(value: string | undefined | null): boolean { + if (!value || typeof value !== 'string') return false; + try { + const parsed = JSON.parse(value); + return parsed?.encrypted === true && parsed?.data != null; + } catch { + return false; + } +} + +/** + * Check if a list/note has any encrypted content. + * Checks title, content, and items. + */ +export function isNoteEncrypted(list: { + title?: string; + content?: string; + items?: Array<{ text?: string }>; +}): boolean { + if (isEncryptedValue(list.title)) return true; + if (isEncryptedValue(list.content)) return true; + if (list.items?.some(item => isEncryptedValue(item.text))) return true; + return false; +}