mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-20 22:24:28 +01:00
Multiuser support (#60)
This commit is contained in:
@@ -12,9 +12,43 @@ 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, 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';
|
||||
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<void> {
|
||||
// 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<T>(type: DataType): T {
|
||||
return DATA_DEFAULTS[type]() as T;
|
||||
@@ -45,7 +79,7 @@ async function loadData<T>(type: DataType): Promise<T> {
|
||||
|
||||
// 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 +89,9 @@ async function loadData<T>(type: DataType): Promise<T> {
|
||||
|
||||
async function saveData<T>(type: DataType, data: T): Promise<void> {
|
||||
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 +103,14 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
|
||||
|
||||
// Wishlist specific functions
|
||||
export async function loadWishlistData(): Promise<WishlistData> {
|
||||
return loadData<WishlistData>('wishlist')
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return getDefaultWishlistData()
|
||||
|
||||
const data = await loadData<WishlistData>('wishlist')
|
||||
return {
|
||||
...data,
|
||||
items: data.items.filter(x => user.isAdmin || x.userIds?.includes(user.id))
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadWishlistItems(): Promise<WishlistItemType[]> {
|
||||
@@ -74,31 +118,98 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
|
||||
return data.items
|
||||
}
|
||||
|
||||
export async function saveWishlistItems(items: WishlistItemType[]): Promise<void> {
|
||||
return saveData('wishlist', { items })
|
||||
export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
||||
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<WishlistData>('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<HabitsData> {
|
||||
return loadData<HabitsData>('habits')
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return getDefaultHabitsData()
|
||||
const data = await loadData<HabitsData>('habits')
|
||||
return {
|
||||
...data,
|
||||
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHabitsData(data: HabitsData): Promise<void> {
|
||||
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<HabitsData>('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<CoinsData> {
|
||||
try {
|
||||
return await loadData<CoinsData>('coins')
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return getDefaultCoinsData()
|
||||
const data = await loadData<CoinsData>('coins')
|
||||
return {
|
||||
...data,
|
||||
transactions: data.transactions.filter(x => x.userId === user.id)
|
||||
}
|
||||
} catch {
|
||||
return { balance: 0, transactions: [] }
|
||||
return getDefaultCoinsData()
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCoinsData(data: CoinsData): Promise<void> {
|
||||
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<CoinsData>('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,9 +225,10 @@ export async function addCoins({
|
||||
relatedItemId?: string
|
||||
note?: string
|
||||
}): Promise<CoinsData> {
|
||||
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
||||
const data = await loadCoinsData()
|
||||
const newTransaction: CoinTransaction = {
|
||||
id: crypto.randomUUID(),
|
||||
id: uuid(),
|
||||
amount,
|
||||
type,
|
||||
description,
|
||||
@@ -138,6 +250,8 @@ export async function loadSettings(): Promise<Settings> {
|
||||
const defaultSettings = getDefaultSettings()
|
||||
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return defaultSettings
|
||||
const data = await loadData<Settings>('settings')
|
||||
return { ...defaultSettings, ...data }
|
||||
} catch {
|
||||
@@ -162,9 +276,10 @@ export async function removeCoins({
|
||||
relatedItemId?: string
|
||||
note?: string
|
||||
}): Promise<CoinsData> {
|
||||
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
||||
const data = await loadCoinsData()
|
||||
const newTransaction: CoinTransaction = {
|
||||
id: crypto.randomUUID(),
|
||||
id: uuid(),
|
||||
amount: -amount,
|
||||
type,
|
||||
description,
|
||||
@@ -182,7 +297,7 @@ export async function removeCoins({
|
||||
return newData
|
||||
}
|
||||
|
||||
export async function uploadAvatar(formData: FormData) {
|
||||
export async function uploadAvatar(formData: FormData): Promise<string> {
|
||||
const file = formData.get('avatar') as File
|
||||
if (!file) throw new Error('No file provided')
|
||||
|
||||
@@ -203,18 +318,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<string> {
|
||||
@@ -226,3 +330,147 @@ export async function getChangelog(): Promise<string> {
|
||||
return '# Changelog\n\nNo changelog available.'
|
||||
}
|
||||
}
|
||||
|
||||
// user logic
|
||||
export async function loadUsersData(): Promise<UserData> {
|
||||
try {
|
||||
return await loadData<UserData>('auth')
|
||||
} catch {
|
||||
return getDefaultUsersData()
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveUsersData(data: UserData): Promise<void> {
|
||||
return saveData('auth', data)
|
||||
}
|
||||
|
||||
export async function getUser(username: string, plainTextPassword?: string): Promise<User | null> {
|
||||
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<User> {
|
||||
const username = formData.get('username') as string;
|
||||
let password = formData.get('password') as string | undefined;
|
||||
const avatarPath = formData.get('avatarPath') as string;
|
||||
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 });
|
||||
|
||||
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 = password ? saltAndHashPassword(password) : '';
|
||||
|
||||
|
||||
const newUser: User = {
|
||||
id: uuid(),
|
||||
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<Omit<User, 'id' | 'password'>>): Promise<User> {
|
||||
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<void> {
|
||||
const data = await loadUsersData()
|
||||
const userIndex = data.users.findIndex(user => user.id === userId)
|
||||
|
||||
if (userIndex === -1) {
|
||||
throw new Error('User not found')
|
||||
}
|
||||
|
||||
const hashedPassword = newPassword ? 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<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
27
app/actions/user.ts
Normal file
27
app/actions/user.ts
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user