Compare commits
22 commits
67a070e9b3
...
08f1743557
| Author | SHA1 | Date | |
|---|---|---|---|
| 08f1743557 | |||
| 148e92b7aa | |||
| 2ea6afe487 | |||
| d029660ef0 | |||
| ef1acc17c3 | |||
| 933a166d05 | |||
| a12ed8dd6a | |||
| 5517aebb6a | |||
| 0f133119c4 | |||
| 5aced3f2c9 | |||
| 78e3d56d76 | |||
| a605b31c5f | |||
| 00959074da | |||
| b4dd7f2bbc | |||
| b19fa98d5b | |||
| fa1dff9de4 | |||
| 1f20d5f00c | |||
| 75dfd8a541 | |||
| 4af220adda | |||
| 1fbf7b3d26 | |||
| e9195978c1 | |||
| 4c704e5a41 |
6 changed files with 128 additions and 856 deletions
|
|
@ -98,8 +98,11 @@ async function loadData() {
|
|||
totalIncomeSats.value = balanceData.total_income_sats || 0
|
||||
totalIncomeFiat.value = balanceData.total_income_fiat || {}
|
||||
|
||||
// Filter for pending transactions (flag = '!')
|
||||
pendingTransactions.value = txData.entries.filter(tx => tx.flag === '!')
|
||||
// Filter for pending transactions (flag = '!'), excluding voided ones
|
||||
// (libra convention: voided keeps '!' flag and carries a 'voided' tag).
|
||||
pendingTransactions.value = txData.entries.filter(
|
||||
tx => tx.flag === '!' && !tx.tags?.includes('voided')
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[BalancePage] Error loading data:', error)
|
||||
toast.error('Failed to load balance data')
|
||||
|
|
|
|||
|
|
@ -1,256 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ExpensesAPI } from '../../services/ExpensesAPI'
|
||||
import type { Account } from '../../types'
|
||||
import { PermissionType } from '../../types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
accounts: Account[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
permissionGranted: []
|
||||
}>()
|
||||
|
||||
const { user } = useAuth()
|
||||
const toast = useToast()
|
||||
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
||||
|
||||
const isGranting = ref(false)
|
||||
|
||||
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
|
||||
|
||||
// Form schema
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
user_id: z.string().min(1, 'User ID is required'),
|
||||
account_id: z.string().min(1, 'Account is required'),
|
||||
permission_type: z.nativeEnum(PermissionType, {
|
||||
errorMap: () => ({ message: 'Permission type is required' })
|
||||
}),
|
||||
notes: z.string().optional(),
|
||||
expires_at: z.string().optional()
|
||||
})
|
||||
)
|
||||
|
||||
// Setup form
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
user_id: '',
|
||||
account_id: '',
|
||||
permission_type: PermissionType.READ,
|
||||
notes: '',
|
||||
expires_at: ''
|
||||
}
|
||||
})
|
||||
|
||||
const { resetForm, meta } = form
|
||||
const isFormValid = computed(() => meta.value.valid)
|
||||
|
||||
// Permission type options
|
||||
const permissionTypes = [
|
||||
{ value: PermissionType.READ, label: 'Read', description: 'View account and balance' },
|
||||
{
|
||||
value: PermissionType.SUBMIT_EXPENSE,
|
||||
label: 'Submit Expense',
|
||||
description: 'Submit expenses to this account'
|
||||
},
|
||||
{ value: PermissionType.MANAGE, label: 'Manage', description: 'Full account management' }
|
||||
]
|
||||
|
||||
// Submit form
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
if (!adminKey.value) {
|
||||
toast.error('Admin access required')
|
||||
return
|
||||
}
|
||||
|
||||
isGranting.value = true
|
||||
|
||||
try {
|
||||
await expensesAPI.grantPermission(adminKey.value, {
|
||||
user_id: values.user_id,
|
||||
account_id: values.account_id,
|
||||
permission_type: values.permission_type,
|
||||
notes: values.notes || undefined,
|
||||
expires_at: values.expires_at || undefined
|
||||
})
|
||||
|
||||
emit('permissionGranted')
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
console.error('Failed to grant permission:', error)
|
||||
toast.error('Failed to grant permission', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
} finally {
|
||||
isGranting.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Handle dialog close
|
||||
function handleClose() {
|
||||
if (!isGranting.value) {
|
||||
resetForm()
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="props.isOpen" @update:open="handleClose">
|
||||
<DialogContent class="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Grant Account Permission</DialogTitle>
|
||||
<DialogDescription>
|
||||
Grant a user permission to access an expense account. Permissions on parent accounts
|
||||
cascade to children.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit="onSubmit" class="space-y-4">
|
||||
<!-- User ID -->
|
||||
<FormField v-slot="{ componentField }" name="user_id">
|
||||
<FormItem>
|
||||
<FormLabel>User ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter user wallet ID"
|
||||
v-bind="componentField"
|
||||
:disabled="isGranting"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>The wallet ID of the user to grant permission to</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Account -->
|
||||
<FormField v-slot="{ componentField }" name="account_id">
|
||||
<FormItem>
|
||||
<FormLabel>Account *</FormLabel>
|
||||
<Select v-bind="componentField" :disabled="isGranting">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select account" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="account in props.accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Account to grant access to</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Permission Type -->
|
||||
<FormField v-slot="{ componentField }" name="permission_type">
|
||||
<FormItem>
|
||||
<FormLabel>Permission Type *</FormLabel>
|
||||
<Select v-bind="componentField" :disabled="isGranting">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select permission type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="type in permissionTypes"
|
||||
:key="type.value"
|
||||
:value="type.value"
|
||||
>
|
||||
<div>
|
||||
<div class="font-medium">{{ type.label }}</div>
|
||||
<div class="text-sm text-muted-foreground">{{ type.description }}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Type of permission to grant</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Expiration Date (Optional) -->
|
||||
<FormField v-slot="{ componentField }" name="expires_at">
|
||||
<FormItem>
|
||||
<FormLabel>Expiration Date (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
v-bind="componentField"
|
||||
:disabled="isGranting"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Leave empty for permanent access</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Notes (Optional) -->
|
||||
<FormField v-slot="{ componentField }" name="notes">
|
||||
<FormItem>
|
||||
<FormLabel>Notes (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Add notes about this permission..."
|
||||
v-bind="componentField"
|
||||
:disabled="isGranting"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Optional notes for admin reference</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="handleClose"
|
||||
:disabled="isGranting"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" :disabled="isGranting || !isFormValid">
|
||||
<Loader2 v-if="isGranting" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ isGranting ? 'Granting...' : 'Grant Permission' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -1,399 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ExpensesAPI } from '../../services/ExpensesAPI'
|
||||
import type { AccountPermission, Account } from '../../types'
|
||||
import { PermissionType } from '../../types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2, Plus, Trash2, Users, Shield } from 'lucide-vue-next'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
import GrantPermissionDialog from './GrantPermissionDialog.vue'
|
||||
|
||||
const { user } = useAuth()
|
||||
const toast = useToast()
|
||||
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
||||
|
||||
const permissions = ref<AccountPermission[]>([])
|
||||
const accounts = ref<Account[]>([])
|
||||
const isLoading = ref(false)
|
||||
const showGrantDialog = ref(false)
|
||||
const permissionToRevoke = ref<AccountPermission | null>(null)
|
||||
const showRevokeDialog = ref(false)
|
||||
|
||||
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
|
||||
|
||||
// Get permission type badge variant
|
||||
function getPermissionBadge(type: PermissionType) {
|
||||
switch (type) {
|
||||
case PermissionType.READ:
|
||||
return 'default'
|
||||
case PermissionType.SUBMIT_EXPENSE:
|
||||
return 'secondary'
|
||||
case PermissionType.MANAGE:
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
// Get permission type label
|
||||
function getPermissionLabel(type: PermissionType): string {
|
||||
switch (type) {
|
||||
case PermissionType.READ:
|
||||
return 'Read'
|
||||
case PermissionType.SUBMIT_EXPENSE:
|
||||
return 'Submit Expense'
|
||||
case PermissionType.MANAGE:
|
||||
return 'Manage'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
// Get account name by ID
|
||||
function getAccountName(accountId: string): string {
|
||||
const account = accounts.value.find((a) => a.id === accountId)
|
||||
return account?.name || accountId
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
|
||||
}
|
||||
|
||||
// Load accounts
|
||||
async function loadAccounts() {
|
||||
if (!adminKey.value) return
|
||||
|
||||
try {
|
||||
accounts.value = await expensesAPI.getAccounts(adminKey.value)
|
||||
} catch (error) {
|
||||
console.error('Failed to load accounts:', error)
|
||||
toast.error('Failed to load accounts', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Load all permissions
|
||||
async function loadPermissions() {
|
||||
if (!adminKey.value) {
|
||||
toast.error('Admin access required', {
|
||||
description: 'You need admin privileges to manage permissions'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
permissions.value = await expensesAPI.listPermissions(adminKey.value)
|
||||
} catch (error) {
|
||||
console.error('Failed to load permissions:', error)
|
||||
toast.error('Failed to load permissions', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle permission granted
|
||||
function handlePermissionGranted() {
|
||||
showGrantDialog.value = false
|
||||
loadPermissions()
|
||||
toast.success('Permission granted', {
|
||||
description: 'User permission has been successfully granted'
|
||||
})
|
||||
}
|
||||
|
||||
// Confirm revoke permission
|
||||
function confirmRevoke(permission: AccountPermission) {
|
||||
permissionToRevoke.value = permission
|
||||
showRevokeDialog.value = true
|
||||
}
|
||||
|
||||
// Revoke permission
|
||||
async function revokePermission() {
|
||||
if (!adminKey.value || !permissionToRevoke.value) return
|
||||
|
||||
try {
|
||||
await expensesAPI.revokePermission(adminKey.value, permissionToRevoke.value.id)
|
||||
toast.success('Permission revoked', {
|
||||
description: 'User permission has been successfully revoked'
|
||||
})
|
||||
loadPermissions()
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke permission:', error)
|
||||
toast.error('Failed to revoke permission', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
} finally {
|
||||
showRevokeDialog.value = false
|
||||
permissionToRevoke.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Group permissions by user
|
||||
const permissionsByUser = computed(() => {
|
||||
const grouped = new Map<string, AccountPermission[]>()
|
||||
|
||||
for (const permission of permissions.value) {
|
||||
const existing = grouped.get(permission.user_id) || []
|
||||
existing.push(permission)
|
||||
grouped.set(permission.user_id, existing)
|
||||
}
|
||||
|
||||
return grouped
|
||||
})
|
||||
|
||||
// Group permissions by account
|
||||
const permissionsByAccount = computed(() => {
|
||||
const grouped = new Map<string, AccountPermission[]>()
|
||||
|
||||
for (const permission of permissions.value) {
|
||||
const existing = grouped.get(permission.account_id) || []
|
||||
existing.push(permission)
|
||||
grouped.set(permission.account_id, existing)
|
||||
}
|
||||
|
||||
return grouped
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadAccounts()
|
||||
loadPermissions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto p-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Permission Management</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Manage user access to expense accounts
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="showGrantDialog = true" :disabled="isLoading">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Grant Permission
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Shield class="h-5 w-5" />
|
||||
Account Permissions
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage all account permissions. Permissions on parent accounts cascade to
|
||||
children.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs default-value="by-user" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="by-user">
|
||||
<Users class="mr-2 h-4 w-4" />
|
||||
By User
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="by-account">
|
||||
<Shield class="mr-2 h-4 w-4" />
|
||||
By Account
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- By User View -->
|
||||
<TabsContent value="by-user" class="space-y-4">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="permissionsByUser.size === 0" class="text-center py-8">
|
||||
<p class="text-muted-foreground">No permissions granted yet</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="[userId, userPermissions] in permissionsByUser"
|
||||
:key="userId"
|
||||
class="border rounded-lg p-4"
|
||||
>
|
||||
<h3 class="font-semibold mb-2">User: {{ userId }}</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Account</TableHead>
|
||||
<TableHead>Permission</TableHead>
|
||||
<TableHead>Granted</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Notes</TableHead>
|
||||
<TableHead class="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="permission in userPermissions" :key="permission.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ getAccountName(permission.account_id) }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="getPermissionBadge(permission.permission_type)">
|
||||
{{ getPermissionLabel(permission.permission_type) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
|
||||
<TableCell>
|
||||
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ permission.notes || '-' }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="confirmRevoke(permission)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- By Account View -->
|
||||
<TabsContent value="by-account" class="space-y-4">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="permissionsByAccount.size === 0" class="text-center py-8">
|
||||
<p class="text-muted-foreground">No permissions granted yet</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="[accountId, accountPermissions] in permissionsByAccount"
|
||||
:key="accountId"
|
||||
class="border rounded-lg p-4"
|
||||
>
|
||||
<h3 class="font-semibold mb-2">Account: {{ getAccountName(accountId) }}</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Permission</TableHead>
|
||||
<TableHead>Granted</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Notes</TableHead>
|
||||
<TableHead class="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="permission in accountPermissions" :key="permission.id">
|
||||
<TableCell class="font-medium">{{ permission.user_id }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="getPermissionBadge(permission.permission_type)">
|
||||
{{ getPermissionLabel(permission.permission_type) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
|
||||
<TableCell>
|
||||
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ permission.notes || '-' }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="confirmRevoke(permission)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Grant Permission Dialog -->
|
||||
<GrantPermissionDialog
|
||||
:is-open="showGrantDialog"
|
||||
:accounts="accounts"
|
||||
@close="showGrantDialog = false"
|
||||
@permission-granted="handlePermissionGranted"
|
||||
/>
|
||||
|
||||
<!-- Revoke Confirmation Dialog -->
|
||||
<AlertDialog :open="showRevokeDialog" @update:open="showRevokeDialog = $event">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke Permission?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to revoke this permission? The user will immediately lose access.
|
||||
<div v-if="permissionToRevoke" class="mt-4 p-4 bg-muted rounded-md">
|
||||
<p class="font-medium">Permission Details:</p>
|
||||
<p class="text-sm mt-2">
|
||||
<strong>User:</strong> {{ permissionToRevoke.user_id }}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
<strong>Account:</strong> {{ getAccountName(permissionToRevoke.account_id) }}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
<strong>Type:</strong> {{ getPermissionLabel(permissionToRevoke.permission_type) }}
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="revokePermission" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -11,8 +11,6 @@ import type {
|
|||
IncomeEntry,
|
||||
AccountNode,
|
||||
UserInfo,
|
||||
AccountPermission,
|
||||
GrantPermissionRequest,
|
||||
TransactionListResponse
|
||||
} from '../types'
|
||||
import { appConfig } from '@/app.config'
|
||||
|
|
@ -343,93 +341,6 @@ export class ExpensesAPI extends BaseService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all account permissions (admin only)
|
||||
*
|
||||
* @param adminKey - Admin key for authentication
|
||||
*/
|
||||
async listPermissions(adminKey: string): Promise<AccountPermission[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/libra/api/v1/permissions`, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(adminKey),
|
||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list permissions: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const permissions = await response.json()
|
||||
return permissions as AccountPermission[]
|
||||
} catch (error) {
|
||||
console.error('[ExpensesAPI] Error listing permissions:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant account permission to a user (admin only)
|
||||
*
|
||||
* @param adminKey - Admin key for authentication
|
||||
* @param request - Permission grant request
|
||||
*/
|
||||
async grantPermission(
|
||||
adminKey: string,
|
||||
request: GrantPermissionRequest
|
||||
): Promise<AccountPermission> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/libra/api/v1/permissions`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(adminKey),
|
||||
body: JSON.stringify(request),
|
||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const errorMessage =
|
||||
errorData.detail || `Failed to grant permission: ${response.statusText}`
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const permission = await response.json()
|
||||
return permission as AccountPermission
|
||||
} catch (error) {
|
||||
console.error('[ExpensesAPI] Error granting permission:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke account permission (admin only)
|
||||
*
|
||||
* @param adminKey - Admin key for authentication
|
||||
* @param permissionId - ID of the permission to revoke
|
||||
*/
|
||||
async revokePermission(adminKey: string, permissionId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/libra/api/v1/permissions/${permissionId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(adminKey),
|
||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const errorMessage =
|
||||
errorData.detail || `Failed to revoke permission: ${response.statusText}`
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ExpensesAPI] Error revoking permission:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's transactions from journal
|
||||
*
|
||||
|
|
|
|||
|
|
@ -28,25 +28,6 @@ export interface Account {
|
|||
has_children?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Account with user-specific permission metadata
|
||||
* (Will be available once libra API implements permissions)
|
||||
*/
|
||||
export interface AccountWithPermissions extends Account {
|
||||
user_permissions?: PermissionType[]
|
||||
inherited_from?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission types for account access control
|
||||
*/
|
||||
export enum PermissionType {
|
||||
READ = 'read',
|
||||
SUBMIT_EXPENSE = 'submit_expense',
|
||||
SUBMIT_INCOME = 'submit_income',
|
||||
MANAGE = 'manage'
|
||||
}
|
||||
|
||||
/**
|
||||
* Expense entry request payload
|
||||
*/
|
||||
|
|
@ -125,31 +106,6 @@ export interface UserInfo {
|
|||
equity_account_name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Account permission for user access control
|
||||
*/
|
||||
export interface AccountPermission {
|
||||
id: string
|
||||
user_id: string
|
||||
account_id: string
|
||||
permission_type: PermissionType
|
||||
granted_at: string
|
||||
granted_by: string
|
||||
expires_at?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant permission request payload
|
||||
*/
|
||||
export interface GrantPermissionRequest {
|
||||
user_id: string
|
||||
account_id: string
|
||||
permission_type: PermissionType
|
||||
expires_at?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaction entry from journal (user view)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -12,10 +12,6 @@ import { Button } from '@/components/ui/button'
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Flag,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
Filter
|
||||
|
|
@ -30,14 +26,26 @@ const isLoading = ref(false)
|
|||
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
|
||||
const customStartDate = ref<string>('')
|
||||
const customEndDate = ref<string>('')
|
||||
const typeFilter = ref<'all' | 'income' | 'expense'>('all')
|
||||
// Each chip is an inclusion toggle for one bucket of rows. Every row
|
||||
// belongs to exactly one bucket (voided rows go to 'voided' regardless
|
||||
// of their income/expense type). Default hides voided.
|
||||
type Category = 'income' | 'expense' | 'voided'
|
||||
|
||||
const typeFilterOptions = [
|
||||
{ label: 'All', value: 'all' as const },
|
||||
{ label: 'Income', value: 'income' as const },
|
||||
{ label: 'Expenses', value: 'expense' as const }
|
||||
const activeCategories = ref<Set<Category>>(new Set<Category>(['income', 'expense']))
|
||||
|
||||
const categoryChips: { label: string; value: Category }[] = [
|
||||
{ label: 'Income', value: 'income' },
|
||||
{ label: 'Expenses', value: 'expense' },
|
||||
{ label: 'Voided', value: 'voided' }
|
||||
]
|
||||
|
||||
function toggleCategory(cat: Category) {
|
||||
const next = new Set(activeCategories.value)
|
||||
if (next.has(cat)) next.delete(cat)
|
||||
else next.add(cat)
|
||||
activeCategories.value = next
|
||||
}
|
||||
|
||||
function isIncome(t: Transaction): boolean {
|
||||
return t.tags?.includes('income-entry') ?? false
|
||||
}
|
||||
|
|
@ -46,6 +54,22 @@ function isExpense(t: Transaction): boolean {
|
|||
return t.tags?.includes('expense-entry') ?? false
|
||||
}
|
||||
|
||||
function isVoided(t: Transaction): boolean {
|
||||
return t.tags?.includes('voided') ?? false
|
||||
}
|
||||
|
||||
function isPending(t: Transaction): boolean {
|
||||
return t.flag === '!' && !isVoided(t)
|
||||
}
|
||||
|
||||
// Which chip bucket a row falls into. Voided always wins over type.
|
||||
function getBucket(t: Transaction): Category | null {
|
||||
if (isVoided(t)) return 'voided'
|
||||
if (isIncome(t)) return 'income'
|
||||
if (isExpense(t)) return 'expense'
|
||||
return null
|
||||
}
|
||||
|
||||
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
||||
|
||||
// Fuzzy search state and configuration
|
||||
|
|
@ -71,12 +95,13 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
|
|||
matchAllWhenSearchEmpty: true
|
||||
}
|
||||
|
||||
// Transactions to display (search results or all transactions), filtered by type
|
||||
// Transactions to display: row passes if its bucket's chip is active.
|
||||
const transactionsToDisplay = computed(() => {
|
||||
const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
|
||||
if (typeFilter.value === 'income') return base.filter(isIncome)
|
||||
if (typeFilter.value === 'expense') return base.filter(isExpense)
|
||||
return base
|
||||
return base.filter(t => {
|
||||
const bucket = getBucket(t)
|
||||
return bucket !== null && activeCategories.value.has(bucket)
|
||||
})
|
||||
})
|
||||
|
||||
// Handle search results
|
||||
|
|
@ -108,20 +133,28 @@ function formatAmount(amount: number): string {
|
|||
return new Intl.NumberFormat('en-US').format(amount)
|
||||
}
|
||||
|
||||
// Get status icon and color based on flag
|
||||
function getStatusInfo(flag?: string) {
|
||||
switch (flag) {
|
||||
case '*':
|
||||
return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' }
|
||||
case '!':
|
||||
return { icon: Clock, color: 'text-orange-600', label: 'Pending' }
|
||||
case '#':
|
||||
return { icon: Flag, color: 'text-red-600', label: 'Flagged' }
|
||||
case 'x':
|
||||
return { icon: XCircle, color: 'text-muted-foreground', label: 'Voided' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
// Income gets a leading '+', expense a leading '-'.
|
||||
function getAmountSign(t: Transaction): string {
|
||||
if (isIncome(t)) return '+'
|
||||
if (isExpense(t)) return '-'
|
||||
return ''
|
||||
}
|
||||
|
||||
// Color tint for the amount text. Voided entries drop to muted regardless
|
||||
// of type since the strike-through carries the "ignore this" signal.
|
||||
function getAmountColorClass(t: Transaction): string {
|
||||
if (isVoided(t)) return 'line-through text-muted-foreground'
|
||||
if (isIncome(t)) return 'text-green-600 dark:text-green-400'
|
||||
if (isExpense(t)) return 'text-red-600 dark:text-red-400'
|
||||
return ''
|
||||
}
|
||||
|
||||
// Tags that drive other visual channels (border / sign / strike-through) —
|
||||
// suppressed from the badge row so it only carries user-added tags.
|
||||
const TYPE_TAGS = new Set(['income-entry', 'expense-entry', 'voided'])
|
||||
|
||||
function getDisplayTags(t: Transaction): string[] {
|
||||
return (t.tags ?? []).filter(tag => !TYPE_TAGS.has(tag))
|
||||
}
|
||||
|
||||
// Load transactions
|
||||
|
|
@ -224,19 +257,20 @@ onMounted(() => {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Type Filter (All / Income / Expenses) -->
|
||||
<!-- Category chips: each chip toggles inclusion of one bucket
|
||||
of rows. Defaults: Income + Expenses on, Voided off. -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Filter class="h-4 w-4 text-muted-foreground" />
|
||||
<Button
|
||||
v-for="option in typeFilterOptions"
|
||||
:key="option.value"
|
||||
:variant="typeFilter === option.value ? 'default' : 'outline'"
|
||||
v-for="chip in categoryChips"
|
||||
:key="chip.value"
|
||||
:variant="activeCategories.has(chip.value) ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="h-7 md:h-8 px-3 text-xs"
|
||||
@click="typeFilter = option.value"
|
||||
@click="toggleCategory(chip.value)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
{{ option.label }}
|
||||
{{ chip.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -291,7 +325,7 @@ onMounted(() => {
|
|||
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }}
|
||||
{{ transactionsToDisplay.length }} transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -307,7 +341,9 @@ onMounted(() => {
|
|||
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
|
||||
<p class="text-muted-foreground">No transactions found</p>
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }}
|
||||
<template v-if="searchResults.length > 0">Try a different search term</template>
|
||||
<template v-else-if="activeCategories.size === 0">Select a category above to see transactions</template>
|
||||
<template v-else>Try selecting a different time period or toggling more categories</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -316,29 +352,19 @@ onMounted(() => {
|
|||
<div
|
||||
v-for="transaction in transactionsToDisplay"
|
||||
:key="transaction.id"
|
||||
:class="[
|
||||
'border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors',
|
||||
isIncome(transaction) && 'border-l-4 border-l-green-600',
|
||||
isExpense(transaction) && 'border-l-4 border-l-red-600'
|
||||
]"
|
||||
class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<!-- Transaction Header -->
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<!-- Status Icon -->
|
||||
<component
|
||||
v-if="getStatusInfo(transaction.flag)"
|
||||
:is="getStatusInfo(transaction.flag)!.icon"
|
||||
<h3
|
||||
:class="[
|
||||
'h-4 w-4 flex-shrink-0',
|
||||
getStatusInfo(transaction.flag)!.color
|
||||
'font-medium text-sm sm:text-base truncate mb-1',
|
||||
isVoided(transaction) && 'line-through text-muted-foreground'
|
||||
]"
|
||||
/>
|
||||
<h3 class="font-medium text-sm sm:text-base truncate">
|
||||
>
|
||||
{{ transaction.description }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm text-muted-foreground">
|
||||
{{ formatDate(transaction.date) }}
|
||||
</p>
|
||||
|
|
@ -346,11 +372,17 @@ onMounted(() => {
|
|||
|
||||
<!-- Amount -->
|
||||
<div class="text-right flex-shrink-0">
|
||||
<p class="font-semibold text-sm sm:text-base">
|
||||
{{ formatAmount(transaction.amount) }} sats
|
||||
<p :class="['font-semibold text-sm sm:text-base', getAmountColorClass(transaction)]">
|
||||
{{ getAmountSign(transaction) }}{{ formatAmount(transaction.amount) }} sats
|
||||
</p>
|
||||
<p v-if="transaction.fiat_amount" class="text-xs text-muted-foreground">
|
||||
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
|
||||
<p
|
||||
v-if="transaction.fiat_amount"
|
||||
:class="[
|
||||
'text-xs',
|
||||
getAmountColorClass(transaction) || 'text-muted-foreground'
|
||||
]"
|
||||
>
|
||||
{{ getAmountSign(transaction) }}{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -367,17 +399,42 @@ onMounted(() => {
|
|||
<span class="font-medium">Ref:</span> {{ transaction.reference }}
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
|
||||
<!-- Badges: type (Income / Expense) + status (Voided / Pending,
|
||||
mutually exclusive) + any user-added tags. -->
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
<Badge
|
||||
v-for="tag in transaction.tags"
|
||||
v-if="isIncome(transaction)"
|
||||
variant="secondary"
|
||||
class="text-xs bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300"
|
||||
>
|
||||
Income
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="isExpense(transaction)"
|
||||
variant="secondary"
|
||||
class="text-xs bg-orange-100 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300"
|
||||
>
|
||||
Expense
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="isVoided(transaction)"
|
||||
variant="outline"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
Voided
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="isPending(transaction)"
|
||||
variant="secondary"
|
||||
class="text-xs bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300"
|
||||
>
|
||||
Pending approval
|
||||
</Badge>
|
||||
<Badge
|
||||
v-for="tag in getDisplayTags(transaction)"
|
||||
:key="tag"
|
||||
variant="secondary"
|
||||
:class="[
|
||||
'text-xs',
|
||||
tag === 'income-entry' && 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
||||
tag === 'expense-entry' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300'
|
||||
]"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue