From e6bb46a24b71de0395f99d4927d46178d4496328 Mon Sep 17 00:00:00 2001 From: dohsimpson Date: Sat, 8 Feb 2025 14:08:46 -0500 Subject: [PATCH 1/8] Multiuser support --- CHANGELOG.md | 12 ++ README.md | 17 +- app/actions/data.ts | 300 +++++++++++++++++++++++++--- app/actions/user.ts | 27 +++ app/api/auth/[...nextauth]/route.ts | 2 + app/debug/layout.tsx | 10 + app/debug/user/page.tsx | 16 ++ app/layout.tsx | 19 +- app/settings/page.tsx | 54 ----- auth.ts | 50 +++++ components/AboutModal.tsx | 1 + components/AddEditHabitModal.tsx | 53 ++++- components/ClientWrapper.tsx | 19 +- components/CoinsManager.tsx | 17 +- components/HabitItem.tsx | 37 +++- components/PasswordEntryForm.tsx | 95 +++++++++ components/PermissionSelector.tsx | 98 +++++++++ components/Profile.tsx | 118 ++++++++++- components/UserCreateForm.tsx | 140 +++++++++++++ components/UserForm.tsx | 245 +++++++++++++++++++++++ components/UserSelectModal.tsx | 213 ++++++++++++++++++++ components/jotai-hydrate.tsx | 5 +- docker-compose.yaml | 2 + hooks/useCoins.tsx | 32 +++ hooks/useHabits.tsx | 78 ++++++-- hooks/useWishlist.tsx | 67 +++++-- lib/atoms.ts | 4 + lib/client-helpers.ts | 21 ++ lib/constants.ts | 2 + lib/exceptions.ts | 6 + lib/server-helpers.ts | 19 ++ lib/types.ts | 54 ++++- lib/utils.ts | 54 ++++- lib/zod.ts | 11 + package-lock.json | 153 +++++++++++++- package.json | 7 +- types/next-auth.d.ts | 8 + 37 files changed, 1912 insertions(+), 154 deletions(-) create mode 100644 app/actions/user.ts create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/debug/layout.tsx create mode 100644 app/debug/user/page.tsx create mode 100644 auth.ts create mode 100644 components/PasswordEntryForm.tsx create mode 100644 components/PermissionSelector.tsx create mode 100644 components/UserCreateForm.tsx create mode 100644 components/UserForm.tsx create mode 100644 components/UserSelectModal.tsx create mode 100644 lib/client-helpers.ts create mode 100644 lib/exceptions.ts create mode 100644 lib/server-helpers.ts create mode 100644 lib/zod.ts create mode 100644 types/next-auth.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9595534..08e7d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Version 0.2.0 + +### Added +* Multi-user support with permissions system +* User management interface +* Support for multiple users tracking habits and wishlists +* Sharing habits and wishlist items with other users + +### BREAKING CHANGE +* Requires AUTH_SECRET environment variable for user authentication +* Generate a secure secret with: `openssl rand -base64 32` + ## Version 0.1.30 ### Fixed diff --git a/README.md b/README.md index 5e457e3..4a513dc 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https - 💰 Create a wishlist of rewards to redeem with earned coins - 📊 View your habit completion streaks and statistics - 📅 Calendar heatmap to visualize your progress (WIP) -- 🌙 Dark mode support (WIP) -- 📲 Progressive Web App (PWA) support (Planned) +- 🌙 Dark mode support +- 📲 Progressive Web App (PWA) support ## Usage @@ -46,11 +46,22 @@ chown -R 1001:1001 data # Required for the nextjs user in container 2. Then run using either method: ```bash +# Generate a secure authentication secret +export AUTH_SECRET=$(openssl rand -base64 32) +echo $AUTH_SECRET + # Using docker-compose (recommended) +## update the AUTH_SECRET environment variable in docker-compose file +nano docker-compose.yaml +## start the container docker compose up -d # Or using docker run directly -docker run -d -p 3000:3000 -v ./data:/app/data dohsimpson/habittrove +docker run -d \ + -p 3000:3000 \ + -v ./data:/app/data \ + -e AUTH_SECRET=$AUTH_SECRET \ + dohsimpson/habittrove ``` Available image tags: diff --git a/app/actions/data.ts b/app/actions/data.ts index c6671cf..730e89f 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -12,9 +12,41 @@ import { Settings, DataType, DATA_DEFAULTS, - getDefaultSettings + getDefaultSettings, + UserData, + getDefaultUsersData, + User, + getDefaultWishlistData, + getDefaultHabitsData, + getDefaultCoinsData, + Permission } from '@/lib/types' -import { d2t, getNow } from '@/lib/utils'; +import { d2t, deepMerge, getNow, saltAndHashPassword, verifyPassword, checkPermission } from '@/lib/utils'; +import { signInSchema } from '@/lib/zod'; +import { auth } from '@/auth'; +import _ from 'lodash'; +import { getCurrentUser, getCurrentUserId } from '@/lib/server-helpers' + +import { PermissionError } from '@/lib/exceptions' + +type ResourceType = 'habit' | 'wishlist' | 'coins' +type ActionType = 'write' | 'interact' + + +async function verifyPermission( + resource: ResourceType, + action: ActionType +): Promise { + // const user = await getCurrentUser() + + // if (!user) throw new PermissionError('User not authenticated') + // if (user.isAdmin) return // Admins bypass permission checks + + // if (!checkPermission(user.permissions, resource, action)) { + // throw new PermissionError(`User does not have ${action} permission for ${resource}`) + // } + return +} function getDefaultData(type: DataType): T { return DATA_DEFAULTS[type]() as T; @@ -45,7 +77,7 @@ async function loadData(type: DataType): Promise { // File exists, read and return its contents const data = await fs.readFile(filePath, 'utf8') - const jsonData = JSON.parse(data) + const jsonData = JSON.parse(data) as T return jsonData } catch (error) { console.error(`Error loading ${type} data:`, error) @@ -55,6 +87,9 @@ async function loadData(type: DataType): Promise { async function saveData(type: DataType, data: T): Promise { try { + const user = await getCurrentUser() + if (!user) throw new Error('User not authenticated') + await ensureDataDir() const filePath = path.join(process.cwd(), 'data', `${type}.json`) const saveData = data @@ -66,7 +101,14 @@ async function saveData(type: DataType, data: T): Promise { // Wishlist specific functions export async function loadWishlistData(): Promise { - return loadData('wishlist') + const user = await getCurrentUser() + if (!user) return getDefaultWishlistData() + + const data = await loadData('wishlist') + return { + ...data, + items: data.items.filter(x => user.isAdmin || x.userIds?.includes(user.id)) + } } export async function loadWishlistItems(): Promise { @@ -74,31 +116,98 @@ export async function loadWishlistItems(): Promise { return data.items } -export async function saveWishlistItems(items: WishlistItemType[]): Promise { - return saveData('wishlist', { items }) +export async function saveWishlistItems(data: WishlistData): Promise { + await verifyPermission('wishlist', 'write') + const user = await getCurrentUser() + + data.items = data.items.map(wishlist => ({ + ...wishlist, + userIds: wishlist.userIds || (user ? [user.id] : undefined) + })) + + if (!user?.isAdmin) { + const existingData = await loadData('wishlist') + existingData.items = existingData.items.filter(x => user?.id && !x.userIds?.includes(user?.id)) + data.items = [ + ...existingData.items, + ...data.items + ] + } + + return saveData('wishlist', data) } // Habits specific functions export async function loadHabitsData(): Promise { - return loadData('habits') + const user = await getCurrentUser() + if (!user) return getDefaultHabitsData() + const data = await loadData('habits') + return { + ...data, + habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id)) + } } export async function saveHabitsData(data: HabitsData): Promise { - return saveData('habits', data) + await verifyPermission('habit', 'write') + + const user = await getCurrentUser() + // Create clone of input data + const newData = _.cloneDeep(data) + + // Map habits with user IDs + newData.habits = newData.habits.map(habit => ({ + ...habit, + userIds: habit.userIds || (user ? [user.id] : undefined) + })) + + if (!user?.isAdmin) { + const existingData = await loadData('habits') + const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id)) + newData.habits = [ + ...existingHabits, + ...newData.habits + ] + } + + return saveData('habits', newData) } // Coins specific functions export async function loadCoinsData(): Promise { try { - return await loadData('coins') + const user = await getCurrentUser() + if (!user) return getDefaultCoinsData() + const data = await loadData('coins') + return { + ...data, + transactions: data.transactions.filter(x => user.isAdmin || x.userId === user.id) + } } catch { - return { balance: 0, transactions: [] } + return getDefaultCoinsData() } } export async function saveCoinsData(data: CoinsData): Promise { - return saveData('coins', data) + const user = await getCurrentUser() + + // Create clones of the data + const newData = _.cloneDeep(data) + newData.transactions = newData.transactions.map(transaction => ({ + ...transaction, + userId: transaction.userId || user?.id + })) + + if (!user?.isAdmin) { + const existingData = await loadData('coins') + const existingTransactions = existingData.transactions.filter(x => user?.id && x.userId !== user.id) + newData.transactions = [ + ...newData.transactions, + ...existingTransactions + ] + } + return saveData('coins', newData) } export async function addCoins({ @@ -114,6 +223,7 @@ export async function addCoins({ relatedItemId?: string note?: string }): Promise { + await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact') const data = await loadCoinsData() const newTransaction: CoinTransaction = { id: crypto.randomUUID(), @@ -138,6 +248,8 @@ export async function loadSettings(): Promise { const defaultSettings = getDefaultSettings() try { + const user = await getCurrentUser() + if (!user) return defaultSettings const data = await loadData('settings') return { ...defaultSettings, ...data } } catch { @@ -162,6 +274,7 @@ export async function removeCoins({ relatedItemId?: string note?: string }): Promise { + await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact') const data = await loadCoinsData() const newTransaction: CoinTransaction = { id: crypto.randomUUID(), @@ -182,7 +295,7 @@ export async function removeCoins({ return newData } -export async function uploadAvatar(formData: FormData) { +export async function uploadAvatar(formData: FormData): Promise { const file = formData.get('avatar') as File if (!file) throw new Error('No file provided') @@ -203,18 +316,7 @@ export async function uploadAvatar(formData: FormData) { const buffer = await file.arrayBuffer() await fs.writeFile(filePath, Buffer.from(buffer)) - // Update settings with new avatar path - const settings = await loadSettings() - const newSettings = { - ...settings, - profile: { - ...settings.profile, - avatarPath: `/data/avatars/${filename}` - } - } - - await saveSettings(newSettings) - return newSettings; + return `/data/avatars/${filename}` } export async function getChangelog(): Promise { @@ -226,3 +328,153 @@ export async function getChangelog(): Promise { return '# Changelog\n\nNo changelog available.' } } + +// user logic +export async function loadUsersData(): Promise { + try { + return await loadData('auth') + } catch { + return getDefaultUsersData() + } +} + +export async function saveUsersData(data: UserData): Promise { + return saveData('auth', data) +} + +export async function getUser(username: string, plainTextPassword: string): Promise { + const data = await loadUsersData() + + const user = data.users.find(user => user.username === username) + if (!user) return null + + // Verify the plaintext password against the stored salt:hash + const isValidPassword = verifyPassword(plainTextPassword, user.password) + if (!isValidPassword) return null + + return user +} + +export async function createUser(formData: FormData): Promise { + const username = formData.get('username') as string; + const password = formData.get('password') as string; + const avatarFile = formData.get('avatar') as File | null; + const permissions = formData.get('permissions') ? + JSON.parse(formData.get('permissions') as string) as Permission[] : + undefined; + + // Validate username and password against schema + await signInSchema.parseAsync({ username, password }); + + const data = await loadUsersData(); + + // Check if username already exists + if (data.users.some(user => user.username === username)) { + throw new Error('Username already exists'); + } + + const hashedPassword = saltAndHashPassword(password); + + // Handle avatar upload if present + let avatarPath: string | undefined; + if (avatarFile && avatarFile instanceof File && avatarFile.size > 0) { + const avatarFormData = new FormData(); + avatarFormData.append('avatar', avatarFile); + avatarPath = await uploadAvatar(avatarFormData); + } + + const newUser: User = { + id: crypto.randomUUID(), + username, + password: hashedPassword, + permissions, + isAdmin: false, + ...(avatarPath && { avatarPath }) + }; + + const newData: UserData = { + users: [...data.users, newUser] + }; + + await saveUsersData(newData); + return newUser; +} + +export async function updateUser(userId: string, updates: Partial>): Promise { + const data = await loadUsersData() + const userIndex = data.users.findIndex(user => user.id === userId) + + if (userIndex === -1) { + throw new Error('User not found') + } + + // If username is being updated, check for duplicates + if (updates.username) { + const isDuplicate = data.users.some( + user => user.username === updates.username && user.id !== userId + ) + if (isDuplicate) { + throw new Error('Username already exists') + } + } + + const updatedUser = { + ...data.users[userIndex], + ...updates + } + + const newData: UserData = { + users: [ + ...data.users.slice(0, userIndex), + updatedUser, + ...data.users.slice(userIndex + 1) + ] + } + + await saveUsersData(newData) + return updatedUser +} + +export async function updateUserPassword(userId: string, newPassword: string): Promise { + const data = await loadUsersData() + const userIndex = data.users.findIndex(user => user.id === userId) + + if (userIndex === -1) { + throw new Error('User not found') + } + + const hashedPassword = saltAndHashPassword(newPassword) + + const updatedUser = { + ...data.users[userIndex], + password: hashedPassword + } + + const newData: UserData = { + users: [ + ...data.users.slice(0, userIndex), + updatedUser, + ...data.users.slice(userIndex + 1) + ] + } + + await saveUsersData(newData) +} + +export async function deleteUser(userId: string): Promise { + const data = await loadUsersData() + const userIndex = data.users.findIndex(user => user.id === userId) + + if (userIndex === -1) { + throw new Error('User not found') + } + + const newData: UserData = { + users: [ + ...data.users.slice(0, userIndex), + ...data.users.slice(userIndex + 1) + ] + } + + await saveUsersData(newData) +} diff --git a/app/actions/user.ts b/app/actions/user.ts new file mode 100644 index 0000000..bfe6c31 --- /dev/null +++ b/app/actions/user.ts @@ -0,0 +1,27 @@ +"use server" + +import { signIn as signInNextAuth, signOut as signOutNextAuth } from '@/auth'; + +export async function signIn(username: string, password: string) { + try { + const result = await signInNextAuth("credentials", { + username, + password, + redirect: false, // This needs to be passed as an option, not as form data + }); + + return result; + } catch (error) { + throw new Error("Invalid credentials"); + } +} + +export async function signOut() { + try { + const result = await signOutNextAuth({ + redirect: false, + }) + } catch (error) { + throw new Error("Failed to sign out"); + } +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..42e2953 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from "@/auth" +export const { GET, POST } = handlers diff --git a/app/debug/layout.tsx b/app/debug/layout.tsx new file mode 100644 index 0000000..6c943d0 --- /dev/null +++ b/app/debug/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from "react"; + +export default function Debug({children}: {children: ReactNode}) { + if (process.env.NODE_ENV !== 'development') return null + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/app/debug/user/page.tsx b/app/debug/user/page.tsx new file mode 100644 index 0000000..8fccb3f --- /dev/null +++ b/app/debug/user/page.tsx @@ -0,0 +1,16 @@ +import { saltAndHashPassword } from '@/lib/utils'; + +export default function DebugPage() { + const password = 'admin'; + const hashedPassword = saltAndHashPassword(password); + + return ( +
+

Debug Page

+
+

Password: {password}

+

Hashed Password: {hashedPassword}

+
+
+ ); +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 1efc34a..43311b4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,10 +4,11 @@ import { DM_Sans } from 'next/font/google' import { JotaiProvider } from '@/components/jotai-providers' import { Suspense } from 'react' import { JotaiHydrate } from '@/components/jotai-hydrate' -import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData } from './actions/data' +import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData } from './actions/data' import Layout from '@/components/Layout' import { Toaster } from '@/components/ui/toaster' import { ThemeProvider } from "@/components/theme-provider" +import { SessionProvider } from 'next-auth/react' // Inter (clean, modern, excellent readability) @@ -36,11 +37,12 @@ export default async function RootLayout({ }: { children: React.ReactNode }) { - const [initialSettings, initialHabits, initialCoins, initialWishlist] = await Promise.all([ + const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers] = await Promise.all([ loadSettings(), loadHabitsData(), loadCoinsData(), - loadWishlistData() + loadWishlistData(), + loadUsersData(), ]) return ( @@ -71,7 +73,8 @@ export default async function RootLayout({ settings: initialSettings, habits: initialHabits, coins: initialCoins, - wishlist: initialWishlist + wishlist: initialWishlist, + users: initialUsers }} > - - {children} - + + + {children} + + diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 4369a0d..73fff78 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -142,60 +142,6 @@ export default function SettingsPage() { - - - Profile Settings - - -
-
- -
- Customize your profile picture -
-
-
- - - - - - -
{ - const newSettings = await uploadAvatar(formData) - setSettings(newSettings) - }}> - { - const file = e.target.files?.[0] - if (file) { - if (file.size > 5 * 1024 * 1024) { // 5MB - alert('File size must be less than 5MB') - e.target.value = '' - return - } - const form = e.target.form - if (form) form.requestSubmit() - } - }} - /> - -
-
-
-
-
) diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..6fbcefc --- /dev/null +++ b/auth.ts @@ -0,0 +1,50 @@ +import NextAuth from "next-auth" +import Credentials from "next-auth/providers/credentials" +import { getUser } from "./app/actions/data" +import { signInSchema } from "./lib/zod" +import { SafeUser } from "./lib/types" + +export const { handlers, signIn, signOut, auth } = NextAuth({ + trustHost: true, + providers: [ + Credentials({ + credentials: { + username: {}, + password: {}, + }, + authorize: async (credentials) => { + const { username, password } = await signInSchema.parseAsync(credentials) + + // Pass the plaintext password to getUser for verification + const user = await getUser(username, password) + + if (!user) { + throw new Error("Invalid credentials.") + } + + const safeUser: SafeUser = { username: user.username, id: user.id, avatarPath: user.avatarPath, isAdmin: user.isAdmin } + return safeUser + }, + }), + ], + callbacks: { + jwt: async ({ token, user }) => { + if (user) { + token.id = (user as SafeUser).id + token.username = (user as SafeUser).username + token.avatarPath = (user as SafeUser).avatarPath + token.isAdmin = (user as SafeUser).isAdmin + } + return token + }, + session: async ({ session, token }) => { + if (session?.user) { + session.user.id = token.id as string + session.user.username = token.username as string + session.user.avatarPath = token.avatarPath as string + session.user.isAdmin = token.isAdmin as boolean + } + return session + } + } +}) \ No newline at end of file diff --git a/components/AboutModal.tsx b/components/AboutModal.tsx index a582bd9..41cf800 100644 --- a/components/AboutModal.tsx +++ b/components/AboutModal.tsx @@ -8,6 +8,7 @@ import { DialogTitle } from "@radix-ui/react-dialog" import { Logo } from "./Logo" import ChangelogModal from "./ChangelogModal" import { useState } from "react" +import { saltAndHashPassword } from "@/lib/utils" interface AboutModalProps { isOpen: boolean diff --git a/components/AddEditHabitModal.tsx b/components/AddEditHabitModal.tsx index c55d38e..58b59db 100644 --- a/components/AddEditHabitModal.tsx +++ b/components/AddEditHabitModal.tsx @@ -3,9 +3,11 @@ import { useState, useEffect } from 'react' import { RRule, RRuleSet, rrulestr } from 'rrule' import { useAtom } from 'jotai' -import { settingsAtom, browserSettingsAtom } from '@/lib/atoms' +import { settingsAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' +import { Switch } from '@/components/ui/switch' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' @@ -13,11 +15,19 @@ import { Info, SmilePlus } from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import data from '@emoji-mart/data' import Picker from '@emoji-mart/react' -import { Habit } from '@/lib/types' +import { Habit, SafeUser } from '@/lib/types' import { d2s, d2t, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils' import { INITIAL_DUE, INITIAL_RECURRENCE_RULE } from '@/lib/constants' import * as chrono from 'chrono-node'; import { DateTime } from 'luxon' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { useHelpers } from '@/lib/client-helpers' interface AddEditHabitModalProps { onClose: () => void @@ -37,6 +47,10 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab const origRuleText = isRecurRule ? parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText() : habit?.frequency || INITIAL_DUE const [ruleText, setRuleText] = useState(origRuleText) const now = getNow({ timezone: settings.system.timezone }) + const { currentUser } = useHelpers() + const [selectedUserIds, setSelectedUserIds] = useState((habit?.userIds || []).filter(id => id !== currentUser?.id)) + const [usersData]= useAtom(usersAtom) + const users = usersData.users const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -47,7 +61,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab targetCompletions: targetCompletions > 1 ? targetCompletions : undefined, completions: habit?.completions || [], frequency: isRecurRule ? serializeRRule(parseNaturalLanguageRRule(ruleText)) : d2t({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }) }), - isTask: isTasksView ? true : undefined + isTask: isTasksView ? true : undefined, + userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]) }) } @@ -216,6 +231,38 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab + {users && users.length > 1 && ( +
+
+ +
+
+
+ {users.filter((u) => u.id !== currentUser?.id).map(user => ( + { + setSelectedUserIds(prev => + prev.includes(user.id) + ? prev.filter(id => id !== user.id) + : [...prev, user.id] + ) + }} + > + + {user.username[0]} + + ))} +
+
+
+ )} diff --git a/components/ClientWrapper.tsx b/components/ClientWrapper.tsx index 11be931..02cb251 100644 --- a/components/ClientWrapper.tsx +++ b/components/ClientWrapper.tsx @@ -1,12 +1,24 @@ 'use client' -import { ReactNode } from 'react' +import { ReactNode, useEffect } from 'react' import { useAtom } from 'jotai' -import { pomodoroAtom } from '@/lib/atoms' +import { pomodoroAtom, userSelectAtom } from '@/lib/atoms' import PomodoroTimer from './PomodoroTimer' +import UserSelectModal from './UserSelectModal' +import { useSession } from 'next-auth/react' export default function ClientWrapper({ children }: { children: ReactNode }) { const [pomo] = useAtom(pomodoroAtom) + const [userSelect, setUserSelect] = useAtom(userSelectAtom) + const { data: session, status } = useSession() + const currentUserId = session?.user.id + + useEffect(() => { + if (status === 'loading') return + if (!currentUserId && !userSelect) { + setUserSelect(true) + } + }, [currentUserId, status, userSelect]) return ( <> @@ -14,6 +26,9 @@ export default function ClientWrapper({ children }: { children: ReactNode }) { {pomo.show && ( )} + {userSelect && ( + setUserSelect(false)}/> + )} ) } diff --git a/components/CoinsManager.tsx b/components/CoinsManager.tsx index 60ed4a5..3641a7b 100644 --- a/components/CoinsManager.tsx +++ b/components/CoinsManager.tsx @@ -5,14 +5,16 @@ import { t2d, d2s, getNow, isSameDate } from '@/lib/utils' import { Button } from '@/components/ui/button' import { FormattedNumber } from '@/components/FormattedNumber' import { History, Pencil } from 'lucide-react' +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import EmptyState from './EmptyState' import { Input } from '@/components/ui/input' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { settingsAtom } from '@/lib/atoms' +import { settingsAtom, usersAtom } from '@/lib/atoms' import Link from 'next/link' import { useAtom } from 'jotai' import { useCoins } from '@/hooks/useCoins' import { TransactionNoteEditor } from './TransactionNoteEditor' +import { useHelpers } from '@/lib/client-helpers' export default function CoinsManager() { const { @@ -28,10 +30,12 @@ export default function CoinsManager() { transactionsToday } = useCoins() const [settings] = useAtom(settingsAtom) + const [usersData] = useAtom(usersAtom) const DEFAULT_AMOUNT = '0' const [amount, setAmount] = useState(DEFAULT_AMOUNT) const [pageSize, setPageSize] = useState(50) const [currentPage, setCurrentPage] = useState(1) + const { currentUser } = useHelpers() const [note, setNote] = useState('') @@ -252,6 +256,17 @@ export default function CoinsManager() { > {transaction.type.split('_').join(' ')} + {transaction.userId && currentUser?.isAdmin && ( + + u.id === transaction.userId)?.avatarPath && + `/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` || ""} + /> + + {usersData.users.find(u => u.id === transaction.userId)?.username[0]} + + + )}

{d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }), timezone: settings.system.timezone })} diff --git a/components/HabitItem.tsx b/components/HabitItem.tsx index 9265bd4..b03d3df 100644 --- a/components/HabitItem.tsx +++ b/components/HabitItem.tsx @@ -1,6 +1,6 @@ -import { Habit } from '@/lib/types' +import { Habit, SafeUser } from '@/lib/types' import { useAtom } from 'jotai' -import { settingsAtom, pomodoroAtom, browserSettingsAtom } from '@/lib/atoms' +import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms' import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s } from '@/lib/utils' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -16,6 +16,8 @@ import { useEffect, useState } from 'react' import { useHabits } from '@/hooks/useHabits' import { INITIAL_RECURRENCE_RULE } from '@/lib/constants' import { DateTime } from 'luxon' +import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' +import { useHelpers } from '@/lib/client-helpers' interface HabitItemProps { habit: Habit @@ -37,6 +39,9 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { const isTasksView = browserSettings.viewType === 'tasks' const isRecurRule = !isTasksView + const [usersData] = useAtom(usersAtom) + const { currentUser } = useHelpers() + useEffect(() => { const params = new URLSearchParams(window.location.search) const highlightId = params.get('highlight') @@ -63,11 +68,29 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { > {habit.name} - {habit.description && ( - - {habit.description} - - )} +

+
+ {habit.description && ( + + {habit.description} + + )} +
+ {habit.userIds && habit.userIds.length > 1 && ( +
+ {habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => { + const user = usersData.users.find(u => u.id === userId) + if (!user) return null + return ( + + + {user.username[0]} + + ) + })} +
+ )} +

When: {isRecurRule ? parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText() : d2s({ dateTime: t2d({ timestamp: habit.frequency, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}

diff --git a/components/PasswordEntryForm.tsx b/components/PasswordEntryForm.tsx new file mode 100644 index 0000000..cea44e1 --- /dev/null +++ b/components/PasswordEntryForm.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; +import { Input } from './ui/input'; +import { Button } from './ui/button'; +import { Label } from './ui/label'; +import { User as UserIcon } from 'lucide-react'; +import { Permission, User } from '@/lib/types'; +import { toast } from '@/hooks/use-toast'; +import { useState } from 'react'; +import { DEFAULT_ADMIN_PASS, DEFAULT_ADMIN_PASS_HASH } from '@/lib/constants'; +import { Switch } from './ui/switch'; + +interface PasswordEntryFormProps { + user: User; + onCancel: () => void; + onSubmit: (password: string) => Promise; + error?: string; +} + +export default function PasswordEntryForm({ + user, + onCancel, + onSubmit, + error +}: PasswordEntryFormProps) { + const hasPassword = user.password !== DEFAULT_ADMIN_PASS_HASH; + const [password, setPassword] = useState(hasPassword ? '' : DEFAULT_ADMIN_PASS); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await onSubmit(password); + } catch (err) { + toast({ + title: "Error", + description: err instanceof Error ? err.message : 'Login failed', + variant: "destructive" + }); + } + }; + + return ( +
+
+ + + + + + +
+
+ {user.username} +
+ +
+
+ + {hasPassword &&
+
+ + setPassword(e.target.value)} + className={error ? 'border-red-500' : ''} + /> + {error && ( +

{error}

+ )} +
+
} + +
+ + +
+
+ ); +} diff --git a/components/PermissionSelector.tsx b/components/PermissionSelector.tsx new file mode 100644 index 0000000..e58d1c9 --- /dev/null +++ b/components/PermissionSelector.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { Switch } from './ui/switch'; +import { Label } from './ui/label'; +import { Permission } from '@/lib/types'; + +interface PermissionSelectorProps { + permissions: Permission[]; + isAdmin: boolean; + onPermissionsChange: (permissions: Permission[]) => void; + onAdminChange: (isAdmin: boolean) => void; +} + +export function PermissionSelector({ + permissions, + isAdmin, + onPermissionsChange, + onAdminChange, +}: PermissionSelectorProps) { + const currentPermissions = isAdmin ? + { + habit: { write: true, interact: true }, + wishlist: { write: true, interact: true }, + coins: { write: true, interact: true } + } : + permissions[0] || { + habit: { write: false, interact: true }, + wishlist: { write: false, interact: true }, + coins: { write: false, interact: true } + }; + + const handlePermissionChange = (resource: keyof Permission, type: 'write' | 'interact', checked: boolean) => { + const newPermissions = [{ + ...currentPermissions, + [resource]: { + ...currentPermissions[resource], + [type]: checked + } + }]; + onPermissionsChange(newPermissions); + }; + + return ( +
+
+ + +
+ + {isAdmin ? ( +

+ Admins have full write and interact permission to all data. +

+ ) : +
+
+ +
+ {['habit', 'wishlist', 'coins'].map((resource) => ( +
+
{resource}
+
+
+ + + handlePermissionChange(resource as keyof Permission, 'write', checked) + } + /> +
+
+ + + handlePermissionChange(resource as keyof Permission, 'interact', checked) + } + /> +
+
+
+ ))} +
+
+
+ } +
+ + + ); +} diff --git a/components/Profile.tsx b/components/Profile.tsx index 2336d40..b421212 100644 --- a/components/Profile.tsx +++ b/components/Profile.tsx @@ -3,26 +3,52 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import { Settings, Info, User, Moon, Sun, Palette } from "lucide-react" +import { Settings, Info, User, Moon, Sun, Palette, ArrowRightLeft, LogOut, Crown } from "lucide-react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog' +import UserForm from './UserForm' import Link from "next/link" import { useAtom } from "jotai" -import { settingsAtom } from "@/lib/atoms" +import { settingsAtom, userSelectAtom } from "@/lib/atoms" import AboutModal from "./AboutModal" -import { useState } from "react" +import { useEffect, useState } from "react" import { useTheme } from "next-themes" +import { signOut } from "@/app/actions/user" +import { toast } from "@/hooks/use-toast" +import { useHelpers } from "@/lib/client-helpers" export function Profile() { const [settings] = useAtom(settingsAtom) + const [userSelect, setUserSelect] = useAtom(userSelectAtom) + const [isEditing, setIsEditing] = useState(false) const [showAbout, setShowAbout] = useState(false) const { theme, setTheme } = useTheme() + const { currentUser: user } = useHelpers() + const [open, setOpen] = useState(false) + + const handleSignOut = async () => { + try { + await signOut() + toast({ + title: "Signed out successfully", + description: "You have been logged out of your account", + }) + setTimeout(() => window.location.reload(), 300); + } catch (error) { + toast({ + title: "Error", + description: "Failed to sign out", + variant: "destructive", + }) + } + } return ( <> - + +
+
+ + + + + + +
+ + {user?.username || "Guest"} + {user?.isAdmin && } + + {user && ( + + )} +
+ {user && ( + + )} +
+
+ + { + setOpen(false); // Close the dropdown + setUserSelect(true); // Open the user select modal + }}> +
+
+ + Switch user +
+
+
Theme + + + +
+ + +
+ + ); +} diff --git a/components/UserForm.tsx b/components/UserForm.tsx new file mode 100644 index 0000000..4efee3a --- /dev/null +++ b/components/UserForm.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { useState } from 'react'; +import { Input } from './ui/input'; +import { Button } from './ui/button'; +import { Label } from './ui/label'; +import { Switch } from './ui/switch'; +import { Permission } from '@/lib/types'; +import { toast } from '@/hooks/use-toast'; +import { useAtom } from 'jotai'; +import { usersAtom } from '@/lib/atoms'; +import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data'; +import { SafeUser, User } from '@/lib/types'; +import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; +import { User as UserIcon } from 'lucide-react'; +import _ from 'lodash'; +import { PermissionSelector } from './PermissionSelector'; +import { useHelpers } from '@/lib/client-helpers'; + +interface UserFormProps { + userId?: string; // if provided, we're editing; if not, we're creating + onCancel: () => void; + onSuccess: () => void; +} + +export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) { + const [users, setUsersData] = useAtom(usersAtom); + const user = userId ? users.users.find(u => u.id === userId) : undefined; + const { currentUser } = useHelpers() + const getDefaultPermissions = (): Permission[] => [{ + habit: { + write: false, + interact: true + }, + wishlist: { + write: false, + interact: true + }, + coins: { + write: false, + interact: true + } + }]; + + const [avatarPath, setAvatarPath] = useState(user?.avatarPath) + const [username, setUsername] = useState(user?.username || ''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [avatarFile, setAvatarFile] = useState(null); + const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false); + const [permissions, setPermissions] = useState( + user?.permissions || getDefaultPermissions() + ); + const isEditing = !!user; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + if (isEditing) { + // Update existing user + if (username !== user.username || avatarPath !== user.avatarPath || !_.isEqual(permissions, user.permissions) || isAdmin !== user.isAdmin) { + await updateUser(user.id, { username, avatarPath, permissions, isAdmin }); + } + + if (password) { + await updateUserPassword(user.id, password); + } + + setUsersData(prev => ({ + ...prev, + users: prev.users.map(u => + u.id === user.id ? { ...u, username, avatarPath, permissions, isAdmin } : u + ), + })); + + toast({ + title: "User updated", + description: `Successfully updated user ${username}`, + variant: 'default' + }); + } else { + // Create new user + const formData = new FormData(); + formData.append('username', username); + formData.append('password', password); + formData.append('permissions', JSON.stringify(isAdmin ? undefined : permissions)); + formData.append('isAdmin', JSON.stringify(isAdmin)); + if (avatarFile) { + formData.append('avatar', avatarFile); + } + + const newUser = await createUser(formData); + setUsersData(prev => ({ + ...prev, + users: [...prev.users, newUser] + })); + + toast({ + title: "User created", + description: `Successfully created user ${username}`, + variant: 'default' + }); + } + + setPassword(''); + setError(''); + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} user`); + } + }; + + const handleAvatarChange = async (file: File) => { + if (file.size > 5 * 1024 * 1024) { + toast({ + title: "Error", + description: "File size must be less than 5MB", + variant: 'destructive' + }); + return; + } + + if (isEditing) { + const formData = new FormData(); + formData.append('avatar', file); + + try { + const path = await uploadAvatar(formData); + setAvatarPath(path); + toast({ + title: "Avatar uploaded", + description: "Successfully uploaded avatar", + variant: 'default' + }); + } catch (err) { + toast({ + title: "Error", + description: "Failed to upload avatar", + variant: 'destructive' + }); + } + } else { + setAvatarFile(file); + } + }; + + return ( +
+
+ + + + + + +
+ { + const file = e.target.files?.[0]; + if (file) { + handleAvatarChange(file); + } + }} + /> + +
+
+ +
+
+ + setUsername(e.target.value)} + className={error ? 'border-red-500' : ''} + /> +
+ +
+ + setPassword(e.target.value)} + className={error ? 'border-red-500' : ''} + required={!isEditing} + /> +
+ + {error && ( +

{error}

+ )} + + + {currentUser && currentUser.isAdmin && } + +
+ +
+ + +
+
+ ); +} diff --git a/components/UserSelectModal.tsx b/components/UserSelectModal.tsx new file mode 100644 index 0000000..526b50c --- /dev/null +++ b/components/UserSelectModal.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useState } from 'react'; +import PasswordEntryForm from './PasswordEntryForm'; +import UserForm from './UserForm'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'; +import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; +import { Crown, Pencil, Plus, User as UserIcon, UserRoundPen } from 'lucide-react'; +import { Input } from './ui/input'; +import { Button } from './ui/button'; +import { useAtom } from 'jotai'; +import { usersAtom } from '@/lib/atoms'; +import { signIn } from '@/app/actions/user'; +import { createUser } from '@/app/actions/data'; +import { toast } from '@/hooks/use-toast'; +import { Description } from '@radix-ui/react-dialog'; +import { SafeUser, User } from '@/lib/types'; +import { cn } from '@/lib/utils'; +import { useHelpers } from '@/lib/client-helpers'; + +function UserCard({ + user, + onSelect, + onEdit, + showEdit, + isCurrentUser +}: { + user: User, + onSelect: () => void, + onEdit: () => void, + showEdit: boolean, + isCurrentUser: boolean +}) { + return ( +
+ + {showEdit && ( + + )} +
+ ); +} + +function AddUserButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +function UserSelectionView({ + users, + currentUser, + onUserSelect, + onEditUser, + onCreateUser +}: { + users: User[], + currentUser?: SafeUser, + onUserSelect: (userId: string) => void, + onEditUser: (userId: string) => void, + onCreateUser: () => void +}) { + return ( +
+ {users + .filter(user => user.id !== currentUser?.id) + .map((user) => ( + onUserSelect(user.id)} + onEdit={() => onEditUser(user.id)} + showEdit={!!currentUser?.isAdmin} + isCurrentUser={false} + /> + ))} + {currentUser?.isAdmin && } +
+ ); +} + +export default function UserSelectModal({ onClose }: { onClose: () => void }) { + const [selectedUser, setSelectedUser] = useState(); + const [isCreating, setIsCreating] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [error, setError] = useState(''); + const [usersData] = useAtom(usersAtom); + const users = usersData.users; +const {currentUser} = useHelpers(); + + const handleUserSelect = (userId: string) => { + setSelectedUser(userId); + setError(''); + }; + + const handleEditUser = (userId: string) => { + setSelectedUser(userId); + setIsEditing(true); + }; + + const handleCreateUser = () => { + setIsCreating(true); + }; + + const handleFormSuccess = () => { + setSelectedUser(undefined); + setIsCreating(false); + setIsEditing(false); + onClose(); + }; + + const handleFormCancel = () => { + setSelectedUser(undefined); + setIsCreating(false); + setIsEditing(false); + setError(''); + }; + + return ( + + + + + {isCreating ? 'Create New User' : 'Select User'} + + +
+ {!selectedUser && !isCreating && !isEditing ? ( + + ) : isCreating || isEditing ? ( + + ) : ( + u.id === selectedUser)!} + onCancel={() => setSelectedUser(undefined)} + onSubmit={async (password) => { + try { + setError(''); + const user = users.find(u => u.id === selectedUser); + if (!user) throw new Error("User not found"); + await signIn(user.username, password); + + setError(''); + onClose(); + + toast({ + title: "Signed in successfully", + description: `Welcome back, ${user.username}!`, + variant: "default" + }); + + setTimeout(() => window.location.reload(), 300); + } catch (err) { + setError('invalid password'); + throw err; + } + }} + error={error} + /> + )} +
+
+
+ ); +} diff --git a/components/jotai-hydrate.tsx b/components/jotai-hydrate.tsx index ddc4f82..fea0f4a 100644 --- a/components/jotai-hydrate.tsx +++ b/components/jotai-hydrate.tsx @@ -1,6 +1,6 @@ 'use client' -import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom } from "@/lib/atoms" +import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom, usersAtom } from "@/lib/atoms" import { useHydrateAtoms } from "jotai/utils" import { JotaiHydrateInitialValues } from "@/lib/types" @@ -12,7 +12,8 @@ export function JotaiHydrate({ [settingsAtom, initialValues.settings], [habitsAtom, initialValues.habits], [coinsAtom, initialValues.coins], - [wishlistAtom, initialValues.wishlist] + [wishlistAtom, initialValues.wishlist], + [usersAtom, initialValues.users] ]) return children } diff --git a/docker-compose.yaml b/docker-compose.yaml index 91bf5e5..690354a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,3 +5,5 @@ services: volumes: - "./data:/app/data" # Use a relative path instead of $(pwd) image: dohsimpson/habittrove + environment: + - AUTH_SECRET=your-secret-key-here diff --git a/hooks/useCoins.tsx b/hooks/useCoins.tsx index f50a786..ea7a1e8 100644 --- a/hooks/useCoins.tsx +++ b/hooks/useCoins.tsx @@ -1,4 +1,5 @@ import { useAtom } from 'jotai' +import { checkPermission } from '@/lib/utils' import { coinsAtom, coinsEarnedTodayAtom, @@ -10,8 +11,36 @@ import { import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data' import { CoinsData } from '@/lib/types' import { toast } from '@/hooks/use-toast' +import { useHelpers } from '@/lib/client-helpers' + +function handlePermissionCheck( + user: any, + resource: 'habit' | 'wishlist' | 'coins', + action: 'write' | 'interact' +): boolean { + if (!user) { + toast({ + title: "Authentication Required", + description: "Please sign in to continue.", + variant: "destructive", + }) + return false + } + + if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) { + toast({ + title: "Permission Denied", + description: `You don't have ${action} permission for ${resource}s.`, + variant: "destructive", + }) + return false + } + + return true +} export function useCoins() { + const { currentUser: user } = useHelpers() const [coins, setCoins] = useAtom(coinsAtom) const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom) const [totalEarned] = useAtom(totalEarnedAtom) @@ -20,6 +49,7 @@ export function useCoins() { const [transactionsToday] = useAtom(transactionsTodayAtom) const add = async (amount: number, description: string, note?: string) => { + if (!handlePermissionCheck(user, 'coins', 'write')) return null if (isNaN(amount) || amount <= 0) { toast({ title: "Invalid amount", @@ -40,6 +70,7 @@ export function useCoins() { } const remove = async (amount: number, description: string, note?: string) => { + if (!handlePermissionCheck(user, 'coins', 'write')) return null const numAmount = Math.abs(amount) if (isNaN(numAmount) || numAmount <= 0) { toast({ @@ -61,6 +92,7 @@ export function useCoins() { } const updateNote = async (transactionId: string, note: string) => { + if (!handlePermissionCheck(user, 'coins', 'write')) return null const transaction = coins.transactions.find(t => t.id === transactionId) if (!transaction) { toast({ diff --git a/hooks/useHabits.tsx b/hooks/useHabits.tsx index beeba3e..085bce8 100644 --- a/hooks/useHabits.tsx +++ b/hooks/useHabits.tsx @@ -1,30 +1,62 @@ import { useAtom } from 'jotai' -import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms' +import { habitsAtom, coinsAtom, settingsAtom, usersAtom } from '@/lib/atoms' import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data' -import { Habit } from '@/lib/types' +import { Habit, Permission, SafeUser, User } from '@/lib/types' +import { toast } from '@/hooks/use-toast' import { DateTime } from 'luxon' -import { - getNowInMilliseconds, - getTodayInTimezone, - isSameDate, - t2d, - d2t, - getNow, - getCompletionsForDate, - getISODate, +import { + getNowInMilliseconds, + getTodayInTimezone, + isSameDate, + t2d, + d2t, + getNow, + getCompletionsForDate, + getISODate, d2s, - playSound + playSound, + checkPermission } from '@/lib/utils' -import { toast } from '@/hooks/use-toast' import { ToastAction } from '@/components/ui/toast' import { Undo2 } from 'lucide-react' +import { useHelpers } from '@/lib/client-helpers' + +function handlePermissionCheck( + user: SafeUser | undefined, + resource: 'habit' | 'wishlist' | 'coins', + action: 'write' | 'interact' +): boolean { + if (!user) { + toast({ + title: "Authentication Required", + description: "Please sign in to continue.", + variant: "destructive", + }) + return false + } + + if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) { + toast({ + title: "Permission Denied", + description: `You don't have ${action} permission for ${resource}s.`, + variant: "destructive", + }) + return false + } + + return true +} + export function useHabits() { + const [usersData] = useAtom(usersAtom) + const { currentUser } = useHelpers() const [habitsData, setHabitsData] = useAtom(habitsAtom) const [coins, setCoins] = useAtom(coinsAtom) const [settings] = useAtom(settingsAtom) const completeHabit = async (habit: Habit) => { + if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return const timezone = settings.system.timezone const today = getTodayInTimezone(timezone) @@ -43,7 +75,7 @@ export function useHabits() { description: `You've already completed this habit today.`, variant: "destructive", }) - return null + return } // Add new completion @@ -71,7 +103,7 @@ export function useHabits() { }) isTargetReached && playSound() toast({ - title: "Habit completed!", + title: "Completed!", description: `You earned ${habit.coinReward} coins.`, action: undoComplete(updatedHabit)}> Undo @@ -98,6 +130,7 @@ export function useHabits() { } const undoComplete = async (habit: Habit) => { + if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return const timezone = settings.system.timezone const today = t2d({ timestamp: getTodayInTimezone(timezone), timezone }) @@ -113,7 +146,7 @@ export function useHabits() { completions: habit.completions.filter( (_, index) => index !== habit.completions.length - 1 ), - archived: habit.isTask ? undefined : habit.archived // Unarchive if it's a task + archived: habit.isTask ? false : habit.archived // Unarchive if it's a task } const updatedHabits = habitsData.habits.map(h => @@ -158,11 +191,12 @@ export function useHabits() { description: "This habit hasn't been completed today.", variant: "destructive", }) - return null + return } } const saveHabit = async (habit: Omit & { id?: string }) => { + if (!handlePermissionCheck(currentUser, 'habit', 'write')) return const newHabit = { ...habit, id: habit.id || getNowInMilliseconds().toString() @@ -177,6 +211,7 @@ export function useHabits() { } const deleteHabit = async (id: string) => { + if (!handlePermissionCheck(currentUser, 'habit', 'write')) return const updatedHabits = habitsData.habits.filter(h => h.id !== id) await saveHabitsData({ habits: updatedHabits }) setHabitsData({ habits: updatedHabits }) @@ -184,6 +219,7 @@ export function useHabits() { } const completePastHabit = async (habit: Habit, date: DateTime) => { + if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return const timezone = settings.system.timezone const dateKey = getISODate({ dateTime: date, timezone }) @@ -199,7 +235,7 @@ export function useHabits() { description: `This habit was already completed on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}.`, variant: "destructive", }) - return null + return } // Use current time but with the past date @@ -236,7 +272,7 @@ export function useHabits() { } toast({ - title: isTargetReached ? "Habit completed!" : "Progress!", + title: isTargetReached ? "Completed!" : "Progress!", description: isTargetReached ? `You earned ${habit.coinReward} coins for ${dateKey}.` : `You've completed ${completionsOnDate + 1}/${target} times on ${dateKey}.`, @@ -253,6 +289,7 @@ export function useHabits() { } const archiveHabit = async (id: string) => { + if (!handlePermissionCheck(currentUser, 'habit', 'write')) return const updatedHabits = habitsData.habits.map(h => h.id === id ? { ...h, archived: true } : h ) @@ -261,8 +298,9 @@ export function useHabits() { } const unarchiveHabit = async (id: string) => { + if (!handlePermissionCheck(currentUser, 'habit', 'write')) return const updatedHabits = habitsData.habits.map(h => - h.id === id ? { ...h, archived: undefined } : h + h.id === id ? { ...h, archived: false } : h ) await saveHabitsData({ habits: updatedHabits }) setHabitsData({ habits: updatedHabits }) diff --git a/hooks/useWishlist.tsx b/hooks/useWishlist.tsx index 7c96e46..60858dc 100644 --- a/hooks/useWishlist.tsx +++ b/hooks/useWishlist.tsx @@ -4,34 +4,70 @@ import { saveWishlistItems, removeCoins } from '@/app/actions/data' import { toast } from '@/hooks/use-toast' import { WishlistItemType } from '@/lib/types' import { celebrations } from '@/utils/celebrations' +import { checkPermission } from '@/lib/utils' +import { useHelpers } from '@/lib/client-helpers' + +function handlePermissionCheck( + user: any, + resource: 'habit' | 'wishlist' | 'coins', + action: 'write' | 'interact' +): boolean { + if (!user) { + toast({ + title: "Authentication Required", + description: "Please sign in to continue.", + variant: "destructive", + }) + return false + } + + if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) { + toast({ + title: "Permission Denied", + description: `You don't have ${action} permission for ${resource}s.`, + variant: "destructive", + }) + return false + } + + return true +} export function useWishlist() { + const { currentUser: user } = useHelpers() const [wishlist, setWishlist] = useAtom(wishlistAtom) const [coins, setCoins] = useAtom(coinsAtom) const balance = coins.balance const addWishlistItem = async (item: Omit) => { + if (!handlePermissionCheck(user, 'wishlist', 'write')) return const newItem = { ...item, id: Date.now().toString() } const newItems = [...wishlist.items, newItem] - setWishlist({ items: newItems }) - await saveWishlistItems(newItems) + const newWishListData = { items: newItems } + setWishlist(newWishListData) + await saveWishlistItems(newWishListData) } const editWishlistItem = async (updatedItem: WishlistItemType) => { + if (!handlePermissionCheck(user, 'wishlist', 'write')) return const newItems = wishlist.items.map(item => item.id === updatedItem.id ? updatedItem : item ) - setWishlist({ items: newItems }) - await saveWishlistItems(newItems) + const newWishListData = { items: newItems } + setWishlist(newWishListData) + await saveWishlistItems(newWishListData) } const deleteWishlistItem = async (id: string) => { + if (!handlePermissionCheck(user, 'wishlist', 'write')) return const newItems = wishlist.items.filter(item => item.id !== id) - setWishlist({ items: newItems }) - await saveWishlistItems(newItems) + const newWishListData = { items: newItems } + setWishlist(newWishListData) + await saveWishlistItems(newWishListData) } const redeemWishlistItem = async (item: WishlistItemType) => { + if (!handlePermissionCheck(user, 'wishlist', 'interact')) return false if (balance >= item.coinCost) { // Check if item has target completions and if we've reached the limit if (item.targetCompletions && item.targetCompletions <= 0) { @@ -71,8 +107,9 @@ export function useWishlist() { } return wishlistItem }) - setWishlist({ items: newItems }) - await saveWishlistItems(newItems) + const newWishListData = { items: newItems } + setWishlist(newWishListData) + await saveWishlistItems(newWishListData) } // Randomly choose a celebration effect @@ -101,19 +138,23 @@ export function useWishlist() { const canRedeem = (cost: number) => balance >= cost const archiveWishlistItem = async (id: string) => { + if (!handlePermissionCheck(user, 'wishlist', 'write')) return const newItems = wishlist.items.map(item => item.id === id ? { ...item, archived: true } : item ) - setWishlist({ items: newItems }) - await saveWishlistItems(newItems) + const newWishListData = { items: newItems } + setWishlist(newWishListData) + await saveWishlistItems(newWishListData) } const unarchiveWishlistItem = async (id: string) => { + if (!handlePermissionCheck(user, 'wishlist', 'write')) return const newItems = wishlist.items.map(item => - item.id === id ? { ...item, archived: undefined } : item + item.id === id ? { ...item, archived: false } : item ) - setWishlist({ items: newItems }) - await saveWishlistItems(newItems) + const newWishListData = { items: newItems } + setWishlist(newWishListData) + await saveWishlistItems(newWishListData) } return { diff --git a/lib/atoms.ts b/lib/atoms.ts index dff2ae5..b67454b 100644 --- a/lib/atoms.ts +++ b/lib/atoms.ts @@ -6,6 +6,7 @@ import { getDefaultWishlistData, Habit, ViewType, + getDefaultUsersData, } from "./types"; import { getTodayInTimezone, @@ -29,6 +30,7 @@ export const browserSettingsAtom = atomWithStorage('browserSettings', { viewType: 'habits' } as BrowserSettings) +export const usersAtom = atom(getDefaultUsersData()) export const settingsAtom = atom(getDefaultSettings()); export const habitsAtom = atom(getDefaultHabitsData()); export const coinsAtom = atom(getDefaultCoinsData()); @@ -82,6 +84,8 @@ export const pomodoroAtom = atom({ minimized: false, }) +export const userSelectAtom = atom(false) + // Derived atom for *fully* completed habits by date, respecting target completions export const completedHabitsMapAtom = atom((get) => { const habits = get(habitsAtom).habits diff --git a/lib/client-helpers.ts b/lib/client-helpers.ts new file mode 100644 index 0000000..7679fb4 --- /dev/null +++ b/lib/client-helpers.ts @@ -0,0 +1,21 @@ +// client helpers +'use-client' + +import { useSession } from "next-auth/react" +import { User, UserId } from './types' +import { useAtom } from 'jotai' +import { usersAtom } from './atoms' + +export function useHelpers() { + const { data: session, status } = useSession() + const currentUser = session?.user + const [usersData] = useAtom(usersAtom) + const currentUserData = usersData.users.find((u) => u.id === currentUser?.id) + + return { + currentUser, + currentUserData, + usersData, + status + } +} \ No newline at end of file diff --git a/lib/constants.ts b/lib/constants.ts index 8186e69..97c4abe 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -19,3 +19,5 @@ export const DUE_MAP: { [key: string]: string } = { export const HabitIcon = Target export const TaskIcon = CheckSquare; +export const DEFAULT_ADMIN_PASS = "admin" +export const DEFAULT_ADMIN_PASS_HASH = "4fd03f11af068acd8aa2bf8a38ce6ef7:bcf07a1776ba9fcb927fbcfb0eda933573f87e0852f8620b79c1da9242664856197f53109a94233cdaea7e6b08bf07713642f990739ff71480990f842809bd99" // "admin" \ No newline at end of file diff --git a/lib/exceptions.ts b/lib/exceptions.ts new file mode 100644 index 0000000..b8925c5 --- /dev/null +++ b/lib/exceptions.ts @@ -0,0 +1,6 @@ +export class PermissionError extends Error { + constructor(message: string) { + super(message) + this.name = 'PermissionError' + } +} diff --git a/lib/server-helpers.ts b/lib/server-helpers.ts new file mode 100644 index 0000000..9e0c2b5 --- /dev/null +++ b/lib/server-helpers.ts @@ -0,0 +1,19 @@ +import { auth } from '@/auth' +import 'server-only' +import { User, UserId } from './types' +import { loadUsersData } from '@/app/actions/data' + +export async function getCurrentUserId(): Promise { + const session = await auth() + const user = session?.user + return user?.id +} + +export async function getCurrentUser(): Promise { + const currentUserId = await getCurrentUserId() + if (!currentUserId) { + return undefined + } + const usersData = await loadUsersData() + return usersData.users.find((u) => u.id === currentUserId) +} \ No newline at end of file diff --git a/lib/types.ts b/lib/types.ts index 43f7d25..cba4550 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,3 +1,35 @@ +import { DEFAULT_ADMIN_PASS_HASH } from "./constants" +import { saltAndHashPassword } from "./utils" + +export type UserId = string + +export type Permission = { + habit: { + write: boolean + interact: boolean + } + wishlist: { + write: boolean + interact: boolean + } + coins: { + write: boolean + interact: boolean + } +} + +export type SafeUser = { + id: UserId + username: string + avatarPath?: string + permissions?: Permission[] + isAdmin?: boolean +} + +export type User = SafeUser & { + password: string +} + export type Habit = { id: string name: string @@ -8,6 +40,7 @@ export type Habit = { completions: string[] // Array of UTC ISO date strings isTask?: boolean // mark the habit as a task archived?: boolean // mark the habit as archived + userIds?: UserId[] } @@ -21,6 +54,7 @@ export type WishlistItemType = { archived?: boolean // mark the wishlist item as archived targetCompletions?: number // Optional field, infinity when unset link?: string // Optional URL to external resource + userIds?: UserId[] } export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO'; @@ -33,6 +67,11 @@ export interface CoinTransaction { timestamp: string; relatedItemId?: string; note?: string; + userId?: UserId; +} + +export interface UserData { + users: User[] } export interface HabitsData { @@ -52,6 +91,17 @@ export interface WishlistData { } // Default value functions +export const getDefaultUsersData = (): UserData => ({ + users: [ + { + id: crypto.randomUUID(), + username: 'admin', + password: DEFAULT_ADMIN_PASS_HASH, + isAdmin: true, + } + ] +}); + export const getDefaultHabitsData = (): HabitsData => ({ habits: [] }); @@ -84,6 +134,7 @@ export const DATA_DEFAULTS = { habits: getDefaultHabitsData, coins: getDefaultCoinsData, settings: getDefaultSettings, + auth: getDefaultUsersData, } as const; // Type for all possible data types @@ -102,7 +153,7 @@ export interface SystemSettings { } export interface ProfileSettings { - avatarPath?: string; + avatarPath?: string; // deprecated } export interface Settings { @@ -118,4 +169,5 @@ export interface JotaiHydrateInitialValues { coins: CoinsData; habits: HabitsData; wishlist: WishlistData; + users: UserData; } diff --git a/lib/utils.ts b/lib/utils.ts index 369b3ad..865699f 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -2,9 +2,11 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" import { DateTime, DateTimeFormatOptions } from "luxon" import { datetime, RRule } from 'rrule' -import { Freq, Habit, CoinTransaction } from '@/lib/types' -import { DUE_MAP, INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants" +import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types' +import { DUE_MAP, RECURRENCE_RULE_MAP } from "./constants" import * as chrono from 'chrono-node'; +import { randomBytes, scryptSync } from "crypto" +import _ from "lodash" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -296,4 +298,50 @@ export const openWindow = (url: string): boolean => { return false } return true -} \ No newline at end of file +} + +export function saltAndHashPassword(password: string, salt?: string): string { + salt = salt || randomBytes(16).toString('hex'); + const hash = scryptSync(password, salt, 64).toString('hex'); + return `${salt}:${hash}`; +} + +export function verifyPassword(password: string, storedHash: string): boolean { + // Split the stored hash into its salt and hash components + const [salt, hash] = storedHash.split(':'); + + // Hash the input password with the same salt + const newHash = saltAndHashPassword(password, salt).split(':')[1]; + + // Compare the new hash with the stored hash + return newHash === hash; +} + +export function deepMerge(a: T, b: T) { + return _.merge(a, b, (x: unknown, y: unknown) => { + if (_.isArray(a)) { + return a.concat(b) + } + }) +} + +export function checkPermission( + permissions: Permission[] | undefined, + resource: 'habit' | 'wishlist' | 'coins', + action: 'write' | 'interact' +): boolean { + if (!permissions) return false + + return permissions.some(permission => { + switch (resource) { + case 'habit': + return permission.habit[action] + case 'wishlist': + return permission.wishlist[action] + case 'coins': + return permission.coins[action] + default: + return false + } + }) +} diff --git a/lib/zod.ts b/lib/zod.ts new file mode 100644 index 0000000..523eb7e --- /dev/null +++ b/lib/zod.ts @@ -0,0 +1,11 @@ +import { object, string } from "zod" + +export const signInSchema = object({ + username: string({ required_error: "Username is required" }) + .min(1, "Username is required") + .regex(/^[a-zA-Z0-9]+$/, "Username must be alphanumeric"), + password: string({ required_error: "Password is required" }) + .min(1, "Password is required") + .min(4, "Password must be more than 4 characters") + .max(32, "Password must be less than 32 characters"), +}) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e00d149..aee228a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "habittrove", - "version": "0.1.26", + "version": "0.1.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "habittrove", - "version": "0.1.26", + "version": "0.1.30", "dependencies": { "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", @@ -37,6 +37,7 @@ "lucide-react": "^0.469.0", "luxon": "^3.5.0", "next": "15.1.3", + "next-auth": "^5.0.0-beta.25", "next-themes": "^0.4.4", "react": "^19.0.0", "react-confetti": "^6.2.2", @@ -47,12 +48,14 @@ "rrule": "^2.8.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", - "web-push": "^3.6.7" + "web-push": "^3.6.7", + "zod": "^3.24.1" }, "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/typography": "^0.5.15", "@types/bun": "^1.1.14", + "@types/lodash": "^4.17.15", "@types/luxon": "^3.4.2", "@types/node": "^20.17.10", "@types/react": "^19", @@ -79,6 +82,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth/core": { + "version": "0.37.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.2.tgz", + "integrity": "sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "@types/cookie": "0.6.0", + "cookie": "0.7.1", + "jose": "^5.9.3", + "oauth4webapi": "^3.0.0", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/runtime": { "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", @@ -921,6 +955,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1831,6 +1874,12 @@ "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==" }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -1948,6 +1997,13 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/luxon": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", @@ -3236,6 +3292,15 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5430,6 +5495,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jotai": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.8.0.tgz", @@ -6490,6 +6564,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.25", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.25.tgz", + "integrity": "sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.37.2" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0-0", + "nodemailer": "^6.6.5", + "react": "^18.2.0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next-themes": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.4.tgz", @@ -6542,6 +6643,15 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.1.4.tgz", + "integrity": "sha512-eVfN3nZNbok2s/ROifO0UAc5G8nRoLSbrcKJ09OqmucgnhXEfdIQOR4gq1eJH1rN3gV7rNw62bDEgftsgFtBEg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6964,6 +7074,28 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6973,6 +7105,12 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9038,6 +9176,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 604e478..74ed51c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habittrove", - "version": "0.1.30", + "version": "0.2.0", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -44,6 +44,7 @@ "lucide-react": "^0.469.0", "luxon": "^3.5.0", "next": "15.1.3", + "next-auth": "^5.0.0-beta.25", "next-themes": "^0.4.4", "react": "^19.0.0", "react-confetti": "^6.2.2", @@ -54,12 +55,14 @@ "rrule": "^2.8.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", - "web-push": "^3.6.7" + "web-push": "^3.6.7", + "zod": "^3.24.1" }, "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/typography": "^0.5.15", "@types/bun": "^1.1.14", + "@types/lodash": "^4.17.15", "@types/luxon": "^3.4.2", "@types/node": "^20.17.10", "@types/react": "^19", diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts new file mode 100644 index 0000000..56319c5 --- /dev/null +++ b/types/next-auth.d.ts @@ -0,0 +1,8 @@ +import 'next-auth' +import { SafeUser } from '@/lib/types' + +declare module 'next-auth' { + interface Session { + user: SafeUser + } +} \ No newline at end of file From 3c0ec2bbc5048f59d5f2d418287d006eea07c792 Mon Sep 17 00:00:00 2001 From: dohsimpson Date: Sat, 8 Feb 2025 23:10:16 -0500 Subject: [PATCH 2/8] show tasks in dashboard + many fixes --- CHANGELOG.md | 12 ++ auth.ts | 12 +- components/AddEditHabitModal.tsx | 6 +- components/AddEditWishlistItemModal.tsx | 42 +++- components/DailyOverview.tsx | 263 ++++++++++++++++++++++-- components/Dashboard.tsx | 12 +- components/HabitItem.tsx | 42 ++-- components/HabitList.tsx | 4 + components/HabitStreak.tsx | 29 ++- components/Header.tsx | 5 +- components/UserForm.tsx | 2 +- components/UserSelectModal.tsx | 2 +- components/WishlistItem.tsx | 43 +++- hooks/useCoins.tsx | 6 +- lib/atoms.ts | 6 + lib/client-helpers.ts | 6 +- lib/types.ts | 5 +- 17 files changed, 416 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08e7d6b..c220c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,24 @@ ## Version 0.2.0 ### Added + * Multi-user support with permissions system * User management interface * Support for multiple users tracking habits and wishlists * Sharing habits and wishlist items with other users +* show both tasks and habits in dashboard (#58) +* show tasks in completion streak (#57) + +### Changed + +- useHelpers hook should return user from atom not session +- sharing wishlist with other users +- disable permission edit if only has 1 user +- always show edit button in user switch modal + ### BREAKING CHANGE + * Requires AUTH_SECRET environment variable for user authentication * Generate a secure secret with: `openssl rand -base64 32` diff --git a/auth.ts b/auth.ts index 6fbcefc..c2e9b3a 100644 --- a/auth.ts +++ b/auth.ts @@ -2,7 +2,7 @@ import NextAuth from "next-auth" import Credentials from "next-auth/providers/credentials" import { getUser } from "./app/actions/data" import { signInSchema } from "./lib/zod" -import { SafeUser } from "./lib/types" +import { SafeUser, SessionUser } from "./lib/types" export const { handlers, signIn, signOut, auth } = NextAuth({ trustHost: true, @@ -22,7 +22,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ throw new Error("Invalid credentials.") } - const safeUser: SafeUser = { username: user.username, id: user.id, avatarPath: user.avatarPath, isAdmin: user.isAdmin } + const safeUser: SessionUser = { id: user.id } return safeUser }, }), @@ -30,19 +30,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ callbacks: { jwt: async ({ token, user }) => { if (user) { - token.id = (user as SafeUser).id - token.username = (user as SafeUser).username - token.avatarPath = (user as SafeUser).avatarPath - token.isAdmin = (user as SafeUser).isAdmin + token.id = (user as SessionUser).id } return token }, session: async ({ session, token }) => { if (session?.user) { session.user.id = token.id as string - session.user.username = token.username as string - session.user.avatarPath = token.avatarPath as string - session.user.isAdmin = token.isAdmin as boolean } return session } diff --git a/components/AddEditHabitModal.tsx b/components/AddEditHabitModal.tsx index 58b59db..c200ae9 100644 --- a/components/AddEditHabitModal.tsx +++ b/components/AddEditHabitModal.tsx @@ -37,7 +37,7 @@ interface AddEditHabitModalProps { export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHabitModalProps) { const [settings] = useAtom(settingsAtom) - const [browserSettings] = useAtom(browserSettingsAtom) + const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom) const isTasksView = browserSettings.viewType === 'tasks' const [name, setName] = useState(habit?.name || '') const [description, setDescription] = useState(habit?.description || '') @@ -243,8 +243,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab key={user.id} className={`h-8 w-8 border-2 cursor-pointer ${selectedUserIds.includes(user.id) - ? 'border-primary hover:border-primary/80' - : 'border-muted hover:border-primary/50' + ? 'border-primary' + : 'border-muted' }`} title={user.username} onClick={() => { diff --git a/components/AddEditWishlistItemModal.tsx b/components/AddEditWishlistItemModal.tsx index 79b444c..ea74788 100644 --- a/components/AddEditWishlistItemModal.tsx +++ b/components/AddEditWishlistItemModal.tsx @@ -1,4 +1,8 @@ import { useState, useEffect } from 'react' +import { useAtom } from 'jotai' +import { usersAtom } from '@/lib/atoms' +import { useHelpers } from '@/lib/client-helpers' +import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -33,7 +37,10 @@ export default function AddEditWishlistItemModal({ const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1) const [targetCompletions, setTargetCompletions] = useState(editingItem?.targetCompletions) const [link, setLink] = useState(editingItem?.link || '') + const { currentUser } = useHelpers() + const [selectedUserIds, setSelectedUserIds] = useState((editingItem?.userIds || []).filter(id => id !== currentUser?.id)) const [errors, setErrors] = useState<{ [key: string]: string }>({}) + const [usersData] = useAtom(usersAtom) useEffect(() => { if (editingItem) { @@ -93,7 +100,8 @@ export default function AddEditWishlistItemModal({ description, coinCost, targetCompletions: targetCompletions || undefined, - link: link.trim() || undefined + link: link.trim() || undefined, + userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]) } if (editingItem) { @@ -268,6 +276,38 @@ export default function AddEditWishlistItemModal({ )} + {usersData.users && usersData.users.length > 1 && ( +
+
+ +
+
+
+ {usersData.users.filter((u) => u.id !== currentUser?.id).map(user => ( + { + setSelectedUserIds(prev => + prev.includes(user.id) + ? prev.filter(id => id !== user.id) + : [...prev, user.id] + ) + }} + > + + {user.username[0]} + + ))} +
+
+
+ )} diff --git a/components/DailyOverview.tsx b/components/DailyOverview.tsx index 7658c81..2dd82a3 100644 --- a/components/DailyOverview.tsx +++ b/components/DailyOverview.tsx @@ -1,4 +1,4 @@ -import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer } from 'lucide-react' +import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus } from 'lucide-react' import { ContextMenu, ContextMenuContent, @@ -9,7 +9,7 @@ import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils' import Link from 'next/link' import { useState, useEffect } from 'react' import { useAtom } from 'jotai' -import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom } from '@/lib/atoms' +import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings } from '@/lib/atoms' import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' @@ -18,6 +18,8 @@ import { WishlistItemType } from '@/lib/types' import { Habit } from '@/lib/types' import Linkify from './linkify' import { useHabits } from '@/hooks/useHabits' +import AddEditHabitModal from './AddEditHabitModal' +import { Button } from './ui/button' interface UpcomingItemsProps { habits: Habit[] @@ -32,21 +34,25 @@ export default function DailyOverview({ }: UpcomingItemsProps) { const { completeHabit, undoComplete } = useHabits() const [settings] = useAtom(settingsAtom) - const [browserSettings] = useAtom(browserSettingsAtom) const [dailyHabits, setDailyHabits] = useState([]) + const [dailyTasks, setDailyTasks] = useState([]) const [completedHabitsMap] = useAtom(completedHabitsMapAtom) const today = getTodayInTimezone(settings.system.timezone) const todayCompletions = completedHabitsMap.get(today) || [] - const isTasksView = browserSettings.viewType === 'tasks' + const { saveHabit } = useHabits() + const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom) useEffect(() => { - // Filter habits that are due today based on their recurrence rule + // Filter habits and tasks that are due today based on their recurrence rule const filteredHabits = habits.filter(habit => - (isTasksView ? habit.isTask : !habit.isTask) && - isHabitDueToday({ habit, timezone: settings.system.timezone }) + !habit.isTask && isHabitDueToday({ habit, timezone: settings.system.timezone }) + ) + const filteredTasks = habits.filter(habit => + habit.isTask && isHabitDueToday({ habit, timezone: settings.system.timezone }) ) setDailyHabits(filteredHabits) - }, [habits, isTasksView]) + setDailyTasks(filteredTasks) + }, [habits]) // Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost const sortedWishlistItems = wishlistItems @@ -64,8 +70,11 @@ export default function DailyOverview({ }) const [expandedHabits, setExpandedHabits] = useState(false) + const [expandedTasks, setExpandedTasks] = useState(false) const [expandedWishlist, setExpandedWishlist] = useState(false) const [_, setPomo] = useAtom(pomodoroAtom) + const [isModalOpen, setIsModalOpen] = useState(false) + const [isTaskModal, setIsTaskModal] = useState(false) return ( <> @@ -74,17 +83,221 @@ export default function DailyOverview({ Today's Overview -
-
-
-

{isTasksView ? 'Daily Tasks' : 'Daily Habits'}

- - {`${dailyHabits.filter(habit => { - const completions = (completedHabitsMap.get(today) || []) - .filter(h => h.id === habit.id).length; - return completions >= (habit.targetCompletions || 1); - }).length}/${dailyHabits.length} Completed`} - +
+ {/* Tasks Section */} + {dailyTasks.length > 0 && ( +
+
+
+

Daily Tasks

+
+
+ + + {`${dailyTasks.filter(task => { + const completions = (completedHabitsMap.get(today) || []) + .filter(h => h.id === task.id).length; + return completions >= (task.targetCompletions || 1); + }).length}/${dailyTasks.length} Completed`} + +
+
+
    + {dailyTasks + .sort((a, b) => { + // First by completion status + const aCompleted = todayCompletions.includes(a); + const bCompleted = todayCompletions.includes(b); + if (aCompleted !== bCompleted) { + return aCompleted ? 1 : -1; + } + + // Then by frequency (daily first) + const aFreq = getHabitFreq(a); + const bFreq = getHabitFreq(b); + const freqOrder = ['daily', 'weekly', 'monthly', 'yearly']; + if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) { + return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq); + } + + // Then by coin reward (higher first) + if (a.coinReward !== b.coinReward) { + return b.coinReward - a.coinReward; + } + + // Finally by target completions (higher first) + const aTarget = a.targetCompletions || 1; + const bTarget = b.targetCompletions || 1; + return bTarget - aTarget; + }) + .slice(0, expandedTasks ? undefined : 5) + .map((habit) => { + const completionsToday = habit.completions.filter(completion => + isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone })) + ).length + const target = habit.targetCompletions || 1 + const isCompleted = completionsToday >= target + return ( +
  • + + + +
    + +
    +
    + + + {habit.name} + + + + { + setPomo((prev) => ({ + ...prev, + show: true, + selectedHabitId: habit.id + })) + }}> + + Start Pomodoro + + +
    +
    + + {habit.targetCompletions && ( + + {completionsToday}/{target} + + )} + {getHabitFreq(habit) !== 'daily' && ( + + {getHabitFreq(habit)} + + )} + + + + {habit.coinReward} + + + +
  • + ) + })} +
+
+ + setBrowserSettings(prev => ({ ...prev, viewType: 'tasks' }))} + > + View + + +
+
+ )} + + {/* Habits Section */} + {dailyHabits.length > 0 && ( +
+
+
+

Daily Habits

+
+
+ + + {`${dailyHabits.filter(habit => { + const completions = (completedHabitsMap.get(today) || []) + .filter(h => h.id === habit.id).length; + return completions >= (habit.targetCompletions || 1); + }).length}/${dailyHabits.length} Completed`} + +
    {dailyHabits @@ -234,12 +447,14 @@ export default function DailyOverview({ setBrowserSettings(prev => ({ ...prev, viewType: 'habits' }))} > View
+ )}
@@ -339,6 +554,16 @@ export default function DailyOverview({
+ {isModalOpen && ( + setIsModalOpen(false)} + onSave={async (habit) => { + await saveHabit({ ...habit, isTask: isTaskModal }) + setIsModalOpen(false) + }} + habit={null} + /> + )} ) } diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index c1d17f4..86899ec 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -1,19 +1,18 @@ 'use client' import { useAtom } from 'jotai' -import { wishlistAtom, habitsAtom, settingsAtom, coinsAtom } from '@/lib/atoms' +import { wishlistAtom, habitsAtom, settingsAtom } from '@/lib/atoms' import DailyOverview from './DailyOverview' import HabitStreak from './HabitStreak' import CoinBalance from './CoinBalance' import { useHabits } from '@/hooks/useHabits' -import { ViewToggle } from './ViewToggle' +import { useCoins } from '@/hooks/useCoins' export default function Dashboard() { const [habitsData] = useAtom(habitsAtom) const habits = habitsData.habits const [settings] = useAtom(settingsAtom) - const [coins] = useAtom(coinsAtom) - const coinBalance = coins.balance + const { balance } = useCoins() const [wishlist] = useAtom(wishlistAtom) const wishlistItems = wishlist.items @@ -21,15 +20,14 @@ export default function Dashboard() {

Dashboard

-
- + {/* */} diff --git a/components/HabitItem.tsx b/components/HabitItem.tsx index b03d3df..ce57009 100644 --- a/components/HabitItem.tsx +++ b/components/HabitItem.tsx @@ -1,4 +1,4 @@ -import { Habit, SafeUser } from '@/lib/types' +import { Habit, SafeUser, User } from '@/lib/types' import { useAtom } from 'jotai' import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms' import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s } from '@/lib/utils' @@ -25,6 +25,26 @@ interface HabitItemProps { onDelete: () => void } +const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => { + if (!habit.userIds || habit.userIds.length <= 1) return null; + + return ( +
+ {habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => { + const user = usersData.users.find(u => u.id === userId) + if (!user) return null + return ( + + + {user.username[0]} + + ) + })} +
+ ); +}; + + export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { const { completeHabit, undoComplete, archiveHabit, unarchiveHabit } = useHabits() const [settings] = useAtom(settingsAtom) @@ -35,13 +55,12 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { const target = habit.targetCompletions || 1 const isCompletedToday = completionsToday >= target const [isHighlighted, setIsHighlighted] = useState(false) + const [usersData] = useAtom(usersAtom) + const { currentUser } = useHelpers() const [browserSettings] = useAtom(browserSettingsAtom) const isTasksView = browserSettings.viewType === 'tasks' const isRecurRule = !isTasksView - const [usersData] = useAtom(usersAtom) - const { currentUser } = useHelpers() - useEffect(() => { const params = new URLSearchParams(window.location.search) const highlightId = params.get('highlight') @@ -76,20 +95,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { )}
- {habit.userIds && habit.userIds.length > 1 && ( -
- {habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => { - const user = usersData.users.find(u => u.id === userId) - if (!user) return null - return ( - - - {user.username[0]} - - ) - })} -
- )} + {renderUserAvatars(habit, currentUser as User, usersData)}
diff --git a/components/HabitList.tsx b/components/HabitList.tsx index 6a44454..dfec470 100644 --- a/components/HabitList.tsx +++ b/components/HabitList.tsx @@ -12,6 +12,7 @@ import ConfirmDialog from './ConfirmDialog' import { Habit } from '@/lib/types' import { useHabits } from '@/hooks/useHabits' import { HabitIcon, TaskIcon } from '@/lib/constants' +import { ViewToggle } from './ViewToggle' export default function HabitList() { const { saveHabit, deleteHabit } = useHabits() @@ -42,6 +43,9 @@ export default function HabitList() { {isTasksView ? 'Add Task' : 'Add Habit'}
+
+ +
{activeHabits.length === 0 ? (
diff --git a/components/HabitStreak.tsx b/components/HabitStreak.tsx index 04e4947..4cf81b8 100644 --- a/components/HabitStreak.tsx +++ b/components/HabitStreak.tsx @@ -20,21 +20,27 @@ export default function HabitStreak({ habits }: HabitStreakProps) { }).reverse() const completions = dates.map(date => { - const completedCount = getCompletedHabitsForDate({ - habits, + const completedHabits = getCompletedHabitsForDate({ + habits: habits.filter(h => !h.isTask), date: t2d({ timestamp: date, timezone: settings.system.timezone }), timezone: settings.system.timezone - }).length; + }); + const completedTasks = getCompletedHabitsForDate({ + habits: habits.filter(h => h.isTask), + date: t2d({ timestamp: date, timezone: settings.system.timezone }), + timezone: settings.system.timezone + }); return { date, - completed: completedCount + habits: completedHabits.length, + tasks: completedTasks.length }; }); return ( - Daily Habit Completion Streak + Daily Completion Streak
@@ -51,14 +57,23 @@ export default function HabitStreak({ habits }: HabitStreakProps) { - [`${value} habits`, 'Completed']} /> + [`${value} ${name}`, 'Completed']} /> +
diff --git a/components/Header.tsx b/components/Header.tsx index 6608dc6..4108446 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -28,9 +28,8 @@ const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: fals export default function Header({ className }: HeaderProps) { const [settings] = useAtom(settingsAtom) - const [coins] = useAtom(coinsAtom) const [browserSettings] = useAtom(browserSettingsAtom) - const isTasksView = browserSettings.viewType === 'tasks' + const { balance } = useCoins() return ( <>
@@ -44,7 +43,7 @@ export default function Header({ className }: HeaderProps) {
diff --git a/components/UserForm.tsx b/components/UserForm.tsx index 4efee3a..6874d61 100644 --- a/components/UserForm.tsx +++ b/components/UserForm.tsx @@ -219,7 +219,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) )} - {currentUser && currentUser.isAdmin && 1 && diff --git a/components/WishlistItem.tsx b/components/WishlistItem.tsx index 210a627..f478bbe 100644 --- a/components/WishlistItem.tsx +++ b/components/WishlistItem.tsx @@ -1,4 +1,8 @@ -import { WishlistItemType } from '@/lib/types' +import { WishlistItemType, User } from '@/lib/types' +import { useAtom } from 'jotai' +import { usersAtom } from '@/lib/atoms' +import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' +import { useHelpers } from '@/lib/client-helpers' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import ReactMarkdown from 'react-markdown' import { Button } from '@/components/ui/button' @@ -24,6 +28,25 @@ interface WishlistItemProps { isArchived?: boolean } +const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => { + if (!item.userIds || item.userIds.length <= 1) return null; + + return ( +
+ {item.userIds?.filter((u) => u !== currentUser?.id).map(userId => { + const user = usersData.users.find(u => u.id === userId) + if (!user) return null + return ( + + + {user.username[0]} + + ) + })} +
+ ); +}; + export default function WishlistItem({ item, onEdit, @@ -35,6 +58,9 @@ export default function WishlistItem({ isHighlighted, isRecentlyRedeemed }: WishlistItemProps) { + const { currentUser } = useHelpers() + const [usersData] = useAtom(usersAtom) + return ( )}
- {item.description && ( - - {item.description} - - )} +
+
+ {item.description && ( + + {item.description} + + )} +
+ {renderUserAvatars(item, currentUser as User, usersData)} +
diff --git a/hooks/useCoins.tsx b/hooks/useCoins.tsx index ea7a1e8..d559633 100644 --- a/hooks/useCoins.tsx +++ b/hooks/useCoins.tsx @@ -6,7 +6,8 @@ import { totalEarnedAtom, totalSpentAtom, coinsSpentTodayAtom, - transactionsTodayAtom + transactionsTodayAtom, + coinsBalanceAtom } from '@/lib/atoms' import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data' import { CoinsData } from '@/lib/types' @@ -47,6 +48,7 @@ export function useCoins() { const [totalSpent] = useAtom(totalSpentAtom) const [coinsSpentToday] = useAtom(coinsSpentTodayAtom) const [transactionsToday] = useAtom(transactionsTodayAtom) + const [balance] = useAtom(coinsBalanceAtom) const add = async (amount: number, description: string, note?: string) => { if (!handlePermissionCheck(user, 'coins', 'write')) return null @@ -125,7 +127,7 @@ export function useCoins() { add, remove, updateNote, - balance: coins.balance, + balance, transactions: coins.transactions, coinsEarnedToday, totalEarned, diff --git a/lib/atoms.ts b/lib/atoms.ts index b67454b..76f4da8 100644 --- a/lib/atoms.ts +++ b/lib/atoms.ts @@ -69,6 +69,12 @@ export const transactionsTodayAtom = atom((get) => { return calculateTransactionsToday(coins.transactions, settings.system.timezone); }); +// Derived atom for current balance from all transactions +export const coinsBalanceAtom = atom((get) => { + const coins = get(coinsAtom); + return coins.transactions.reduce((sum, transaction) => sum + transaction.amount, 0); +}); + /* transient atoms */ interface PomodoroAtom { show: boolean diff --git a/lib/client-helpers.ts b/lib/client-helpers.ts index 7679fb4..35f0454 100644 --- a/lib/client-helpers.ts +++ b/lib/client-helpers.ts @@ -8,13 +8,13 @@ import { usersAtom } from './atoms' export function useHelpers() { const { data: session, status } = useSession() - const currentUser = session?.user + const currentUserId = session?.user.id const [usersData] = useAtom(usersAtom) - const currentUserData = usersData.users.find((u) => u.id === currentUser?.id) + const currentUser = usersData.users.find((u) => u.id === currentUserId) return { + currentUserId, currentUser, - currentUserData, usersData, status } diff --git a/lib/types.ts b/lib/types.ts index cba4550..b25be2b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -18,8 +18,11 @@ export type Permission = { } } -export type SafeUser = { +export type SessionUser = { id: UserId +} + +export type SafeUser = SessionUser & { username: string avatarPath?: string permissions?: Permission[] From 279a124eb965b405c5c56e7ebeb9f564da818e0d Mon Sep 17 00:00:00 2001 From: dohsimpson Date: Sun, 9 Feb 2025 08:58:44 -0500 Subject: [PATCH 3/8] fix uuid --- CHANGELOG.md | 2 ++ app/actions/data.ts | 10 ++++++---- app/debug/user/page.tsx | 2 +- components/AboutModal.tsx | 1 - lib/server-helpers.ts | 15 +++++++++++++++ lib/types.ts | 5 +++-- lib/utils.test.ts | 29 ++++++++++++++++++++++++++++- lib/utils.ts | 25 ++++++------------------- package-lock.json | 18 ++++++++++++++++-- package.json | 1 + 10 files changed, 78 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c220c9c..71efed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - sharing wishlist with other users - disable permission edit if only has 1 user - always show edit button in user switch modal +- move crypto utils to server-helpers +- use uuid package for client-compatible generator ### BREAKING CHANGE diff --git a/app/actions/data.ts b/app/actions/data.ts index 730e89f..c4262c2 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -21,7 +21,9 @@ import { getDefaultCoinsData, Permission } from '@/lib/types' -import { d2t, deepMerge, getNow, saltAndHashPassword, verifyPassword, checkPermission } from '@/lib/utils'; +import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils'; +import { verifyPassword } from "@/lib/server-helpers"; +import { saltAndHashPassword } from "@/lib/server-helpers"; import { signInSchema } from '@/lib/zod'; import { auth } from '@/auth'; import _ from 'lodash'; @@ -226,7 +228,7 @@ export async function addCoins({ await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact') const data = await loadCoinsData() const newTransaction: CoinTransaction = { - id: crypto.randomUUID(), + id: uuid(), amount, type, description, @@ -277,7 +279,7 @@ export async function removeCoins({ await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact') const data = await loadCoinsData() const newTransaction: CoinTransaction = { - id: crypto.randomUUID(), + id: uuid(), amount: -amount, type, description, @@ -384,7 +386,7 @@ export async function createUser(formData: FormData): Promise { } const newUser: User = { - id: crypto.randomUUID(), + id: uuid(), username, password: hashedPassword, permissions, diff --git a/app/debug/user/page.tsx b/app/debug/user/page.tsx index 8fccb3f..509f322 100644 --- a/app/debug/user/page.tsx +++ b/app/debug/user/page.tsx @@ -1,4 +1,4 @@ -import { saltAndHashPassword } from '@/lib/utils'; +import { saltAndHashPassword } from "@/lib/server-helpers"; export default function DebugPage() { const password = 'admin'; diff --git a/components/AboutModal.tsx b/components/AboutModal.tsx index 41cf800..a582bd9 100644 --- a/components/AboutModal.tsx +++ b/components/AboutModal.tsx @@ -8,7 +8,6 @@ import { DialogTitle } from "@radix-ui/react-dialog" import { Logo } from "./Logo" import ChangelogModal from "./ChangelogModal" import { useState } from "react" -import { saltAndHashPassword } from "@/lib/utils" interface AboutModalProps { isOpen: boolean diff --git a/lib/server-helpers.ts b/lib/server-helpers.ts index 9e0c2b5..b4e3eae 100644 --- a/lib/server-helpers.ts +++ b/lib/server-helpers.ts @@ -2,6 +2,7 @@ import { auth } from '@/auth' import 'server-only' import { User, UserId } from './types' import { loadUsersData } from '@/app/actions/data' +import { randomBytes, scryptSync } from 'crypto' export async function getCurrentUserId(): Promise { const session = await auth() @@ -16,4 +17,18 @@ export async function getCurrentUser(): Promise { } const usersData = await loadUsersData() return usersData.users.find((u) => u.id === currentUserId) +} +export function saltAndHashPassword(password: string, salt?: string): string { + salt = salt || randomBytes(16).toString('hex') + const hash = scryptSync(password, salt, 64).toString('hex') + return `${salt}:${hash}` +} + +export function verifyPassword(password: string, storedHash: string): boolean { + // Split the stored hash into its salt and hash components + const [salt, hash] = storedHash.split(':') + // Hash the input password with the same salt + const newHash = saltAndHashPassword(password, salt).split(':')[1] + // Compare the new hash with the stored hash + return newHash === hash } \ No newline at end of file diff --git a/lib/types.ts b/lib/types.ts index b25be2b..58a41aa 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,5 +1,6 @@ import { DEFAULT_ADMIN_PASS_HASH } from "./constants" -import { saltAndHashPassword } from "./utils" +import { saltAndHashPassword } from "./server-helpers" +import { uuid } from "./utils" export type UserId = string @@ -97,7 +98,7 @@ export interface WishlistData { export const getDefaultUsersData = (): UserData => ({ users: [ { - id: crypto.randomUUID(), + id: uuid(), username: 'admin', password: DEFAULT_ADMIN_PASS_HASH, isAdmin: true, diff --git a/lib/utils.test.ts b/lib/utils.test.ts index 3267ea3..858fe7b 100644 --- a/lib/utils.test.ts +++ b/lib/utils.test.ts @@ -15,7 +15,8 @@ import { calculateTotalSpent, calculateCoinsSpentToday, isHabitDueToday, - isHabitDue + isHabitDue, + uuid } from './utils' import { CoinTransaction } from './types' import { DateTime } from "luxon"; @@ -31,6 +32,32 @@ describe('cn utility', () => { }) }) +describe('uuid', () => { + test('should generate valid UUIDs', () => { + const id = uuid() + // UUID v4 format: 8-4-4-4-12 hex digits + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + }) + + test('should generate unique UUIDs', () => { + const ids = new Set() + for (let i = 0; i < 1000; i++) { + ids.add(uuid()) + } + // All 1000 UUIDs should be unique + expect(ids.size).toBe(1000) + }) + + test('should generate v4 UUIDs', () => { + const id = uuid() + // Version 4 UUID has specific bits set: + // - 13th character is '4' + // - 17th character is '8', '9', 'a', or 'b' + expect(id.charAt(14)).toBe('4') + expect('89ab').toContain(id.charAt(19)) + }) +}) + describe('datetime utilities', () => { let fixedNow: DateTime; let currentDateIndex = 0; diff --git a/lib/utils.ts b/lib/utils.ts index 865699f..172e103 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,9 +4,9 @@ import { DateTime, DateTimeFormatOptions } from "luxon" import { datetime, RRule } from 'rrule' import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types' import { DUE_MAP, RECURRENCE_RULE_MAP } from "./constants" -import * as chrono from 'chrono-node'; -import { randomBytes, scryptSync } from "crypto" +import * as chrono from 'chrono-node' import _ from "lodash" +import { v4 as uuidv4 } from 'uuid' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -300,23 +300,6 @@ export const openWindow = (url: string): boolean => { return true } -export function saltAndHashPassword(password: string, salt?: string): string { - salt = salt || randomBytes(16).toString('hex'); - const hash = scryptSync(password, salt, 64).toString('hex'); - return `${salt}:${hash}`; -} - -export function verifyPassword(password: string, storedHash: string): boolean { - // Split the stored hash into its salt and hash components - const [salt, hash] = storedHash.split(':'); - - // Hash the input password with the same salt - const newHash = saltAndHashPassword(password, salt).split(':')[1]; - - // Compare the new hash with the stored hash - return newHash === hash; -} - export function deepMerge(a: T, b: T) { return _.merge(a, b, (x: unknown, y: unknown) => { if (_.isArray(a)) { @@ -345,3 +328,7 @@ export function checkPermission( } }) } + +export function uuid() { + return uuidv4() +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index aee228a..93d770c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "habittrove", - "version": "0.1.30", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "habittrove", - "version": "0.1.30", + "version": "0.2.0", "dependencies": { "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", @@ -48,6 +48,7 @@ "rrule": "^2.8.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "uuid": "^11.0.5", "web-push": "^3.6.7", "zod": "^3.24.1" }, @@ -8805,6 +8806,19 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index 74ed51c..175ff31 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "rrule": "^2.8.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "uuid": "^11.0.5", "web-push": "^3.6.7", "zod": "^3.24.1" }, From 829bca42d5675a594eec536860dab9d64e6aea25 Mon Sep 17 00:00:00 2001 From: dohsimpson Date: Sun, 9 Feb 2025 09:34:02 -0500 Subject: [PATCH 4/8] smarter task views --- CHANGELOG.md | 1 + components/AddEditHabitModal.tsx | 13 ++- components/DailyOverview.tsx | 95 ++++++++++++++++---- components/HabitCalendar.tsx | 143 +++++++++++++++++++++++++++---- components/HabitList.tsx | 23 +++-- components/HabitStreak.tsx | 21 +++-- lib/atoms.ts | 6 ++ 7 files changed, 244 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71efed2..9b13b2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - always show edit button in user switch modal - move crypto utils to server-helpers - use uuid package for client-compatible generator +- fix add button for tasks and habits in daily overview ### BREAKING CHANGE diff --git a/components/AddEditHabitModal.tsx b/components/AddEditHabitModal.tsx index c200ae9..9196b54 100644 --- a/components/AddEditHabitModal.tsx +++ b/components/AddEditHabitModal.tsx @@ -33,17 +33,16 @@ interface AddEditHabitModalProps { onClose: () => void onSave: (habit: Omit) => Promise habit?: Habit | null + isTask: boolean } -export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHabitModalProps) { +export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: AddEditHabitModalProps) { const [settings] = useAtom(settingsAtom) - const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom) - const isTasksView = browserSettings.viewType === 'tasks' const [name, setName] = useState(habit?.name || '') const [description, setDescription] = useState(habit?.description || '') const [coinReward, setCoinReward] = useState(habit?.coinReward || 1) const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1) - const isRecurRule = !isTasksView + const isRecurRule = !isTask const origRuleText = isRecurRule ? parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText() : habit?.frequency || INITIAL_DUE const [ruleText, setRuleText] = useState(origRuleText) const now = getNow({ timezone: settings.system.timezone }) @@ -61,7 +60,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab targetCompletions: targetCompletions > 1 ? targetCompletions : undefined, completions: habit?.completions || [], frequency: isRecurRule ? serializeRRule(parseNaturalLanguageRRule(ruleText)) : d2t({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }) }), - isTask: isTasksView ? true : undefined, + isTask: isTask || undefined, userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]) }) } @@ -70,7 +69,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab - {habit ? `Edit ${isTasksView ? 'Task' : 'Habit'}` : `Add New ${isTasksView ? 'Task' : 'Habit'}`} + {habit ? `Edit ${isTask ? 'Task' : 'Habit'}` : `Add New ${isTask ? 'Task' : 'Habit'}`}
@@ -265,7 +264,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab )}
- +
diff --git a/components/DailyOverview.tsx b/components/DailyOverview.tsx index 2dd82a3..9c38a76 100644 --- a/components/DailyOverview.tsx +++ b/components/DailyOverview.tsx @@ -9,7 +9,7 @@ import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils' import Link from 'next/link' import { useState, useEffect } from 'react' import { useAtom } from 'jotai' -import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings } from '@/lib/atoms' +import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom } from '@/lib/atoms' import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' @@ -43,19 +43,25 @@ export default function DailyOverview({ const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom) useEffect(() => { - // Filter habits and tasks that are due today based on their recurrence rule + // Filter habits and tasks that are due today and not archived const filteredHabits = habits.filter(habit => - !habit.isTask && isHabitDueToday({ habit, timezone: settings.system.timezone }) + !habit.isTask && + !habit.archived && + isHabitDueToday({ habit, timezone: settings.system.timezone }) ) const filteredTasks = habits.filter(habit => - habit.isTask && isHabitDueToday({ habit, timezone: settings.system.timezone }) + habit.isTask && + !habit.archived && + isHabitDueToday({ habit, timezone: settings.system.timezone }) ) setDailyHabits(filteredHabits) setDailyTasks(filteredTasks) }, [habits]) // Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost + // Filter out archived wishlist items const sortedWishlistItems = wishlistItems + .filter(item => !item.archived) .sort((a, b) => { const aRedeemable = a.coinCost <= coinBalance const bRedeemable = b.coinCost <= coinBalance @@ -72,9 +78,15 @@ export default function DailyOverview({ const [expandedHabits, setExpandedHabits] = useState(false) const [expandedTasks, setExpandedTasks] = useState(false) const [expandedWishlist, setExpandedWishlist] = useState(false) + const [hasTasks] = useAtom(hasTasksAtom) const [_, setPomo] = useAtom(pomodoroAtom) - const [isModalOpen, setIsModalOpen] = useState(false) - const [isTaskModal, setIsTaskModal] = useState(false) + const [modalConfig, setModalConfig] = useState<{ + isOpen: boolean, + isTask: boolean + }>({ + isOpen: false, + isTask: false + }); return ( <> @@ -85,7 +97,30 @@ export default function DailyOverview({
{/* Tasks Section */} - {dailyTasks.length > 0 && ( + {hasTasks && dailyTasks.length === 0 ? ( +
+
+

Daily Tasks

+ +
+
+ No tasks due today. Add some tasks to get started! +
+
+ ) : hasTasks && (
@@ -97,8 +132,10 @@ export default function DailyOverview({ size="sm" className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary" onClick={() => { - setIsTaskModal(true) - setIsModalOpen(true) + setModalConfig({ + isOpen: true, + isTask: true + }); }} > @@ -271,7 +308,30 @@ export default function DailyOverview({ )} {/* Habits Section */} - {dailyHabits.length > 0 && ( + {dailyHabits.length === 0 ? ( +
+
+

Daily Habits

+ +
+
+ No habits due today. Add some habits to get started! +
+
+ ) : (
@@ -283,8 +343,10 @@ export default function DailyOverview({ size="sm" className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary" onClick={() => { - setIsTaskModal(false) - setIsModalOpen(true) + setModalConfig({ + isOpen: true, + isTask: false + }); }} > @@ -554,14 +616,15 @@ export default function DailyOverview({
- {isModalOpen && ( + {modalConfig.isOpen && ( setIsModalOpen(false)} + onClose={() => setModalConfig({ isOpen: false, isTask: false })} onSave={async (habit) => { - await saveHabit({ ...habit, isTask: isTaskModal }) - setIsModalOpen(false) + await saveHabit({ ...habit, isTask: modalConfig.isTask }) + setModalConfig({ isOpen: false, isTask: false }); }} habit={null} + isTask={modalConfig.isTask} /> )} diff --git a/components/HabitCalendar.tsx b/components/HabitCalendar.tsx index c19f95e..418e709 100644 --- a/components/HabitCalendar.tsx +++ b/components/HabitCalendar.tsx @@ -9,7 +9,7 @@ import { Check, Circle, CircleCheck } from 'lucide-react' import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils' import { useAtom } from 'jotai' import { useHabits } from '@/hooks/useHabits' -import { habitsAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms' +import { habitsAtom, settingsAtom, completedHabitsMapAtom, hasTasksAtom } from '@/lib/atoms' import { DateTime } from 'luxon' import Linkify from './linkify' import { Habit } from '@/lib/types' @@ -27,6 +27,7 @@ export default function HabitCalendar() { const [settings] = useAtom(settingsAtom) const [selectedDate, setSelectedDate] = useState(getNow({ timezone: settings.system.timezone })) const [habitsData] = useAtom(habitsAtom) + const [hasTasks] = useAtom(hasTasksAtom) const habits = habitsData.habits const [completedHabitsMap] = useAtom(completedHabitsMapAtom) @@ -39,9 +40,9 @@ export default function HabitCalendar() { }, [completedHabitsMap, settings.system.timezone]) return ( -
-

Habit Calendar

-
+
+

Habit Calendar

+
Calendar @@ -62,7 +63,7 @@ export default function HabitCalendar() { ) }} modifiersClassNames={{ - completed: 'bg-green-100 text-green-800 font-bold', + completed: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 font-medium rounded-md', }} /> @@ -71,7 +72,7 @@ export default function HabitCalendar() { {selectedDate ? ( - <>Habits for {d2s({ dateTime: selectedDate, timezone: settings.system.timezone, format: "yyyy-MM-dd" })} + <>{d2s({ dateTime: selectedDate, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })} ) : ( 'Select a date' )} @@ -79,20 +80,124 @@ export default function HabitCalendar() { {selectedDate && ( -
    - {habits - .filter(habit => isHabitDue({ - habit, - timezone: settings.system.timezone, - date: selectedDate - })) - .map((habit) => { +
    + {hasTasks && ( +
    +
    +

    Tasks

    + + {`${habits.filter(habit => { + if (habit.isTask && isHabitDue({ + habit, + timezone: settings.system.timezone, + date: selectedDate + })) { + const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone }) + return completions >= (habit.targetCompletions || 1) + } + return false + }).length}/${habits.filter(habit => habit.isTask && isHabitDue({ + habit, + timezone: settings.system.timezone, + date: selectedDate + })).length} Completed`} + +
    +
      + {habits + .filter(habit => habit.isTask && !habit.archived && isHabitDue({ + habit, + timezone: settings.system.timezone, + date: selectedDate + })) + .map((habit) => { + const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone }) + const isCompleted = completions >= (habit.targetCompletions || 1) + return ( +
    • + + {habit.name} + {habit.archived && ( + Archived + )} + +
      +
      + {habit.targetCompletions && ( + + {completions}/{habit.targetCompletions} + + )} + +
      +
      +
    • + ) + })} +
    +
    + )} +
    +
    +

    Habits

    + + {`${habits.filter(habit => { + if (!habit.isTask && !habit.archived && isHabitDue({ + habit, + timezone: settings.system.timezone, + date: selectedDate + })) { + const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone }) + return completions >= (habit.targetCompletions || 1) + } + return false + }).length}/${habits.filter(habit => !habit.isTask && isHabitDue({ + habit, + timezone: settings.system.timezone, + date: selectedDate + })).length} Completed`} + +
    +
      + {habits + .filter(habit => !habit.isTask && !habit.archived && isHabitDue({ + habit, + timezone: settings.system.timezone, + date: selectedDate + })) + .map((habit) => { const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone }) const isCompleted = completions >= (habit.targetCompletions || 1) return ( -
    • - +
    • + {habit.name} + {habit.archived && ( + Archived + )}
      @@ -129,8 +234,10 @@ export default function HabitCalendar() {
    • ) - })} -
    + })} +
+
+
)} diff --git a/components/HabitList.tsx b/components/HabitList.tsx index dfec470..cccee52 100644 --- a/components/HabitList.tsx +++ b/components/HabitList.tsx @@ -25,7 +25,13 @@ export default function HabitList() { const activeHabits = habits.filter(h => !h.archived) const archivedHabits = habits.filter(h => h.archived) const [settings] = useAtom(settingsAtom) - const [isModalOpen, setIsModalOpen] = useState(false) + const [modalConfig, setModalConfig] = useState<{ + isOpen: boolean, + isTask: boolean + }>({ + isOpen: false, + isTask: false + }) const [editingHabit, setEditingHabit] = useState(null) const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, habitId: string | null }>({ isOpen: false, @@ -39,7 +45,7 @@ export default function HabitList() {

{isTasksView ? 'My Tasks' : 'My Habits'}

-
@@ -62,7 +68,7 @@ export default function HabitList() { habit={habit} onEdit={() => { setEditingHabit(habit) - setIsModalOpen(true) + setModalConfig({ isOpen: true, isTask: isTasksView }) }} onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })} /> @@ -82,7 +88,7 @@ export default function HabitList() { habit={habit} onEdit={() => { setEditingHabit(habit) - setIsModalOpen(true) + setModalConfig({ isOpen: true, isTask: isTasksView }) }} onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })} /> @@ -90,18 +96,19 @@ export default function HabitList() { )}
- {isModalOpen && + {modalConfig.isOpen && { - setIsModalOpen(false) + setModalConfig({ isOpen: false, isTask: false }) setEditingHabit(null) }} onSave={async (habit) => { - await saveHabit({ ...habit, id: editingHabit?.id }) - setIsModalOpen(false) + await saveHabit({ ...habit, id: editingHabit?.id, isTask: modalConfig.isTask }) + setModalConfig({ isOpen: false, isTask: false }) setEditingHabit(null) }} habit={editingHabit} + isTask={modalConfig.isTask} /> } { const d = getNow({ timezone: settings.system.timezone }); @@ -66,14 +67,16 @@ export default function HabitStreak({ habits }: HabitStreakProps) { strokeWidth={2} dot={false} /> - + {hasTasks && ( + + )}
diff --git a/lib/atoms.ts b/lib/atoms.ts index 76f4da8..fe53097 100644 --- a/lib/atoms.ts +++ b/lib/atoms.ts @@ -139,3 +139,9 @@ export const pomodoroTodayCompletionsAtom = atom((get) => { timezone: settings.system.timezone }) }) + +// Derived atom to check if any habits are tasks +export const hasTasksAtom = atom((get) => { + const habits = get(habitsAtom) + return habits.habits.some(habit => habit.isTask === true) +}) From da7e6bacbc7e4d3e7769f77729ddace93d1229a8 Mon Sep 17 00:00:00 2001 From: dohsimpson Date: Wed, 12 Feb 2025 22:47:38 -0500 Subject: [PATCH 5/8] lots of fixes --- CHANGELOG.md | 9 ++ app/actions/data.ts | 16 ++-- components/AddEditHabitModal.tsx | 57 +++++++++--- components/DailyOverview.tsx | 31 ++++--- components/HabitCalendar.tsx | 12 +-- components/HabitItem.tsx | 62 ++++++++----- components/HabitList.tsx | 22 ++--- components/PasswordEntryForm.tsx | 4 +- components/PermissionSelector.tsx | 67 +++++++------- components/UserCreateForm.tsx | 140 ------------------------------ components/UserForm.tsx | 75 ++++++++++++---- components/WishlistItem.tsx | 14 +-- hooks/useTasks.tsx | 0 hooks/useWishlist.tsx | 4 +- lib/client-helpers.ts | 7 +- lib/constants.ts | 14 ++- lib/server-helpers.ts | 8 +- lib/utils.test.ts | 58 ++++++++++++- lib/utils.ts | 15 +++- lib/zod.ts | 26 +++--- 20 files changed, 354 insertions(+), 287 deletions(-) delete mode 100644 components/UserCreateForm.tsx delete mode 100644 hooks/useTasks.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b13b2c..fdeb49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ * Sharing habits and wishlist items with other users * show both tasks and habits in dashboard (#58) * show tasks in completion streak (#57) +* show badge for overdue tasks +* shortcut for selecting date for tasks +* context menu shortcut to move task to today ### Changed @@ -20,6 +23,12 @@ - move crypto utils to server-helpers - use uuid package for client-compatible generator - fix add button for tasks and habits in daily overview +- better error message for user creation via frontend validation +- allow empty password +- better layout for permission editor +- disable buttons when doesn't have permissions +- disable redeem if user has no more coins +- fix task completed in the past still show up as uncompleted if due today ### BREAKING CHANGE diff --git a/app/actions/data.ts b/app/actions/data.ts index c4262c2..e502d49 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -184,7 +184,7 @@ export async function loadCoinsData(): Promise { const data = await loadData('coins') return { ...data, - transactions: data.transactions.filter(x => user.isAdmin || x.userId === user.id) + transactions: data.transactions.filter(x => x.userId === user.id) } } catch { return getDefaultCoinsData() @@ -341,10 +341,16 @@ export async function loadUsersData(): Promise { } export async function saveUsersData(data: UserData): Promise { + if (process.env.DEMO === 'true') { + // remove password for all users + data.users.map(user => { + user.password = '' + }) + } return saveData('auth', data) } -export async function getUser(username: string, plainTextPassword: string): Promise { +export async function getUser(username: string, plainTextPassword?: string): Promise { const data = await loadUsersData() const user = data.users.find(user => user.username === username) @@ -375,7 +381,7 @@ export async function createUser(formData: FormData): Promise { throw new Error('Username already exists'); } - const hashedPassword = saltAndHashPassword(password); + const hashedPassword = password ? saltAndHashPassword(password) : ''; // Handle avatar upload if present let avatarPath: string | undefined; @@ -437,7 +443,7 @@ export async function updateUser(userId: string, updates: Partial { +export async function updateUserPassword(userId: string, newPassword?: string): Promise { const data = await loadUsersData() const userIndex = data.users.findIndex(user => user.id === userId) @@ -445,7 +451,7 @@ export async function updateUserPassword(userId: string, newPassword: string): P throw new Error('User not found') } - const hashedPassword = saltAndHashPassword(newPassword) + const hashedPassword = newPassword ? saltAndHashPassword(newPassword) : '' const updatedUser = { ...data.users[userIndex], diff --git a/components/AddEditHabitModal.tsx b/components/AddEditHabitModal.tsx index 9196b54..cc48a5a 100644 --- a/components/AddEditHabitModal.tsx +++ b/components/AddEditHabitModal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState } from 'react' import { RRule, RRuleSet, rrulestr } from 'rrule' import { useAtom } from 'jotai' import { settingsAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms' @@ -11,13 +11,13 @@ import { Switch } from '@/components/ui/switch' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' -import { Info, SmilePlus } from 'lucide-react' +import { Info, SmilePlus, Zap } from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import data from '@emoji-mart/data' import Picker from '@emoji-mart/react' import { Habit, SafeUser } from '@/lib/types' import { d2s, d2t, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils' -import { INITIAL_DUE, INITIAL_RECURRENCE_RULE } from '@/lib/constants' +import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES } from '@/lib/constants' import * as chrono from 'chrono-node'; import { DateTime } from 'luxon' import { @@ -47,8 +47,9 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad const [ruleText, setRuleText] = useState(origRuleText) const now = getNow({ timezone: settings.system.timezone }) const { currentUser } = useHelpers() + const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false) const [selectedUserIds, setSelectedUserIds] = useState((habit?.userIds || []).filter(id => id !== currentUser?.id)) - const [usersData]= useAtom(usersAtom) + const [usersData] = useAtom(usersAtom) const users = usersData.users const handleSubmit = async (e: React.FormEvent) => { @@ -129,13 +130,47 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad When *
- setRuleText(e.target.value)} - required - // placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'" - /> +
+ setRuleText(e.target.value)} + required + /> + {isTask && ( + + + + + +
+
+ {QUICK_DATES.map((date) => ( + + ))} +
+
+
+
+ )} +
diff --git a/components/DailyOverview.tsx b/components/DailyOverview.tsx index 9c38a76..b8bef36 100644 --- a/components/DailyOverview.tsx +++ b/components/DailyOverview.tsx @@ -51,7 +51,6 @@ export default function DailyOverview({ ) const filteredTasks = habits.filter(habit => habit.isTask && - !habit.archived && isHabitDueToday({ habit, timezone: settings.system.timezone }) ) setDailyHabits(filteredHabits) @@ -127,6 +126,13 @@ export default function DailyOverview({

Daily Tasks

+ + {`${dailyTasks.filter(task => { + const completions = (completedHabitsMap.get(today) || []) + .filter(h => h.id === task.id).length; + return completions >= (task.targetCompletions || 1); + }).length}/${dailyTasks.length} Completed`} + - - {`${dailyTasks.filter(task => { - const completions = (completedHabitsMap.get(today) || []) - .filter(h => h.id === task.id).length; - return completions >= (task.targetCompletions || 1); - }).length}/${dailyTasks.length} Completed`} -
    @@ -184,7 +183,7 @@ export default function DailyOverview({ isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone })) ).length const target = habit.targetCompletions || 1 - const isCompleted = completionsToday >= target + const isCompleted = completionsToday >= target || (habit.isTask && habit.archived) return (
  • Daily Habits
+ + {`${dailyHabits.filter(habit => { + const completions = (completedHabitsMap.get(today) || []) + .filter(h => h.id === habit.id).length; + return completions >= (habit.targetCompletions || 1); + }).length}/${dailyHabits.length} Completed`} + - - {`${dailyHabits.filter(habit => { - const completions = (completedHabitsMap.get(today) || []) - .filter(h => h.id === habit.id).length; - return completions >= (habit.targetCompletions || 1); - }).length}/${dailyHabits.length} Completed`} -
    diff --git a/components/HabitCalendar.tsx b/components/HabitCalendar.tsx index 418e709..1f89654 100644 --- a/components/HabitCalendar.tsx +++ b/components/HabitCalendar.tsx @@ -105,7 +105,7 @@ export default function HabitCalendar() {
    {habits - .filter(habit => habit.isTask && !habit.archived && isHabitDue({ + .filter(habit => habit.isTask && isHabitDue({ habit, timezone: settings.system.timezone, date: selectedDate @@ -117,9 +117,6 @@ export default function HabitCalendar() {
  • {habit.name} - {habit.archived && ( - Archived - )}
    @@ -165,7 +162,7 @@ export default function HabitCalendar() {

    Habits

    {`${habits.filter(habit => { - if (!habit.isTask && !habit.archived && isHabitDue({ + if (!habit.isTask && isHabitDue({ habit, timezone: settings.system.timezone, date: selectedDate @@ -183,7 +180,7 @@ export default function HabitCalendar() {
      {habits - .filter(habit => !habit.isTask && !habit.archived && isHabitDue({ + .filter(habit => !habit.isTask && isHabitDue({ habit, timezone: settings.system.timezone, date: selectedDate @@ -195,9 +192,6 @@ export default function HabitCalendar() {
    • {habit.name} - {habit.archived && ( - Archived - )}
      diff --git a/components/HabitItem.tsx b/components/HabitItem.tsx index ce57009..051dba5 100644 --- a/components/HabitItem.tsx +++ b/components/HabitItem.tsx @@ -1,10 +1,10 @@ -import { Habit, SafeUser, User } from '@/lib/types' +import { Habit, SafeUser, User, Permission } from '@/lib/types' import { useAtom } from 'jotai' import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms' -import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s } from '@/lib/utils' +import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s, getCompletionsForToday, isTaskOverdue } from '@/lib/utils' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore } from 'lucide-react' +import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -46,17 +46,17 @@ const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { - const { completeHabit, undoComplete, archiveHabit, unarchiveHabit } = useHabits() + const { completeHabit, undoComplete, archiveHabit, unarchiveHabit, saveHabit } = useHabits() const [settings] = useAtom(settingsAtom) const [_, setPomo] = useAtom(pomodoroAtom) - const completionsToday = habit.completions?.filter(completion => - isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone })) - ).length || 0 + const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone }) const target = habit.targetCompletions || 1 const isCompletedToday = completionsToday >= target const [isHighlighted, setIsHighlighted] = useState(false) const [usersData] = useAtom(usersAtom) - const { currentUser } = useHelpers() + const { currentUser, hasPermission } = useHelpers() + const canWrite = hasPermission('habit', 'write') + const canInteract = hasPermission('habit', 'interact') const [browserSettings] = useAtom(browserSettingsAtom) const isTasksView = browserSettings.viewType === 'tasks' const isRecurRule = !isTasksView @@ -86,17 +86,22 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''} ${habit.archived ? 'opacity-75' : ''}`} > - {habit.name} -
      -
      - {habit.description && ( - - {habit.description} - +
      + + {habit.name} + {isTaskOverdue(habit, settings.system.timezone) && ( + + Overdue + )} -
      + {renderUserAvatars(habit, currentUser as User, usersData)}
      + {habit.description && ( + + {habit.description} + + )}

      When: {isRecurRule ? parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText() : d2s({ dateTime: t2d({ timestamp: habit.frequency, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}

      @@ -112,7 +117,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { variant={isCompletedToday ? "secondary" : "default"} size="sm" onClick={async () => await completeHabit(habit)} - disabled={habit.archived || (isCompletedToday && completionsToday >= target)} + disabled={!canInteract || habit.archived || (isCompletedToday && completionsToday >= target)} className={`overflow-hidden w-24 sm:w-auto ${habit.archived ? 'cursor-not-allowed' : ''}`} > @@ -150,6 +155,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { variant="outline" size="sm" onClick={async () => await undoComplete(habit)} + disabled={!canWrite} className="w-10 sm:w-auto" > @@ -163,6 +169,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { variant="edit" size="sm" onClick={onEdit} + disabled={!canWrite} className="hidden sm:flex" > @@ -178,6 +185,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { {!habit.archived && ( { + if (!canInteract) return setPomo((prev) => ({ ...prev, show: true, @@ -189,13 +197,23 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { )} {!habit.archived && ( - archiveHabit(habit.id)}> - - Archive - + <> + {habit.isTask && ( + { + saveHabit({...habit, frequency: d2t({ dateTime: getNow({ timezone: settings.system.timezone })})}) + }}> + + Move to Today + + )} + archiveHabit(habit.id)}> + + Archive + + )} {habit.archived && ( - unarchiveHabit(habit.id)}> + unarchiveHabit(habit.id)}> Unarchive diff --git a/components/HabitList.tsx b/components/HabitList.tsx index cccee52..07a60a7 100644 --- a/components/HabitList.tsx +++ b/components/HabitList.tsx @@ -41,17 +41,17 @@ export default function HabitList() { return (
      -
      -

      - {isTasksView ? 'My Tasks' : 'My Habits'} -

      - -
      -
      - -
      +
      +

      + {isTasksView ? 'My Tasks' : 'My Habits'} +

      + +
      +
      + +
      {activeHabits.length === 0 ? (
      diff --git a/components/PasswordEntryForm.tsx b/components/PasswordEntryForm.tsx index cea44e1..15fe074 100644 --- a/components/PasswordEntryForm.tsx +++ b/components/PasswordEntryForm.tsx @@ -24,8 +24,8 @@ export default function PasswordEntryForm({ onSubmit, error }: PasswordEntryFormProps) { - const hasPassword = user.password !== DEFAULT_ADMIN_PASS_HASH; - const [password, setPassword] = useState(hasPassword ? '' : DEFAULT_ADMIN_PASS); + const hasPassword = !!user.password; + const [password, setPassword] = useState(''); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/components/PermissionSelector.tsx b/components/PermissionSelector.tsx index e58d1c9..9b21ad2 100644 --- a/components/PermissionSelector.tsx +++ b/components/PermissionSelector.tsx @@ -11,6 +11,12 @@ interface PermissionSelectorProps { onAdminChange: (isAdmin: boolean) => void; } +const permissionLabels: { [key: string]: string } = { + habit: 'Habit / Task', + wishlist: 'Wishlist', + coins: 'Coins' +}; + export function PermissionSelector({ permissions, isAdmin, @@ -42,57 +48,60 @@ export function PermissionSelector({ return (
      -
      - - -
      +
      + +
      +
      +
      +
      Admin Access
      +
      + +
      - {isAdmin ? ( -

      - Admins have full write and interact permission to all data. -

      - ) : -
      -
      - -
      + {isAdmin ? ( +

      + Admins have full permission to all data for all users +

      + ) : ( +
      {['habit', 'wishlist', 'coins'].map((resource) => ( -
      -
      {resource}
      -
      -
      - +
      +
      {permissionLabels[resource]}
      +
      +
      handlePermissionChange(resource as keyof Permission, 'write', checked) } /> +
      -
      - +
      handlePermissionChange(resource as keyof Permission, 'interact', checked) } /> +
      ))}
      -
      + )}
      - } +
      - - - ); + ) } diff --git a/components/UserCreateForm.tsx b/components/UserCreateForm.tsx deleted file mode 100644 index 3c1e766..0000000 --- a/components/UserCreateForm.tsx +++ /dev/null @@ -1,140 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Input } from './ui/input'; -import { Button } from './ui/button'; -import { toast } from '@/hooks/use-toast'; -import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; -import { User } from 'lucide-react'; -import { createUser } from '@/app/actions/data'; -import { useAtom } from 'jotai'; -import { usersAtom } from '@/lib/atoms'; - -export default function UserCreateForm({ - onCancel, - onSuccess -}: { - onCancel: () => void; - onSuccess: () => void; -}) { - const [newUsername, setNewUsername] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const [, setUsersData] = useAtom(usersAtom); - - const [avatarFile, setAvatarFile] = useState(null); - - const handleCreateUser = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newUsername || !password) return; - - try { - const formData = new FormData(); - formData.append('username', newUsername); - formData.append('password', password); - - if (avatarFile) { - formData.append('avatar', avatarFile); - } - - const newUser = await createUser(formData); - - setUsersData(prev => ({ - ...prev, - users: [...prev.users, newUser], - })); - - setPassword(''); - setNewUsername(''); - setError(''); - onSuccess(); - - toast({ - title: "User created", - description: `Successfully created user ${newUser.username}`, - variant: 'default' - }); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create user'); - } - }; - - return ( -
      -
      - setNewUsername(e.target.value)} - className={error ? 'border-red-500' : ''} - /> - setPassword(e.target.value)} - className={error ? 'border-red-500' : ''} - /> - {error && ( -

      {error}

      - )} -
      -
      - - - - - - -
      - { - const file = e.target.files?.[0]; - if (file) { - if (file.size > 5 * 1024 * 1024) { - toast({ - title: "Error", - description: "File size must be less than 5MB", - variant: "destructive" - }); - return; - } - setAvatarFile(file); - } - }} - /> - -
      -
      - -
      - - -
      -
      - ); -} diff --git a/components/UserForm.tsx b/components/UserForm.tsx index 6874d61..ff2b73a 100644 --- a/components/UserForm.tsx +++ b/components/UserForm.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { passwordSchema, usernameSchema } from '@/lib/zod'; import { Input } from './ui/input'; import { Button } from './ui/button'; import { Label } from './ui/label'; @@ -44,7 +45,8 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) const [avatarPath, setAvatarPath] = useState(user?.avatarPath) const [username, setUsername] = useState(user?.username || ''); - const [password, setPassword] = useState(''); + const [password, setPassword] = useState(''); + const [disablePassword, setDisablePassword] = useState(user?.password === ''); const [error, setError] = useState(''); const [avatarFile, setAvatarFile] = useState(null); const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false); @@ -57,20 +59,46 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) e.preventDefault(); try { + // Validate username + const usernameResult = usernameSchema.safeParse(username); + if (!usernameResult.success) { + setError(usernameResult.error.errors[0].message); + return; + } + + // Validate password unless disabled + if (!disablePassword && password) { + const passwordResult = passwordSchema.safeParse(password); + if (!passwordResult.success) { + setError(passwordResult.error.errors[0].message); + return; + } + } + if (isEditing) { // Update existing user if (username !== user.username || avatarPath !== user.avatarPath || !_.isEqual(permissions, user.permissions) || isAdmin !== user.isAdmin) { await updateUser(user.id, { username, avatarPath, permissions, isAdmin }); } - if (password) { + // Handle password update + if (disablePassword) { + await updateUserPassword(user.id, undefined); + } else if (password) { await updateUserPassword(user.id, password); } setUsersData(prev => ({ ...prev, users: prev.users.map(u => - u.id === user.id ? { ...u, username, avatarPath, permissions, isAdmin } : u + u.id === user.id ? { + ...u, + username, + avatarPath, + permissions, + isAdmin, + password: disablePassword ? '' : (password || u.password) // use the correct password to update atom + } : u ), })); @@ -83,7 +111,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) // Create new user const formData = new FormData(); formData.append('username', username); - formData.append('password', password); + if (password) formData.append('password', password); formData.append('permissions', JSON.stringify(isAdmin ? undefined : permissions)); formData.append('isAdmin', JSON.stringify(isAdmin)); if (avatarFile) { @@ -199,19 +227,30 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) />
      -
      - - setPassword(e.target.value)} - className={error ? 'border-red-500' : ''} - required={!isEditing} - /> +
      +
      + + setPassword(e.target.value)} + className={error ? 'border-red-500' : ''} + disabled={disablePassword} + /> +
      + +
      + + +
      {error && ( @@ -236,7 +275,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) > Cancel -
      diff --git a/components/WishlistItem.tsx b/components/WishlistItem.tsx index f478bbe..b30f5ff 100644 --- a/components/WishlistItem.tsx +++ b/components/WishlistItem.tsx @@ -1,4 +1,4 @@ -import { WishlistItemType, User } from '@/lib/types' +import { WishlistItemType, User, Permission } from '@/lib/types' import { useAtom } from 'jotai' import { usersAtom } from '@/lib/atoms' import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' @@ -58,7 +58,9 @@ export default function WishlistItem({ isHighlighted, isRecentlyRedeemed }: WishlistItemProps) { - const { currentUser } = useHelpers() + const { currentUser, hasPermission } = useHelpers() + const canWrite = hasPermission('wishlist', 'write') + const canInteract = hasPermission('wishlist', 'interact') const [usersData] = useAtom(usersAtom) return ( @@ -104,7 +106,7 @@ export default function WishlistItem({ variant={canRedeem ? "default" : "secondary"} size="sm" onClick={onRedeem} - disabled={!canRedeem || item.archived} + disabled={!canRedeem || !canInteract || item.archived} className={`transition-all duration-300 w-24 sm:w-auto ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''} ${item.archived ? 'cursor-not-allowed' : ''}`} > @@ -129,6 +131,7 @@ export default function WishlistItem({ variant="edit" size="sm" onClick={onEdit} + disabled={!canWrite} className="hidden sm:flex" > @@ -143,13 +146,13 @@ export default function WishlistItem({ {!item.archived && ( - + Archive )} {item.archived && ( - + Unarchive @@ -162,6 +165,7 @@ export default function WishlistItem({ Delete diff --git a/hooks/useTasks.tsx b/hooks/useTasks.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/hooks/useWishlist.tsx b/hooks/useWishlist.tsx index 60858dc..2504ade 100644 --- a/hooks/useWishlist.tsx +++ b/hooks/useWishlist.tsx @@ -1,5 +1,5 @@ import { useAtom } from 'jotai' -import { wishlistAtom, coinsAtom } from '@/lib/atoms' +import { wishlistAtom, coinsAtom, coinsBalanceAtom } from '@/lib/atoms' import { saveWishlistItems, removeCoins } from '@/app/actions/data' import { toast } from '@/hooks/use-toast' import { WishlistItemType } from '@/lib/types' @@ -37,7 +37,7 @@ export function useWishlist() { const { currentUser: user } = useHelpers() const [wishlist, setWishlist] = useAtom(wishlistAtom) const [coins, setCoins] = useAtom(coinsAtom) - const balance = coins.balance + const [balance] = useAtom(coinsBalanceAtom) const addWishlistItem = async (item: Omit) => { if (!handlePermissionCheck(user, 'wishlist', 'write')) return diff --git a/lib/client-helpers.ts b/lib/client-helpers.ts index 35f0454..de3f199 100644 --- a/lib/client-helpers.ts +++ b/lib/client-helpers.ts @@ -5,6 +5,7 @@ import { useSession } from "next-auth/react" import { User, UserId } from './types' import { useAtom } from 'jotai' import { usersAtom } from './atoms' +import { checkPermission } from './utils' export function useHelpers() { const { data: session, status } = useSession() @@ -16,6 +17,8 @@ export function useHelpers() { currentUserId, currentUser, usersData, - status + status, + hasPermission: (resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact') => currentUser?.isAdmin || + checkPermission(currentUser?.permissions, resource, action) } -} \ No newline at end of file +} diff --git a/lib/constants.ts b/lib/constants.ts index 97c4abe..56453e4 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -19,5 +19,17 @@ export const DUE_MAP: { [key: string]: string } = { export const HabitIcon = Target export const TaskIcon = CheckSquare; +export const QUICK_DATES = [ + { label: 'Today', value: 'today' }, + { label: 'Tomorrow', value: 'tomorrow' }, + { label: 'Monday', value: 'this monday' }, + { label: 'Tuesday', value: 'this tuesday' }, + { label: 'Wednesday', value: 'this wednesday' }, + { label: 'Thursday', value: 'this thursday' }, + { label: 'Friday', value: 'this friday' }, + { label: 'Saturday', value: 'this saturday' }, + { label: 'Sunday', value: 'this sunday' }, +] as const + export const DEFAULT_ADMIN_PASS = "admin" -export const DEFAULT_ADMIN_PASS_HASH = "4fd03f11af068acd8aa2bf8a38ce6ef7:bcf07a1776ba9fcb927fbcfb0eda933573f87e0852f8620b79c1da9242664856197f53109a94233cdaea7e6b08bf07713642f990739ff71480990f842809bd99" // "admin" \ No newline at end of file +export const DEFAULT_ADMIN_PASS_HASH = "4fd03f11af068acd8aa2bf8a38ce6ef7:bcf07a1776ba9fcb927fbcfb0eda933573f87e0852f8620b79c1da9242664856197f53109a94233cdaea7e6b08bf07713642f990739ff71480990f842809bd99" // "admin" diff --git a/lib/server-helpers.ts b/lib/server-helpers.ts index b4e3eae..98aaad2 100644 --- a/lib/server-helpers.ts +++ b/lib/server-helpers.ts @@ -19,12 +19,18 @@ export async function getCurrentUser(): Promise { return usersData.users.find((u) => u.id === currentUserId) } export function saltAndHashPassword(password: string, salt?: string): string { + if (password.length === 0) throw new Error('Password must not be empty') salt = salt || randomBytes(16).toString('hex') const hash = scryptSync(password, salt, 64).toString('hex') return `${salt}:${hash}` } -export function verifyPassword(password: string, storedHash: string): boolean { +export function verifyPassword(password?: string, storedHash?: string): boolean { + // if both password and storedHash is undefined, return true + if (!password && !storedHash) return true + // else if either password or storedHash is undefined, return false + if (!password || !storedHash) return false + // Split the stored hash into its salt and hash components const [salt, hash] = storedHash.split(':') // Hash the input password with the same salt diff --git a/lib/utils.test.ts b/lib/utils.test.ts index 858fe7b..66ff2c0 100644 --- a/lib/utils.test.ts +++ b/lib/utils.test.ts @@ -16,7 +16,8 @@ import { calculateCoinsSpentToday, isHabitDueToday, isHabitDue, - uuid + uuid, + isTaskOverdue } from './utils' import { CoinTransaction } from './types' import { DateTime } from "luxon"; @@ -32,6 +33,61 @@ describe('cn utility', () => { }) }) +describe('isTaskOverdue', () => { + const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({ + id: 'test-habit', + name: 'Test Habit', + description: '', + frequency, + coinReward: 10, + completions: [], + isTask, + archived + }) + + test('should return false for non-tasks', () => { + const habit = createTestHabit('FREQ=DAILY', false) + expect(isTaskOverdue(habit, 'UTC')).toBe(false) + }) + + test('should return false for archived tasks', () => { + const habit = createTestHabit('2024-01-01T00:00:00Z', true, true) + expect(isTaskOverdue(habit, 'UTC')).toBe(false) + }) + + test('should return false for future tasks', () => { + const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO() + const habit = createTestHabit(tomorrow) + expect(isTaskOverdue(habit, 'UTC')).toBe(false) + }) + + test('should return false for completed past tasks', () => { + const yesterday = DateTime.now().minus({ days: 1 }).toUTC().toISO() + const habit = { + ...createTestHabit(yesterday), + completions: [DateTime.now().toUTC().toISO()] + } + expect(isTaskOverdue(habit, 'UTC')).toBe(false) + }) + + test('should return true for incomplete past tasks', () => { + const yesterday = DateTime.now().minus({ days: 1 }).toUTC().toISO() + const habit = createTestHabit(yesterday) + expect(isTaskOverdue(habit, 'UTC')).toBe(true) + }) + + test('should handle timezone differences correctly', () => { + // Create a task due "tomorrow" in UTC + const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO() + const habit = createTestHabit(tomorrow) + + // Test in various timezones + expect(isTaskOverdue(habit, 'UTC')).toBe(false) + expect(isTaskOverdue(habit, 'America/New_York')).toBe(false) + expect(isTaskOverdue(habit, 'Asia/Tokyo')).toBe(false) + }) +}) + describe('uuid', () => { test('should generate valid UUIDs', () => { const id = uuid() diff --git a/lib/utils.ts b/lib/utils.ts index 172e103..bbcb799 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -38,7 +38,7 @@ export function t2d({ timestamp, timezone }: { timestamp: string; timezone: stri return DateTime.fromISO(timestamp).setZone(timezone); } -// convert datetime object to iso timestamp, mostly for storage write +// convert datetime object to iso timestamp, mostly for storage write (be sure to use default utc timezone when writing) export function d2t({ dateTime, timezone = 'utc' }: { dateTime: DateTime, timezone?: string }) { return dateTime.setZone(timezone).toISO()!; } @@ -255,6 +255,17 @@ export function isHabitDue({ return startOfDay <= t && t <= endOfDay } +export function isHabitCompleted(habit: Habit, timezone: string): boolean { + return getCompletionsForToday({ habit, timezone: timezone }) >= (habit.targetCompletions || 1) +} + +export function isTaskOverdue(habit: Habit, timezone: string): boolean { + if (!habit.isTask || habit.archived) return false + const dueDate = t2d({ timestamp: habit.frequency, timezone }).startOf('day') + const now = getNow({ timezone }).startOf('day') + return dueDate < now && !isHabitCompleted(habit, timezone) +} + export function isHabitDueToday({ habit, timezone @@ -331,4 +342,4 @@ export function checkPermission( export function uuid() { return uuidv4() -} \ No newline at end of file +} diff --git a/lib/zod.ts b/lib/zod.ts index 523eb7e..3d9fdd5 100644 --- a/lib/zod.ts +++ b/lib/zod.ts @@ -1,11 +1,17 @@ -import { object, string } from "zod" - +import { literal, object, string } from "zod" + +export const usernameSchema = string() + .min(3, "Username must be at least 3 characters") + .max(20, "Username must be less than 20 characters") + .regex(/^[a-zA-Z0-9]+$/, "Username must be alphanumeric") + +export const passwordSchema = string() + .min(4, "Password must be more than 4 characters") + .max(32, "Password must be less than 32 characters") + .optional() + .or(literal('')) + export const signInSchema = object({ - username: string({ required_error: "Username is required" }) - .min(1, "Username is required") - .regex(/^[a-zA-Z0-9]+$/, "Username must be alphanumeric"), - password: string({ required_error: "Password is required" }) - .min(1, "Password is required") - .min(4, "Password must be more than 4 characters") - .max(32, "Password must be less than 32 characters"), -}) \ No newline at end of file + username: usernameSchema, + password: passwordSchema, +}) From 6d3742bc24820bbb7f36dabd887c9f4d585f1f86 Mon Sep 17 00:00:00 2001 From: dohsimpson Date: Thu, 13 Feb 2025 09:29:57 -0500 Subject: [PATCH 6/8] small fixes --- CHANGELOG.md | 2 ++ app/actions/data.ts | 3 ++- components/PermissionSelector.tsx | 8 ++++---- components/UserForm.tsx | 6 +++++- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdeb49f..a19f220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ - disable buttons when doesn't have permissions - disable redeem if user has no more coins - fix task completed in the past still show up as uncompleted if due today +- fix create user error when disable password +- fix permission switches spacing in mobile viewport ### BREAKING CHANGE diff --git a/app/actions/data.ts b/app/actions/data.ts index e502d49..c53c305 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -365,12 +365,13 @@ export async function getUser(username: string, plainTextPassword?: string): Pro export async function createUser(formData: FormData): Promise { const username = formData.get('username') as string; - const password = formData.get('password') as string; + let password = formData.get('password') as string | undefined; const avatarFile = formData.get('avatar') as File | null; const permissions = formData.get('permissions') ? JSON.parse(formData.get('permissions') as string) as Permission[] : undefined; + if (password === null) password = undefined // Validate username and password against schema await signInSchema.parseAsync({ username, password }); diff --git a/components/PermissionSelector.tsx b/components/PermissionSelector.tsx index 9b21ad2..b7be7a4 100644 --- a/components/PermissionSelector.tsx +++ b/components/PermissionSelector.tsx @@ -73,7 +73,8 @@ export function PermissionSelector({
      {permissionLabels[resource]}
      -
      +
      + -
      -
      +
      + -
      diff --git a/components/UserForm.tsx b/components/UserForm.tsx index ff2b73a..ee181ba 100644 --- a/components/UserForm.tsx +++ b/components/UserForm.tsx @@ -111,7 +111,11 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) // Create new user const formData = new FormData(); formData.append('username', username); - if (password) formData.append('password', password); + if (disablePassword) { + formData.append('password', ''); + } else if (password) { + formData.append('password', password); + } formData.append('permissions', JSON.stringify(isAdmin ? undefined : permissions)); formData.append('isAdmin', JSON.stringify(isAdmin)); if (avatarFile) { From d6847891d97feb1fab98183bd76ce7fd60659790 Mon Sep 17 00:00:00 2001 From: dohsimpson Date: Sat, 15 Feb 2025 17:03:11 -0500 Subject: [PATCH 7/8] fix completion count badge --- CHANGELOG.md | 24 -------------- components/CompletionCountBadge.tsx | 40 +++++++++++++++++++++++ components/HabitCalendar.tsx | 50 ++++++++--------------------- components/Logo.tsx | 2 +- 4 files changed, 55 insertions(+), 61 deletions(-) create mode 100644 components/CompletionCountBadge.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index a19f220..a32c9ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,33 +5,9 @@ ### Added * Multi-user support with permissions system -* User management interface -* Support for multiple users tracking habits and wishlists * Sharing habits and wishlist items with other users * show both tasks and habits in dashboard (#58) * show tasks in completion streak (#57) -* show badge for overdue tasks -* shortcut for selecting date for tasks -* context menu shortcut to move task to today - -### Changed - -- useHelpers hook should return user from atom not session -- sharing wishlist with other users -- disable permission edit if only has 1 user -- always show edit button in user switch modal -- move crypto utils to server-helpers -- use uuid package for client-compatible generator -- fix add button for tasks and habits in daily overview -- better error message for user creation via frontend validation -- allow empty password -- better layout for permission editor -- disable buttons when doesn't have permissions -- disable redeem if user has no more coins -- fix task completed in the past still show up as uncompleted if due today -- fix create user error when disable password -- fix permission switches spacing in mobile viewport - ### BREAKING CHANGE diff --git a/components/CompletionCountBadge.tsx b/components/CompletionCountBadge.tsx new file mode 100644 index 0000000..569e35a --- /dev/null +++ b/components/CompletionCountBadge.tsx @@ -0,0 +1,40 @@ +import { Badge } from '@/components/ui/badge' +import { Habit } from '@/lib/types' +import { isHabitDue, getCompletionsForDate } from '@/lib/utils' + +interface CompletionCountBadgeProps { + habits: Habit[] + selectedDate: luxon.DateTime + timezone: string + type: 'tasks' | 'habits' +} + +export function CompletionCountBadge({ habits, selectedDate, timezone, type }: CompletionCountBadgeProps) { + const filteredHabits = habits.filter(habit => { + const isTask = type === 'tasks' + if ((habit.isTask === isTask) && isHabitDue({ + habit, + timezone, + date: selectedDate + })) { + const completions = getCompletionsForDate({ habit, date: selectedDate, timezone }) + return completions >= (habit.targetCompletions || 1) + } + return false + }).length + + const totalHabits = habits.filter(habit => + (habit.isTask === (type === 'tasks')) && + isHabitDue({ + habit, + timezone, + date: selectedDate + }) + ).length + + return ( + + {`${filteredHabits}/${totalHabits} Completed`} + + ) +} diff --git a/components/HabitCalendar.tsx b/components/HabitCalendar.tsx index 1f89654..9bf5f94 100644 --- a/components/HabitCalendar.tsx +++ b/components/HabitCalendar.tsx @@ -3,7 +3,7 @@ import { useState, useMemo, useCallback } from 'react' import { Calendar } from '@/components/ui/calendar' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' +import { CompletionCountBadge } from '@/components/CompletionCountBadge' import { Button } from '@/components/ui/button' import { Check, Circle, CircleCheck } from 'lucide-react' import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils' @@ -85,23 +85,12 @@ export default function HabitCalendar() {

      Tasks

      - - {`${habits.filter(habit => { - if (habit.isTask && isHabitDue({ - habit, - timezone: settings.system.timezone, - date: selectedDate - })) { - const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone }) - return completions >= (habit.targetCompletions || 1) - } - return false - }).length}/${habits.filter(habit => habit.isTask && isHabitDue({ - habit, - timezone: settings.system.timezone, - date: selectedDate - })).length} Completed`} - +
        {habits @@ -160,27 +149,16 @@ export default function HabitCalendar() {

        Habits

        - - {`${habits.filter(habit => { - if (!habit.isTask && isHabitDue({ - habit, - timezone: settings.system.timezone, - date: selectedDate - })) { - const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone }) - return completions >= (habit.targetCompletions || 1) - } - return false - }).length}/${habits.filter(habit => !habit.isTask && isHabitDue({ - habit, - timezone: settings.system.timezone, - date: selectedDate - })).length} Completed`} - +
          {habits - .filter(habit => !habit.isTask && isHabitDue({ + .filter(habit => !habit.isTask && !habit.archived && isHabitDue({ habit, timezone: settings.system.timezone, date: selectedDate diff --git a/components/Logo.tsx b/components/Logo.tsx index 2ae2e6f..0beafe4 100644 --- a/components/Logo.tsx +++ b/components/Logo.tsx @@ -3,7 +3,7 @@ import { Sparkles } from "lucide-react" export function Logo() { return (
          - + {/* */} HabitTrove
          ) From a43e9fede8f69297f5f3c0f879c592ce14c26512 Mon Sep 17 00:00:00 2001 From: dohsimpson Date: Tue, 18 Feb 2025 23:38:49 -0500 Subject: [PATCH 8/8] fixes --- CHANGELOG.md | 5 +-- app/actions/data.ts | 15 +-------- app/layout.tsx | 2 ++ components/PasswordEntryForm.tsx | 2 -- components/UserForm.tsx | 54 +++++++++++++++----------------- lib/constants.ts | 5 +-- lib/env.server.ts | 31 ++++++++++++++++++ lib/types.ts | 4 +-- 8 files changed, 65 insertions(+), 53 deletions(-) create mode 100644 lib/env.server.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a32c9ec..f3a369d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,9 @@ ### BREAKING CHANGE -* Requires AUTH_SECRET environment variable for user authentication -* Generate a secure secret with: `openssl rand -base64 32` +* PLEASE BACK UP `data/` DIRECTORY BEFORE UPGRADE. +* Requires AUTH_SECRET environment variable for user authentication. Generate a secure secret with: `openssl rand -base64 32` +* Previous coin balance will be hidden. If this is undesirable, consider using manual adjustment to adjust coin balance after upgrade. ## Version 0.1.30 diff --git a/app/actions/data.ts b/app/actions/data.ts index c53c305..45dc2c2 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -341,12 +341,6 @@ export async function loadUsersData(): Promise { } export async function saveUsersData(data: UserData): Promise { - if (process.env.DEMO === 'true') { - // remove password for all users - data.users.map(user => { - user.password = '' - }) - } return saveData('auth', data) } @@ -366,7 +360,7 @@ export async function getUser(username: string, plainTextPassword?: string): Pro export async function createUser(formData: FormData): Promise { const username = formData.get('username') as string; let password = formData.get('password') as string | undefined; - const avatarFile = formData.get('avatar') as File | null; + const avatarPath = formData.get('avatarPath') as string; const permissions = formData.get('permissions') ? JSON.parse(formData.get('permissions') as string) as Permission[] : undefined; @@ -384,13 +378,6 @@ export async function createUser(formData: FormData): Promise { const hashedPassword = password ? saltAndHashPassword(password) : ''; - // Handle avatar upload if present - let avatarPath: string | undefined; - if (avatarFile && avatarFile instanceof File && avatarFile.size > 0) { - const avatarFormData = new FormData(); - avatarFormData.append('avatar', avatarFile); - avatarPath = await uploadAvatar(avatarFormData); - } const newUser: User = { id: uuid(), diff --git a/app/layout.tsx b/app/layout.tsx index 43311b4..cebc20f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,3 +1,5 @@ +import '@/lib/env.server' // startup env var check + import './globals.css' import { Inter } from 'next/font/google' import { DM_Sans } from 'next/font/google' diff --git a/components/PasswordEntryForm.tsx b/components/PasswordEntryForm.tsx index 15fe074..56a6eb1 100644 --- a/components/PasswordEntryForm.tsx +++ b/components/PasswordEntryForm.tsx @@ -8,8 +8,6 @@ import { User as UserIcon } from 'lucide-react'; import { Permission, User } from '@/lib/types'; import { toast } from '@/hooks/use-toast'; import { useState } from 'react'; -import { DEFAULT_ADMIN_PASS, DEFAULT_ADMIN_PASS_HASH } from '@/lib/constants'; -import { Switch } from './ui/switch'; interface PasswordEntryFormProps { user: User; diff --git a/components/UserForm.tsx b/components/UserForm.tsx index ee181ba..5ed1889 100644 --- a/components/UserForm.tsx +++ b/components/UserForm.tsx @@ -30,15 +30,15 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) const { currentUser } = useHelpers() const getDefaultPermissions = (): Permission[] => [{ habit: { - write: false, + write: true, interact: true }, wishlist: { - write: false, + write: true, interact: true }, coins: { - write: false, + write: true, interact: true } }]; @@ -46,7 +46,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) const [avatarPath, setAvatarPath] = useState(user?.avatarPath) const [username, setUsername] = useState(user?.username || ''); const [password, setPassword] = useState(''); - const [disablePassword, setDisablePassword] = useState(user?.password === ''); + const [disablePassword, setDisablePassword] = useState(user?.password === '' || process.env.NEXT_PUBLIC_DEMO === 'true'); const [error, setError] = useState(''); const [avatarFile, setAvatarFile] = useState(null); const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false); @@ -118,9 +118,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) } formData.append('permissions', JSON.stringify(isAdmin ? undefined : permissions)); formData.append('isAdmin', JSON.stringify(isAdmin)); - if (avatarFile) { - formData.append('avatar', avatarFile); - } + formData.append('avatarPath', avatarPath || ''); const newUser = await createUser(formData); setUsersData(prev => ({ @@ -153,27 +151,24 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) return; } - if (isEditing) { - const formData = new FormData(); - formData.append('avatar', file); + const formData = new FormData(); + formData.append('avatar', file); - try { - const path = await uploadAvatar(formData); - setAvatarPath(path); - toast({ - title: "Avatar uploaded", - description: "Successfully uploaded avatar", - variant: 'default' - }); - } catch (err) { - toast({ - title: "Error", - description: "Failed to upload avatar", - variant: 'destructive' - }); - } - } else { - setAvatarFile(file); + try { + const path = await uploadAvatar(formData); + setAvatarPath(path); + setAvatarFile(null); // Clear the file since we've uploaded it + toast({ + title: "Avatar uploaded", + description: "Successfully uploaded avatar", + variant: 'default' + }); + } catch (err) { + toast({ + title: "Error", + description: "Failed to upload avatar", + variant: 'destructive' + }); } }; @@ -245,6 +240,9 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) className={error ? 'border-red-500' : ''} disabled={disablePassword} /> + {process.env.NEXT_PUBLIC_DEMO === 'true' && ( +

          Password is automatically disabled in demo instance

          + )}
        @@ -262,7 +260,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) )} - {currentUser && currentUser.isAdmin && users.users.length > 1 && { + AUTH_SECRET: string; + NEXT_PUBLIC_DEMO?: string; + } +} + +try { + zodEnv.parse(process.env) +} catch (err) { + if (err instanceof z.ZodError) { + const { fieldErrors } = err.flatten() + const errorMessage = Object.entries(fieldErrors) + .map(([field, errors]) => + errors ? `${field}: ${errors.join(", ")}` : field, + ) + .join("\n ") + + console.error( + `Missing environment variables:\n ${errorMessage}`, + ) + process.exit(1) + } +} diff --git a/lib/types.ts b/lib/types.ts index 58a41aa..86beff2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,5 +1,3 @@ -import { DEFAULT_ADMIN_PASS_HASH } from "./constants" -import { saltAndHashPassword } from "./server-helpers" import { uuid } from "./utils" export type UserId = string @@ -100,7 +98,7 @@ export const getDefaultUsersData = (): UserData => ({ { id: uuid(), username: 'admin', - password: DEFAULT_ADMIN_PASS_HASH, + password: '', isAdmin: true, } ]