Adds permission management components
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.
This commit is contained in:
parent
e745caffaa
commit
6ecaafb633
2 changed files with 655 additions and 0 deletions
256
src/modules/expenses/components/admin/GrantPermissionDialog.vue
Normal file
256
src/modules/expenses/components/admin/GrantPermissionDialog.vue
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
<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>
|
||||||
399
src/modules/expenses/components/admin/PermissionManager.vue
Normal file
399
src/modules/expenses/components/admin/PermissionManager.vue
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
<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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue