WIP - permissions

This commit is contained in:
Francisco Gaona
2025-12-28 05:43:03 +01:00
parent f4143ab106
commit 88f656c3f5
35 changed files with 3040 additions and 53 deletions

View File

@@ -0,0 +1,262 @@
<template>
<div class="space-y-6">
<div v-if="loading" class="text-center py-8">Loading access settings...</div>
<div v-else class="space-y-6">
<!-- Global Access Model -->
<Card>
<CardHeader>
<CardTitle>Global Access Model</CardTitle>
<CardDescription>
Define the default access control model for this object
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label>Access Model</Label>
<Select v-model="accessModel">
<SelectTrigger>
<SelectValue placeholder="Select access model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="public">Public - Anyone can access</SelectItem>
<SelectItem value="owner">Owner Only - Only record owner can access</SelectItem>
<SelectItem value="mixed">Mixed - Owner plus role/share-based access</SelectItem>
</SelectContent>
</Select>
<p class="text-sm text-muted-foreground">
<span v-if="accessModel === 'public'">
All users can access records by default
</span>
<span v-else-if="accessModel === 'owner'">
Only the record owner can access records
</span>
<span v-else-if="accessModel === 'mixed'">
Record owner has access, plus role-based and sharing rules apply
</span>
</p>
</div>
<div class="space-y-2">
<Label>Owner Field</Label>
<Input v-model="ownerField" placeholder="ownerId" />
<p class="text-sm text-muted-foreground">
The field name that stores the record owner's ID
</p>
</div>
<div class="space-y-3">
<Label>Public Permissions</Label>
<div class="space-y-2">
<div class="flex items-center space-x-2">
<Checkbox
id="public-read"
v-model:checked="publicRead"
/>
<Label for="public-read" class="cursor-pointer font-normal">Public Read</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="public-create"
v-model:checked="publicCreate"
/>
<Label for="public-create" class="cursor-pointer font-normal">Public Create</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="public-update"
v-model:checked="publicUpdate"
/>
<Label for="public-update" class="cursor-pointer font-normal">Public Update</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="public-delete"
v-model:checked="publicDelete"
/>
<Label for="public-delete" class="cursor-pointer font-normal">Public Delete</Label>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Field-Level Permissions -->
<Card>
<CardHeader>
<CardTitle>Field-Level Permissions</CardTitle>
<CardDescription>
Set default read/write permissions for individual fields
</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-2">
<div
v-for="field in fields"
:key="field.apiName"
class="flex items-center justify-between p-3 border rounded-lg"
>
<div class="flex-1">
<div class="font-medium">{{ field.label }}</div>
<div class="text-sm text-muted-foreground">{{ field.apiName }}</div>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center space-x-2">
<Checkbox
:id="`${field.apiName}-read`"
:checked="getFieldPermission(field.apiName, 'read')"
@update:checked="(val) => setFieldPermission(field.apiName, 'read', val)"
/>
<Label :for="`${field.apiName}-read`" class="cursor-pointer">Read</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
:id="`${field.apiName}-write`"
:checked="getFieldPermission(field.apiName, 'write')"
@update:checked="(val) => setFieldPermission(field.apiName, 'write', val)"
/>
<Label :for="`${field.apiName}-write`" class="cursor-pointer">Write</Label>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Save Button -->
<div class="flex justify-end">
<Button @click="saveChanges" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Changes' }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
interface Props {
objectApiName: string
fields: any[]
}
const props = defineProps<Props>()
const emit = defineEmits(['updated'])
const { api } = useApi()
const { toast } = useToast()
const loading = ref(true)
const saving = ref(false)
const accessModel = ref<string>('owner')
const publicRead = ref<boolean>(false)
const publicCreate = ref<boolean>(false)
const publicUpdate = ref<boolean>(false)
const publicDelete = ref<boolean>(false)
const ownerField = ref<string>('ownerId')
const fieldPermissions = ref<Record<string, { defaultReadable: boolean; defaultWritable: boolean }>>({})
const fetchAccessConfig = async () => {
try {
loading.value = true
const data = await api.get(`/setup/objects/${props.objectApiName}/access`)
accessModel.value = data.accessModel || 'owner'
publicRead.value = Boolean(data.publicRead)
publicCreate.value = Boolean(data.publicCreate)
publicUpdate.value = Boolean(data.publicUpdate)
publicDelete.value = Boolean(data.publicDelete)
ownerField.value = data.ownerField || 'ownerId'
// Initialize field permissions from field definitions
fieldPermissions.value = {}
if (data.fields && data.fields.length > 0) {
data.fields.forEach((field: any) => {
fieldPermissions.value[field.apiName] = {
defaultReadable: Boolean(field.defaultReadable ?? true),
defaultWritable: Boolean(field.defaultWritable ?? true),
}
})
} else {
// Initialize all fields with default permissions
props.fields.forEach((field) => {
fieldPermissions.value[field.apiName] = {
defaultReadable: true,
defaultWritable: true,
}
})
}
} catch (e: any) {
console.error('Error fetching access config:', e)
toast.error('Failed to load access settings')
} finally {
loading.value = false
}
}
const getFieldPermission = (fieldKey: string, type: 'read' | 'write'): boolean => {
const perms = fieldPermissions.value[fieldKey]
if (!perms) return true
const value = type === 'read' ? perms.defaultReadable : perms.defaultWritable
return Boolean(value)
}
const setFieldPermission = (fieldKey: string, type: 'read' | 'write', value: boolean) => {
if (!fieldPermissions.value[fieldKey]) {
fieldPermissions.value[fieldKey] = { defaultReadable: true, defaultWritable: true }
}
if (type === 'read') {
fieldPermissions.value[fieldKey].defaultReadable = Boolean(value)
} else {
fieldPermissions.value[fieldKey].defaultWritable = Boolean(value)
}
}
const saveChanges = async () => {
try {
saving.value = true
// Ensure all values are proper booleans
const payload = {
accessModel: accessModel.value,
publicRead: Boolean(publicRead.value),
publicCreate: Boolean(publicCreate.value),
publicUpdate: Boolean(publicUpdate.value),
publicDelete: Boolean(publicDelete.value),
ownerField: ownerField.value,
}
// Update global access config
await api.put(`/setup/objects/${props.objectApiName}/access`, payload)
// Update field permissions
const fieldPermsArray = Object.entries(fieldPermissions.value).map(([fieldKey, perms]) => ({
fieldKey,
defaultReadable: perms.defaultReadable,
defaultWritable: perms.defaultWritable,
}))
await api.put(`/setup/objects/${props.objectApiName}/field-permissions`, fieldPermsArray)
toast.success('Access settings saved successfully')
emit('updated')
} catch (e: any) {
console.error('Error saving access config:', e)
toast.error('Failed to save access settings')
} finally {
saving.value = false
}
}
onMounted(() => {
fetchAccessConfig()
})
</script>

View File

@@ -0,0 +1,284 @@
<template>
<Dialog :open="open" @update:open="handleClose">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>Share Record</DialogTitle>
<DialogDescription>
Grant access to this record to other users
</DialogDescription>
</DialogHeader>
<div class="space-y-6 py-4">
<!-- Existing Shares -->
<div v-if="shares.length > 0" class="space-y-3">
<h3 class="text-sm font-semibold">Current Shares</h3>
<div
v-for="share in shares"
:key="share.id"
class="flex items-center justify-between p-3 border rounded-lg"
>
<div class="flex-1">
<div class="font-medium">{{ share.granteeUser?.email || 'Unknown User' }}</div>
<div class="text-sm text-muted-foreground">
Permissions: {{ share.actions.join(', ') }}
<span v-if="share.fields">(Limited fields)</span>
</div>
<div v-if="share.expiresAt" class="text-xs text-muted-foreground">
Expires: {{ formatDate(share.expiresAt) }}
</div>
</div>
<Button
variant="ghost"
size="sm"
@click="handleRevokeShare(share.id)"
>
<X class="w-4 h-4" />
</Button>
</div>
</div>
<!-- Add New Share Form -->
<div class="space-y-4 border-t pt-4">
<h3 class="text-sm font-semibold">Add New Share</h3>
<div class="space-y-2">
<Label>User Email</Label>
<Input
v-model="newShare.userEmail"
placeholder="user@example.com"
type="email"
/>
</div>
<div class="space-y-2">
<Label>Permissions</Label>
<div class="space-y-2">
<div class="flex items-center space-x-2">
<Checkbox
id="perm-read"
:checked="newShare.permissions.read"
@update:checked="(val) => newShare.permissions.read = val"
/>
<Label for="perm-read" class="cursor-pointer">Read</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="perm-update"
:checked="newShare.permissions.update"
@update:checked="(val) => newShare.permissions.update = val"
/>
<Label for="perm-update" class="cursor-pointer">Update</Label>
</div>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center space-x-2">
<Checkbox
id="field-scoped"
:checked="newShare.fieldScoped"
@update:checked="(val) => newShare.fieldScoped = val"
/>
<Label for="field-scoped" class="cursor-pointer">Limit to specific fields</Label>
</div>
<div v-if="newShare.fieldScoped" class="ml-6 space-y-2 border-l-2 pl-4">
<Label class="text-sm">Select Fields</Label>
<div class="space-y-1 max-h-48 overflow-y-auto">
<div
v-for="field in fields"
:key="field.apiName"
class="flex items-center space-x-2"
>
<Checkbox
:id="`field-${field.apiName}`"
:checked="newShare.selectedFields.includes(field.apiName)"
@update:checked="(val) => handleFieldToggle(field.apiName, val)"
/>
<Label :for="`field-${field.apiName}`" class="cursor-pointer text-sm">
{{ field.label }}
</Label>
</div>
</div>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center space-x-2">
<Checkbox
id="has-expiry"
:checked="newShare.hasExpiry"
@update:checked="(val) => newShare.hasExpiry = val"
/>
<Label for="has-expiry" class="cursor-pointer">Set expiration date</Label>
</div>
<Input
v-if="newShare.hasExpiry"
v-model="newShare.expiryDate"
type="datetime-local"
class="ml-6"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="handleClose">Cancel</Button>
<Button @click="handleAddShare" :disabled="!canAddShare || saving">
{{ saving ? 'Sharing...' : 'Share' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
interface Props {
open: boolean
objectDefinitionId: string
recordId: string
fields?: any[]
}
const props = withDefaults(defineProps<Props>(), {
fields: () => []
})
const emit = defineEmits(['close', 'shared'])
const { api } = useApi()
const { toast } = useToast()
const shares = ref<any[]>([])
const loading = ref(false)
const saving = ref(false)
const newShare = ref({
userEmail: '',
permissions: {
read: true,
update: false,
},
fieldScoped: false,
selectedFields: [] as string[],
hasExpiry: false,
expiryDate: '',
})
const canAddShare = computed(() => {
return newShare.value.userEmail && (newShare.value.permissions.read || newShare.value.permissions.update)
})
const fetchShares = async () => {
try {
loading.value = true
shares.value = await api.get(`/shares/record/${props.objectDefinitionId}/${props.recordId}`)
} catch (e: any) {
console.error('Error fetching shares:', e)
} finally {
loading.value = false
}
}
const handleFieldToggle = (fieldKey: string, checked: boolean) => {
if (checked) {
if (!newShare.value.selectedFields.includes(fieldKey)) {
newShare.value.selectedFields.push(fieldKey)
}
} else {
newShare.value.selectedFields = newShare.value.selectedFields.filter(f => f !== fieldKey)
}
}
const handleAddShare = async () => {
try {
saving.value = true
// First, find user by email (you'll need an endpoint for this)
// For now, we'll assume the email is actually a user ID
const actions = []
if (newShare.value.permissions.read) actions.push('read')
if (newShare.value.permissions.update) actions.push('update')
const payload: any = {
objectDefinitionId: props.objectDefinitionId,
recordId: props.recordId,
granteeUserId: newShare.value.userEmail, // Should be user ID, not email
actions,
}
if (newShare.value.fieldScoped && newShare.value.selectedFields.length > 0) {
payload.fields = newShare.value.selectedFields
}
if (newShare.value.hasExpiry && newShare.value.expiryDate) {
payload.expiresAt = new Date(newShare.value.expiryDate).toISOString()
}
await api.post('/shares', payload)
toast.success('Record shared successfully')
await fetchShares()
// Reset form
newShare.value = {
userEmail: '',
permissions: { read: true, update: false },
fieldScoped: false,
selectedFields: [],
hasExpiry: false,
expiryDate: '',
}
emit('shared')
} catch (e: any) {
console.error('Error creating share:', e)
toast.error('Failed to share record')
} finally {
saving.value = false
}
}
const handleRevokeShare = async (shareId: string) => {
if (!confirm('Are you sure you want to revoke this share?')) return
try {
await api.delete(`/shares/${shareId}`)
toast.success('Share revoked successfully')
await fetchShares()
emit('shared')
} catch (e: any) {
console.error('Error revoking share:', e)
toast.error('Failed to revoke share')
}
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString()
}
const handleClose = () => {
emit('close')
}
watch(() => props.open, (isOpen) => {
if (isOpen) {
fetchShares()
}
})
</script>

View File

@@ -0,0 +1,265 @@
<template>
<div class="space-y-6">
<div v-if="loading" class="text-center py-8">Loading...</div>
<div v-else class="space-y-6">
<!-- Object Permissions -->
<div
v-for="obj in objects"
:key="obj.id"
class="border rounded-lg p-4 space-y-3"
>
<div class="flex items-center justify-between">
<h3 class="font-semibold">{{ obj.label }}</h3>
<Button
variant="ghost"
size="sm"
@click="toggleObjectExpanded(obj.id)"
>
{{ expandedObjects[obj.id] ? 'Collapse' : 'Expand' }}
</Button>
</div>
<div v-if="expandedObjects[obj.id]" class="space-y-4">
<!-- CRUD Permissions -->
<div class="grid grid-cols-2 gap-3">
<div class="flex items-center space-x-2">
<Checkbox
:id="`${obj.id}-read`"
:checked="hasPermission(obj.apiName, 'read')"
@update:checked="(val) => setPermission(obj.apiName, 'read', val)"
/>
<Label :for="`${obj.id}-read`" class="cursor-pointer">Read</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
:id="`${obj.id}-create`"
:checked="hasPermission(obj.apiName, 'create')"
@update:checked="(val) => setPermission(obj.apiName, 'create', val)"
/>
<Label :for="`${obj.id}-create`" class="cursor-pointer">Create</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
:id="`${obj.id}-update`"
:checked="hasPermission(obj.apiName, 'update')"
@update:checked="(val) => setPermission(obj.apiName, 'update', val)"
/>
<Label :for="`${obj.id}-update`" class="cursor-pointer">Update</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
:id="`${obj.id}-delete`"
:checked="hasPermission(obj.apiName, 'delete')"
@update:checked="(val) => setPermission(obj.apiName, 'delete', val)"
/>
<Label :for="`${obj.id}-delete`" class="cursor-pointer">Delete</Label>
</div>
</div>
<!-- Advanced: Condition-based permissions -->
<div class="border-t pt-3">
<div class="flex items-center space-x-2 mb-2">
<Checkbox
:id="`${obj.id}-conditions`"
:checked="hasConditions(obj.apiName)"
@update:checked="(val) => toggleConditions(obj.apiName, val)"
/>
<Label :for="`${obj.id}-conditions`" class="cursor-pointer text-sm">
Apply conditions (e.g., own records only)
</Label>
</div>
<div v-if="hasConditions(obj.apiName)" class="ml-6 space-y-2">
<div class="text-sm text-muted-foreground">
Only allow access to records where:
</div>
<div class="flex gap-2">
<Input
v-model="getConditions(obj.apiName).field"
placeholder="Field name (e.g., ownerId)"
class="flex-1"
/>
<Input
v-model="getConditions(obj.apiName).value"
placeholder="Value (e.g., $userId)"
class="flex-1"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Save Button -->
<div class="flex justify-end gap-2">
<Button variant="outline" @click="$emit('cancel')">Cancel</Button>
<Button @click="savePermissions" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Permissions' }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
interface Props {
role: any
}
const props = defineProps<Props>()
const emit = defineEmits(['saved', 'cancel'])
const { api } = useApi()
const { toast } = useToast()
const loading = ref(true)
const saving = ref(false)
const objects = ref<any[]>([])
const expandedObjects = ref<Record<string, boolean>>({})
// Store permissions as CASL-like rules
const permissions = ref<Record<string, {
actions: string[]
conditions?: any
}>>({})
const fetchObjects = async () => {
try {
loading.value = true
objects.value = await api.get('/setup/objects')
// Expand all objects by default
objects.value.forEach(obj => {
expandedObjects.value[obj.id] = true
})
} catch (e: any) {
console.error('Error fetching objects:', e)
} finally {
loading.value = false
}
}
const fetchRolePermissions = async () => {
try {
const rules = await api.get(`/role-rules/role/${props.role.id}`)
// Parse existing rules into our format
if (rules && rules.length > 0 && rules[0].rulesJson) {
const rulesJson = rules[0].rulesJson
rulesJson.forEach((rule: any) => {
if (!permissions.value[rule.subject]) {
permissions.value[rule.subject] = { actions: [] }
}
if (Array.isArray(rule.action)) {
permissions.value[rule.subject].actions.push(...rule.action)
} else {
permissions.value[rule.subject].actions.push(rule.action)
}
if (rule.conditions) {
permissions.value[rule.subject].conditions = rule.conditions
}
})
}
} catch (e: any) {
console.error('Error fetching role permissions:', e)
}
}
const toggleObjectExpanded = (objectId: string) => {
expandedObjects.value[objectId] = !expandedObjects.value[objectId]
}
const hasPermission = (subject: string, action: string): boolean => {
return permissions.value[subject]?.actions.includes(action) || false
}
const setPermission = (subject: string, action: string, value: boolean) => {
if (!permissions.value[subject]) {
permissions.value[subject] = { actions: [] }
}
if (value) {
if (!permissions.value[subject].actions.includes(action)) {
permissions.value[subject].actions.push(action)
}
} else {
permissions.value[subject].actions = permissions.value[subject].actions.filter(a => a !== action)
}
}
const hasConditions = (subject: string): boolean => {
return !!permissions.value[subject]?.conditions
}
const toggleConditions = (subject: string, value: boolean) => {
if (!permissions.value[subject]) {
permissions.value[subject] = { actions: [] }
}
if (value) {
permissions.value[subject].conditions = { field: 'ownerId', value: '$userId' }
} else {
delete permissions.value[subject].conditions
}
}
const getConditions = (subject: string) => {
if (!permissions.value[subject]?.conditions) {
return { field: '', value: '' }
}
const cond = permissions.value[subject].conditions
// Convert CASL condition format to simple field/value
const field = Object.keys(cond)[0] || ''
const value = cond[field] || ''
return { field, value }
}
const savePermissions = async () => {
try {
saving.value = true
// Convert our permission structure to CASL rules format
const rules: any[] = []
Object.entries(permissions.value).forEach(([subject, perm]) => {
if (perm.actions.length > 0) {
const rule: any = {
action: perm.actions,
subject,
}
if (perm.conditions) {
const cond = getConditions(subject)
if (cond.field && cond.value) {
rule.conditions = { [cond.field]: cond.value }
}
}
rules.push(rule)
}
})
await api.post('/role-rules', {
roleId: props.role.id,
rulesJson: rules,
})
emit('saved')
} catch (e: any) {
console.error('Error saving permissions:', e)
toast.error('Failed to save permissions')
} finally {
saving.value = false
}
}
onMounted(async () => {
await fetchObjects()
await fetchRolePermissions()
})
</script>

View File

@@ -1,30 +1,59 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { Check } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<CheckboxRootEmits>()
interface Props {
checked?: boolean
disabled?: boolean
required?: boolean
name?: string
value?: string
id?: string
class?: HTMLAttributes['class']
}
const delegatedProps = reactiveOmit(props, "class")
const props = withDefaults(defineProps<Props>(), {
checked: false,
disabled: false,
required: false,
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const emit = defineEmits<{
'update:checked': [value: boolean]
}>()
const handleChange = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:checked', target.checked)
}
</script>
<template>
<CheckboxRoot
v-bind="forwarded"
:class="
cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
props.class)"
>
<CheckboxIndicator class="grid place-content-center text-current">
<slot>
<Check class="h-4 w-4" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
<div class="relative inline-flex items-center">
<input
type="checkbox"
:id="props.id"
:checked="props.checked"
:disabled="props.disabled"
:required="props.required"
:name="props.name"
:value="props.value"
@change="handleChange"
:class="
cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer',
'appearance-none bg-background',
'checked:bg-primary checked:border-primary',
props.class
)
"
/>
<Check
v-if="props.checked"
class="absolute h-4 w-4 text-primary-foreground pointer-events-none"
:class="{ 'opacity-50': props.disabled }"
/>
</div>
</template>