Implements components for granting and revoking account permissions. This introduces a `GrantPermissionDialog` for assigning access rights to users, and a `PermissionManager` component to list and revoke existing permissions. The UI provides options to view permissions grouped by user or by account.
256 lines
7.8 KiB
Vue
256 lines
7.8 KiB
Vue
<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>
|