Compare commits

...

1 Commits

Author SHA1 Message Date
Doh
9052c9f37a per-user coins data for admin (#82)
* admin user can see per-user coins data

* fixes

* fix
2025-02-28 17:07:44 -05:00
9 changed files with 119 additions and 69 deletions

View File

@@ -1,5 +1,15 @@
# Changelog # Changelog
## Version 0.2.4
### Added
* admin can select user to view coins for that user
### Fixed
* fix disable password in demo instance (#74)
## Version 0.2.3 ## Version 0.2.3
### Fixed ### Fixed

View File

@@ -185,7 +185,7 @@ export async function loadCoinsData(): Promise<CoinsData> {
const data = await loadData<CoinsData>('coins') const data = await loadData<CoinsData>('coins')
return { return {
...data, ...data,
transactions: data.transactions.filter(x => x.userId === user.id) transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
} }
} catch { } catch {
return getDefaultCoinsData() return getDefaultCoinsData()
@@ -219,12 +219,14 @@ export async function addCoins({
type = 'MANUAL_ADJUSTMENT', type = 'MANUAL_ADJUSTMENT',
relatedItemId, relatedItemId,
note, note,
userId,
}: { }: {
amount: number amount: number
description: string description: string
type?: TransactionType type?: TransactionType
relatedItemId?: string relatedItemId?: string
note?: string note?: string
userId?: string
}): Promise<CoinsData> { }): Promise<CoinsData> {
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact') await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
const data = await loadCoinsData() const data = await loadCoinsData()
@@ -235,7 +237,8 @@ export async function addCoins({
description, description,
timestamp: d2t({ dateTime: getNow({}) }), timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }), ...(relatedItemId && { relatedItemId }),
...(note && note.trim() !== '' && { note }) ...(note && note.trim() !== '' && { note }),
userId: userId || await getCurrentUserId()
} }
const newData: CoinsData = { const newData: CoinsData = {
@@ -270,12 +273,14 @@ export async function removeCoins({
type = 'MANUAL_ADJUSTMENT', type = 'MANUAL_ADJUSTMENT',
relatedItemId, relatedItemId,
note, note,
userId,
}: { }: {
amount: number amount: number
description: string description: string
type?: TransactionType type?: TransactionType
relatedItemId?: string relatedItemId?: string
note?: string note?: string
userId?: string
}): Promise<CoinsData> { }): Promise<CoinsData> {
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact') await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
const data = await loadCoinsData() const data = await loadCoinsData()
@@ -286,7 +291,8 @@ export async function removeCoins({
description, description,
timestamp: d2t({ dateTime: getNow({}) }), timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }), ...(relatedItemId && { relatedItemId }),
...(note && note.trim() !== '' && { note }) ...(note && note.trim() !== '' && { note }),
userId: userId || await getCurrentUserId()
} }
const newData: CoinsData = { const newData: CoinsData = {
@@ -478,6 +484,6 @@ export async function deleteUser(userId: string): Promise<void> {
export async function loadServerSettings(): Promise<ServerSettings> { export async function loadServerSettings(): Promise<ServerSettings> {
return { return {
isDemo: !!process.env.NEXT_PUBLIC_DEMO, isDemo: !!process.env.DEMO,
} }
} }

View File

@@ -17,6 +17,8 @@ import { TransactionNoteEditor } from './TransactionNoteEditor'
import { useHelpers } from '@/lib/client-helpers' import { useHelpers } from '@/lib/client-helpers'
export default function CoinsManager() { export default function CoinsManager() {
const { currentUser } = useHelpers()
const [selectedUser, setSelectedUser] = useState<string>()
const { const {
add, add,
remove, remove,
@@ -28,14 +30,13 @@ export default function CoinsManager() {
totalSpent, totalSpent,
coinsSpentToday, coinsSpentToday,
transactionsToday transactionsToday
} = useCoins() } = useCoins({selectedUser})
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
const DEFAULT_AMOUNT = '0' const DEFAULT_AMOUNT = '0'
const [amount, setAmount] = useState(DEFAULT_AMOUNT) const [amount, setAmount] = useState(DEFAULT_AMOUNT)
const [pageSize, setPageSize] = useState(50) const [pageSize, setPageSize] = useState(50)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const { currentUser } = useHelpers()
const [note, setNote] = useState('') const [note, setNote] = useState('')
@@ -62,7 +63,22 @@ export default function CoinsManager() {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Coins Management</h1> <div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold mr-6">Coins Management</h1>
{currentUser?.isAdmin && (
<select
className="border rounded p-2"
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
>
{usersData.users.map(user => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
)}
</div>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<Card> <Card>

View File

@@ -251,6 +251,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
id="disable-password" id="disable-password"
checked={disablePassword} checked={disablePassword}
onCheckedChange={setDisablePassword} onCheckedChange={setDisablePassword}
disabled={serverSettings.isDemo}
/> />
<Label htmlFor="disable-password">Disable password</Label> <Label htmlFor="disable-password">Disable password</Label>
</div> </div>

View File

@@ -1,21 +1,23 @@
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { checkPermission } from '@/lib/utils' import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
import { import {
coinsAtom, coinsAtom,
coinsEarnedTodayAtom, // coinsEarnedTodayAtom,
totalEarnedAtom, // totalEarnedAtom,
totalSpentAtom, // totalSpentAtom,
coinsSpentTodayAtom, // coinsSpentTodayAtom,
transactionsTodayAtom, // transactionsTodayAtom,
coinsBalanceAtom // coinsBalanceAtom,
settingsAtom,
usersAtom
} from '@/lib/atoms' } from '@/lib/atoms'
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data' import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
import { CoinsData } from '@/lib/types' import { CoinsData, User } from '@/lib/types'
import { toast } from '@/hooks/use-toast' import { toast } from '@/hooks/use-toast'
import { useHelpers } from '@/lib/client-helpers' import { useHelpers } from '@/lib/client-helpers'
function handlePermissionCheck( function handlePermissionCheck(
user: any, user: User | undefined,
resource: 'habit' | 'wishlist' | 'coins', resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact' action: 'write' | 'interact'
): boolean { ): boolean {
@@ -40,18 +42,30 @@ function handlePermissionCheck(
return true return true
} }
export function useCoins() { export function useCoins(options?: { selectedUser?: string }) {
const { currentUser: user } = useHelpers()
const [coins, setCoins] = useAtom(coinsAtom) const [coins, setCoins] = useAtom(coinsAtom)
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom) const [settings] = useAtom(settingsAtom)
const [totalEarned] = useAtom(totalEarnedAtom) const [users] = useAtom(usersAtom)
const [totalSpent] = useAtom(totalSpentAtom) const { currentUser } = useHelpers()
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom) let user: User | undefined;
const [transactionsToday] = useAtom(transactionsTodayAtom) if (!options?.selectedUser) {
const [balance] = useAtom(coinsBalanceAtom) user = currentUser;
} else {
user = users.users.find(u => u.id === options.selectedUser)
}
// Filter transactions for the selectd user
const transactions = coins.transactions.filter(t => t.userId === user?.id)
const balance = transactions.reduce((sum, t) => sum + t.amount, 0)
const coinsEarnedToday = calculateCoinsEarnedToday(transactions, settings.system.timezone)
const totalEarned = calculateTotalEarned(transactions)
const totalSpent = calculateTotalSpent(transactions)
const coinsSpentToday = calculateCoinsSpentToday(transactions, settings.system.timezone)
const transactionsToday = calculateTransactionsToday(transactions, settings.system.timezone)
const add = async (amount: number, description: string, note?: string) => { const add = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(user, 'coins', 'write')) return null if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
if (isNaN(amount) || amount <= 0) { if (isNaN(amount) || amount <= 0) {
toast({ toast({
title: "Invalid amount", title: "Invalid amount",
@@ -64,7 +78,8 @@ export function useCoins() {
amount, amount,
description, description,
type: 'MANUAL_ADJUSTMENT', type: 'MANUAL_ADJUSTMENT',
note note,
userId: user?.id
}) })
setCoins(data) setCoins(data)
toast({ title: "Success", description: `Added ${amount} coins` }) toast({ title: "Success", description: `Added ${amount} coins` })
@@ -72,7 +87,7 @@ export function useCoins() {
} }
const remove = async (amount: number, description: string, note?: string) => { const remove = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(user, 'coins', 'write')) return null if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
const numAmount = Math.abs(amount) const numAmount = Math.abs(amount)
if (isNaN(numAmount) || numAmount <= 0) { if (isNaN(numAmount) || numAmount <= 0) {
toast({ toast({
@@ -86,7 +101,8 @@ export function useCoins() {
amount: numAmount, amount: numAmount,
description, description,
type: 'MANUAL_ADJUSTMENT', type: 'MANUAL_ADJUSTMENT',
note note,
userId: user?.id
}) })
setCoins(data) setCoins(data)
toast({ title: "Success", description: `Removed ${numAmount} coins` }) toast({ title: "Success", description: `Removed ${numAmount} coins` })
@@ -94,7 +110,7 @@ export function useCoins() {
} }
const updateNote = async (transactionId: string, note: string) => { const updateNote = async (transactionId: string, note: string) => {
if (!handlePermissionCheck(user, 'coins', 'write')) return null if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
const transaction = coins.transactions.find(t => t.id === transactionId) const transaction = coins.transactions.find(t => t.id === transactionId)
if (!transaction) { if (!transaction) {
toast({ toast({
@@ -128,7 +144,7 @@ export function useCoins() {
remove, remove,
updateNote, updateNote,
balance, balance,
transactions: coins.transactions, transactions: transactions,
coinsEarnedToday, coinsEarnedToday,
totalEarned, totalEarned,
totalSpent, totalSpent,

View File

@@ -1,11 +1,12 @@
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { wishlistAtom, coinsAtom, coinsBalanceAtom } from '@/lib/atoms' import { wishlistAtom, coinsAtom } from '@/lib/atoms'
import { saveWishlistItems, removeCoins } from '@/app/actions/data' import { saveWishlistItems, removeCoins } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast' import { toast } from '@/hooks/use-toast'
import { WishlistItemType } from '@/lib/types' import { WishlistItemType } from '@/lib/types'
import { celebrations } from '@/utils/celebrations' import { celebrations } from '@/utils/celebrations'
import { checkPermission } from '@/lib/utils' import { checkPermission } from '@/lib/utils'
import { useHelpers } from '@/lib/client-helpers' import { useHelpers } from '@/lib/client-helpers'
import { useCoins } from './useCoins'
function handlePermissionCheck( function handlePermissionCheck(
user: any, user: any,
@@ -37,7 +38,7 @@ export function useWishlist() {
const { currentUser: user } = useHelpers() const { currentUser: user } = useHelpers()
const [wishlist, setWishlist] = useAtom(wishlistAtom) const [wishlist, setWishlist] = useAtom(wishlistAtom)
const [coins, setCoins] = useAtom(coinsAtom) const [coins, setCoins] = useAtom(coinsAtom)
const [balance] = useAtom(coinsBalanceAtom) const { balance } = useCoins()
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => { const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
if (!handlePermissionCheck(user, 'wishlist', 'write')) return if (!handlePermissionCheck(user, 'wishlist', 'write')) return

View File

@@ -49,44 +49,44 @@ export const coinsAtom = atom(getDefaultCoinsData());
export const wishlistAtom = atom(getDefaultWishlistData()); export const wishlistAtom = atom(getDefaultWishlistData());
export const serverSettingsAtom = atom(getDefaultServerSettings()); export const serverSettingsAtom = atom(getDefaultServerSettings());
// Derived atom for coins earned today // // Derived atom for coins earned today
export const coinsEarnedTodayAtom = atom((get) => { // export const coinsEarnedTodayAtom = atom((get) => {
const coins = get(coinsAtom); // const coins = get(coinsAtom);
const settings = get(settingsAtom); // const settings = get(settingsAtom);
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone); // return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
}); // });
// Derived atom for total earned // // Derived atom for total earned
export const totalEarnedAtom = atom((get) => { // export const totalEarnedAtom = atom((get) => {
const coins = get(coinsAtom); // const coins = get(coinsAtom);
return calculateTotalEarned(coins.transactions); // return calculateTotalEarned(coins.transactions);
}); // });
// Derived atom for total spent // // Derived atom for total spent
export const totalSpentAtom = atom((get) => { // export const totalSpentAtom = atom((get) => {
const coins = get(coinsAtom); // const coins = get(coinsAtom);
return calculateTotalSpent(coins.transactions); // return calculateTotalSpent(coins.transactions);
}); // });
// Derived atom for coins spent today // // Derived atom for coins spent today
export const coinsSpentTodayAtom = atom((get) => { // export const coinsSpentTodayAtom = atom((get) => {
const coins = get(coinsAtom); // const coins = get(coinsAtom);
const settings = get(settingsAtom); // const settings = get(settingsAtom);
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone); // return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
}); // });
// Derived atom for transactions today // // Derived atom for transactions today
export const transactionsTodayAtom = atom((get) => { // export const transactionsTodayAtom = atom((get) => {
const coins = get(coinsAtom); // const coins = get(coinsAtom);
const settings = get(settingsAtom); // const settings = get(settingsAtom);
return calculateTransactionsToday(coins.transactions, settings.system.timezone); // return calculateTransactionsToday(coins.transactions, settings.system.timezone);
}); // });
// Derived atom for current balance from all transactions // // Derived atom for current balance from all transactions
export const coinsBalanceAtom = atom((get) => { // export const coinsBalanceAtom = atom((get) => {
const coins = get(coinsAtom); // const coins = get(coinsAtom);
return coins.transactions.reduce((sum, transaction) => sum + transaction.amount, 0); // return coins.transactions.reduce((sum, transaction) => sum + transaction.amount, 0);
}); // });
/* transient atoms */ /* transient atoms */
interface PomodoroAtom { interface PomodoroAtom {

View File

@@ -2,13 +2,13 @@ import { z } from "zod"
const zodEnv = z.object({ const zodEnv = z.object({
AUTH_SECRET: z.string(), AUTH_SECRET: z.string(),
NEXT_PUBLIC_DEMO: z.string().optional(), DEMO: z.string().optional(),
}) })
declare global { declare global {
interface ProcessEnv extends z.TypeOf<typeof zodEnv> { interface ProcessEnv extends z.TypeOf<typeof zodEnv> {
AUTH_SECRET: string; AUTH_SECRET: string;
NEXT_PUBLIC_DEMO?: string; DEMO?: string;
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.2.3", "version": "0.2.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",