mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Multiuser support (#60)
This commit is contained in:
16
lib/atoms.ts
16
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());
|
||||
@@ -67,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
|
||||
@@ -82,6 +90,8 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
||||
minimized: false,
|
||||
})
|
||||
|
||||
export const userSelectAtom = atom<boolean>(false)
|
||||
|
||||
// Derived atom for *fully* completed habits by date, respecting target completions
|
||||
export const completedHabitsMapAtom = atom((get) => {
|
||||
const habits = get(habitsAtom).habits
|
||||
@@ -129,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)
|
||||
})
|
||||
|
||||
24
lib/client-helpers.ts
Normal file
24
lib/client-helpers.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// client helpers
|
||||
'use-client'
|
||||
|
||||
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()
|
||||
const currentUserId = session?.user.id
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const currentUser = usersData.users.find((u) => u.id === currentUserId)
|
||||
|
||||
return {
|
||||
currentUserId,
|
||||
currentUser,
|
||||
usersData,
|
||||
status,
|
||||
hasPermission: (resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact') => currentUser?.isAdmin ||
|
||||
checkPermission(currentUser?.permissions, resource, action)
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,14 @@ 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
|
||||
31
lib/env.server.ts
Normal file
31
lib/env.server.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const zodEnv = z.object({
|
||||
AUTH_SECRET: z.string(),
|
||||
NEXT_PUBLIC_DEMO: z.string().optional(),
|
||||
})
|
||||
|
||||
declare global {
|
||||
interface ProcessEnv extends z.TypeOf<typeof zodEnv> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
6
lib/exceptions.ts
Normal file
6
lib/exceptions.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class PermissionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'PermissionError'
|
||||
}
|
||||
}
|
||||
40
lib/server-helpers.ts
Normal file
40
lib/server-helpers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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<UserId | undefined> {
|
||||
const session = await auth()
|
||||
const user = session?.user
|
||||
return user?.id
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<User | undefined> {
|
||||
const currentUserId = await getCurrentUserId()
|
||||
if (!currentUserId) {
|
||||
return undefined
|
||||
}
|
||||
const usersData = await loadUsersData()
|
||||
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 {
|
||||
// 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
|
||||
const newHash = saltAndHashPassword(password, salt).split(':')[1]
|
||||
// Compare the new hash with the stored hash
|
||||
return newHash === hash
|
||||
}
|
||||
56
lib/types.ts
56
lib/types.ts
@@ -1,3 +1,37 @@
|
||||
import { uuid } 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 SessionUser = {
|
||||
id: UserId
|
||||
}
|
||||
|
||||
export type SafeUser = SessionUser & {
|
||||
username: string
|
||||
avatarPath?: string
|
||||
permissions?: Permission[]
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export type User = SafeUser & {
|
||||
password: string
|
||||
}
|
||||
|
||||
export type Habit = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -8,6 +42,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 +56,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 +69,11 @@ export interface CoinTransaction {
|
||||
timestamp: string;
|
||||
relatedItemId?: string;
|
||||
note?: string;
|
||||
userId?: UserId;
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
users: User[]
|
||||
}
|
||||
|
||||
export interface HabitsData {
|
||||
@@ -52,6 +93,17 @@ export interface WishlistData {
|
||||
}
|
||||
|
||||
// Default value functions
|
||||
export const getDefaultUsersData = (): UserData => ({
|
||||
users: [
|
||||
{
|
||||
id: uuid(),
|
||||
username: 'admin',
|
||||
password: '',
|
||||
isAdmin: true,
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export const getDefaultHabitsData = (): HabitsData => ({
|
||||
habits: []
|
||||
});
|
||||
@@ -84,6 +136,7 @@ export const DATA_DEFAULTS = {
|
||||
habits: getDefaultHabitsData,
|
||||
coins: getDefaultCoinsData,
|
||||
settings: getDefaultSettings,
|
||||
auth: getDefaultUsersData,
|
||||
} as const;
|
||||
|
||||
// Type for all possible data types
|
||||
@@ -102,7 +155,7 @@ export interface SystemSettings {
|
||||
}
|
||||
|
||||
export interface ProfileSettings {
|
||||
avatarPath?: string;
|
||||
avatarPath?: string; // deprecated
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
@@ -118,4 +171,5 @@ export interface JotaiHydrateInitialValues {
|
||||
coins: CoinsData;
|
||||
habits: HabitsData;
|
||||
wishlist: WishlistData;
|
||||
users: UserData;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
calculateTotalSpent,
|
||||
calculateCoinsSpentToday,
|
||||
isHabitDueToday,
|
||||
isHabitDue
|
||||
isHabitDue,
|
||||
uuid,
|
||||
isTaskOverdue
|
||||
} from './utils'
|
||||
import { CoinTransaction } from './types'
|
||||
import { DateTime } from "luxon";
|
||||
@@ -31,6 +33,87 @@ 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()
|
||||
// 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;
|
||||
|
||||
56
lib/utils.ts
56
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 * as chrono from 'chrono-node';
|
||||
import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types'
|
||||
import { DUE_MAP, RECURRENCE_RULE_MAP } from "./constants"
|
||||
import * as chrono from 'chrono-node'
|
||||
import _ from "lodash"
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -36,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()!;
|
||||
}
|
||||
@@ -253,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
|
||||
@@ -296,4 +309,37 @@ export const openWindow = (url: string): boolean => {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function deepMerge<T>(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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function uuid() {
|
||||
return uuidv4()
|
||||
}
|
||||
|
||||
17
lib/zod.ts
Normal file
17
lib/zod.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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: usernameSchema,
|
||||
password: passwordSchema,
|
||||
})
|
||||
Reference in New Issue
Block a user