chore(expenses): delete orphaned admin permission UI

PermissionManager.vue and GrantPermissionDialog.vue were never
imported or routed anywhere; the three ExpensesAPI methods backing
them (listPermissions, grantPermission, revokePermission) pointed
at /libra/api/v1/permissions* which doesn't exist on the backend
(real path is /api/v1/admin/permissions*). The whole feature has
been unreachable since whenever the path drifted.

Removes the two components, the three API methods, and the four
types only they used (AccountPermission, GrantPermissionRequest,
AccountWithPermissions, PermissionType).

If cross-account permission management becomes a real need, the
backend at aiolabs/libra already provides the endpoints (now
correctly gated by require_super_user); rebuild the UI fresh
against the right paths rather than reviving this dead surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-05 23:17:52 +02:00
commit 4c704e5a41
4 changed files with 0 additions and 788 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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
*

View file

@ -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)
*/