Multiuser support (#60)

This commit is contained in:
Doh
2025-02-18 23:43:23 -05:00
committed by GitHub
parent 363b31e934
commit 8ac2ec053d
48 changed files with 2678 additions and 271 deletions

View File

@@ -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
View 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");
}
}

View File

@@ -0,0 +1,2 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

10
app/debug/layout.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { ReactNode } from "react";
export default function Debug({children}: {children: ReactNode}) {
if (process.env.NODE_ENV !== 'development') return null
return (
<div className="debug">
{children}
</div>
)
}

16
app/debug/user/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { saltAndHashPassword } from "@/lib/server-helpers";
export default function DebugPage() {
const password = 'admin';
const hashedPassword = saltAndHashPassword(password);
return (
<div className="p-4">
<h1 className="text-xl font-bold mb-4">Debug Page</h1>
<div className="bg-gray-100 p-4 rounded break-all">
<p><strong>Password:</strong> {password}</p>
<p><strong>Hashed Password:</strong> {hashedPassword}</p>
</div>
</div>
);
}

View File

@@ -1,13 +1,16 @@
import '@/lib/env.server' // startup env var check
import './globals.css'
import { Inter } from 'next/font/google'
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 +39,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 +75,8 @@ export default async function RootLayout({
settings: initialSettings,
habits: initialHabits,
coins: initialCoins,
wishlist: initialWishlist
wishlist: initialWishlist,
users: initialUsers
}}
>
<ThemeProvider
@@ -80,9 +85,11 @@ export default async function RootLayout({
enableSystem
disableTransitionOnChange
>
<Layout>
{children}
</Layout>
<SessionProvider>
<Layout>
{children}
</Layout>
</SessionProvider>
</ThemeProvider>
</JotaiHydrate>
</Suspense>

View File

@@ -142,60 +142,6 @@ export default function SettingsPage() {
</div>
</CardContent>
</Card>
<Card className="mb-6">
<CardHeader>
<CardTitle>Profile Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="avatar">Avatar</Label>
<div className="text-sm text-muted-foreground">
Customize your profile picture
</div>
</div>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={settings.profile?.avatarPath ? `/api/avatars/${settings.profile.avatarPath.split('/').pop()}` : '/avatars/default.png'} />
<AvatarFallback>
<User className="h-8 w-8" />
</AvatarFallback>
</Avatar>
<form action={async (formData: FormData) => {
const newSettings = await uploadAvatar(formData)
setSettings(newSettings)
}}>
<input
type="file"
id="avatar"
name="avatar"
accept="image/png, image/jpeg"
className="hidden"
onChange={(e) => {
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()
}
}}
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('avatar')?.click()}
>
Change
</Button>
</form>
</div>
</div>
</CardContent>
</Card>
</div >
</>
)