add contact deletion and data export for GDPR compliance

First part of GDPR support (#244): the right to erasure and the right
to access.

- DELETE /api/v1/contacts/{id} permanently deletes a contact or
  visitor. Conversations, messages, notes, and participants go with it
  via DB cascades. The avatar file is removed too.
- GET /api/v1/contacts/{id}/export downloads a JSON file with
  everything stored about the contact: profile, custom attributes, and
  all conversations with their messages. Private notes stay internal.
- Both actions are gated by new permissions (contacts:delete,
  contacts:export), granted to Admin in migration v2.6.0, and recorded
  in the activity log with actor and IP.
- Contact page gets Export data and Delete contact buttons, with a
  confirm dialog for delete.
- The unlinked media cleaner now also removes attachment files whose
  message no longer exists. Before this, deleting a conversation left
  its attachments on disk forever.
This commit is contained in:
Abhinav Raut
2026-07-14 01:30:59 +05:30
parent 2fe850a2ef
commit bfbcd0a885
18 changed files with 319 additions and 8 deletions
+69
View File
@@ -1,6 +1,7 @@
package main
import (
"fmt"
"path/filepath"
"strconv"
"strings"
@@ -9,6 +10,7 @@ import (
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/abhinavxd/libredesk/internal/user/models"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
@@ -178,6 +180,73 @@ func handleUpdateContact(r *fastglue.Request) error {
return r.SendEnvelope(contact)
}
// handleDeleteContact permanently deletes a contact along with their conversations, messages, and notes.
func handleDeleteContact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
contact, err := app.user.GetContactOrVisitor(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
app.lo.Info("deleting contact", "contact_id", id, "actor_id", auser.ID)
if err := app.user.DeleteContact(id); err != nil {
return sendErrorEnvelope(r, err)
}
if contact.AvatarURL.Valid {
fileName := filepath.Base(contact.AvatarURL.String)
app.media.Delete(fileName)
}
if err := app.activityLog.ContactDeleted(auser.ID, auser.Email, realip.FromRequest(r.RequestCtx), id, contact.Email.String); err != nil {
app.lo.Error("error creating contact deleted activity log", "error", err)
}
return r.SendEnvelope(true)
}
// handleExportContact sends all stored data for a contact as a JSON file download.
func handleExportContact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
contact, err := app.user.GetContactOrVisitor(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
data, err := app.user.ExportContactData(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.activityLog.ContactDataExported(auser.ID, auser.Email, realip.FromRequest(r.RequestCtx), id, contact.Email.String); err != nil {
app.lo.Error("error creating contact data exported activity log", "error", err)
}
filename := fmt.Sprintf("contact-%d-data.json", id)
r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff")
r.RequestCtx.SetContentType("application/json; charset=utf-8")
r.RequestCtx.SetBody(data)
return nil
}
// handleGetContactNotes returns all notes for a contact.
func handleGetContactNotes(r *fastglue.Request) error {
var (
+2
View File
@@ -148,6 +148,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/contacts/{id}", perm(handleGetContact, "contacts:read"))
g.PUT("/api/v1/contacts/{id}", perm(handleUpdateContact, "contacts:write"))
g.PUT("/api/v1/contacts/{id}/block", perm(handleBlockContact, "contacts:block"))
g.DELETE("/api/v1/contacts/{id}", perm(handleDeleteContact, "contacts:delete"))
g.GET("/api/v1/contacts/{id}/export", perm(handleExportContact, "contacts:export"))
// Contact notes.
g.GET("/api/v1/contacts/{id}/notes", perm(handleGetContactNotes, "contact_notes:read"))
+1
View File
@@ -45,6 +45,7 @@ var migList = []migFunc{
{"v2.3.0", migrations.V2_3_0},
{"v2.4.0", migrations.V2_4_0},
{"v2.5.0", migrations.V2_5_0},
{"v2.6.0", migrations.V2_6_0},
}
// upgrade upgrades the database to the current version by running SQL migration files
+4
View File
@@ -212,6 +212,8 @@ const blockContact = (id, data) => http.put(`/api/v1/contacts/${id}/block`, data
'Content-Type': 'application/json'
}
})
const deleteContact = (id) => http.delete(`/api/v1/contacts/${id}`)
const exportContact = (id) => http.get(`/api/v1/contacts/${id}/export`, { responseType: 'blob' })
const getTeam = (id) => http.get(`/api/v1/teams/${id}`)
const getTeams = () => http.get('/api/v1/teams')
const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data, {
@@ -666,6 +668,8 @@ export default {
getContact,
updateContact,
blockContact,
deleteContact,
exportContact,
getCustomAttributes,
createCustomAttribute,
updateCustomAttribute,
@@ -38,6 +38,12 @@ export function useActivityLogFilters () {
}, {
label: t('activityLog.entryType.agentRolePermissionsChanged'),
value: 'agent_role_permissions_changed'
}, {
label: t('activityLog.entryType.contactDeleted'),
value: 'contact_deleted'
}, {
label: t('activityLog.entryType.contactDataExported'),
value: 'contact_data_exported'
}]
},
}))
@@ -37,6 +37,8 @@ export const permissions = {
CONTACTS_READ: 'contacts:read',
CONTACTS_WRITE: 'contacts:write',
CONTACTS_BLOCK: 'contacts:block',
CONTACTS_DELETE: 'contacts:delete',
CONTACTS_EXPORT: 'contacts:export',
CONTACT_NOTES_READ: 'contact_notes:read',
CONTACT_NOTES_WRITE: 'contact_notes:write',
CONTACT_NOTES_DELETE: 'contact_notes:delete',
@@ -189,6 +189,8 @@ const permissions = ref([
{ name: perms.CONTACTS_READ, label: t('admin.role.contacts.read') },
{ name: perms.CONTACTS_WRITE, label: t('admin.role.contacts.write') },
{ name: perms.CONTACTS_BLOCK, label: t('admin.role.contacts.block') },
{ name: perms.CONTACTS_DELETE, label: t('admin.role.contacts.delete') },
{ name: perms.CONTACTS_EXPORT, label: t('admin.role.contacts.export') },
{ name: perms.CONTACT_NOTES_READ, label: t('admin.role.contactNotes.read') },
{ name: perms.CONTACT_NOTES_WRITE, label: t('admin.role.contactNotes.write') },
{ name: perms.CONTACT_NOTES_DELETE, label: t('admin.role.contactNotes.delete') }
@@ -47,7 +47,7 @@
{{ contact.created_at ? format(new Date(contact.created_at), 'PPP') : 'N/A' }}
</div>
<div class="w-30 pt-3">
<div class="flex gap-2 pt-3">
<Button
:variant="contact.enabled ? 'destructive' : 'outline'"
@click="showBlockConfirmation = true"
@@ -57,6 +57,24 @@
<ShieldCheckIcon v-else size="18" />
{{ t(contact.enabled ? 'globals.messages.block' : 'globals.messages.unblock') }}
</Button>
<Button
v-if="userStore.can('contacts:export')"
variant="outline"
size="sm"
@click="exportContact"
>
<DownloadIcon size="18" />
{{ t('contact.exportData') }}
</Button>
<Button
v-if="userStore.can('contacts:delete')"
variant="destructive"
size="sm"
@click="showDeleteConfirmation = true"
>
<Trash2Icon size="18" />
{{ t('contact.deleteContact') }}
</Button>
</div>
</div>
@@ -92,13 +110,30 @@
</div>
</DialogContent>
</Dialog>
<Dialog :open="showDeleteConfirmation" @update:open="showDeleteConfirmation = $event">
<DialogContent class="sm:max-w-md">
<DialogHeader class="gap-y-3">
<DialogTitle>{{ t('contact.deleteContact') }}</DialogTitle>
<DialogDescription>{{ t('contact.deleteConfirm') }}</DialogDescription>
</DialogHeader>
<div class="flex justify-end space-x-2 pt-4">
<Button variant="outline" @click="showDeleteConfirmation = false">
{{ t('globals.messages.cancel') }}
</Button>
<Button variant="destructive" @click="confirmDelete">
{{ t('globals.messages.delete') }}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</ContactDetail>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { format } from 'date-fns'
import { useI18n } from 'vue-i18n'
import { useForm } from 'vee-validate'
@@ -114,7 +149,14 @@ import {
DialogDescription
} from '@shared-ui/components/ui/dialog'
import { useUserStore } from '../../stores/user'
import { ShieldOffIcon, ShieldCheckIcon, IdCardIcon, CalendarIcon } from 'lucide-vue-next'
import {
ShieldOffIcon,
ShieldCheckIcon,
IdCardIcon,
CalendarIcon,
DownloadIcon,
Trash2Icon
} from 'lucide-vue-next'
import ContactDetail from '@/layouts/contact/ContactDetail.vue'
import api from '../../api'
import ContactForm from '@/features/contact/ContactForm.vue'
@@ -129,9 +171,11 @@ import { Spinner } from '@shared-ui/components/ui/spinner'
const { t } = useI18n()
const emitter = useEmitter()
const route = useRoute()
const router = useRouter()
const formLoading = ref(false)
const contact = ref(null)
const showBlockConfirmation = ref(false)
const showDeleteConfirmation = ref(false)
const userStore = useUserStore()
const form = useForm({
@@ -183,6 +227,36 @@ async function toggleBlock() {
}
}
async function confirmDelete() {
showDeleteConfirmation.value = false
try {
formLoading.value = true
await api.deleteContact(contact.value.id)
emitToast(t('contact.deletedSuccessfully'))
router.push({ name: 'contacts' })
} catch (err) {
showError(err)
} finally {
formLoading.value = false
}
}
async function exportContact() {
try {
const response = await api.exportContact(contact.value.id)
const url = URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = `contact-${contact.value.id}-data.json`
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
} catch (err) {
showError(err)
}
}
const onSubmit = form.handleSubmit(async (values) => {
try {
formLoading.value = true
+10
View File
@@ -37,6 +37,8 @@
"activityLog.agentOnline": "{actorEmail} ({actorId}) changed {targetEmail} ({targetId}) status to online",
"activityLog.agentOnlineSelf": "{actorEmail} ({actorId}) is online",
"activityLog.agentPasswordSet": "{actorEmail} ({actorId}) set password for {targetEmail} ({targetId})",
"activityLog.contactDataExported": "{actorEmail} ({actorId}) exported data of contact {contactEmail} ({contactId})",
"activityLog.contactDeleted": "{actorEmail} ({actorId}) deleted contact {contactEmail} ({contactId})",
"activityLog.entryType": "Log entry type",
"activityLog.entryType.agentAway": "Agent away",
"activityLog.entryType.agentAwayReassigned": "Agent away reassigned",
@@ -45,6 +47,8 @@
"activityLog.entryType.agentOnline": "Agent online",
"activityLog.entryType.agentPasswordSet": "Agent password set",
"activityLog.entryType.agentRolePermissionsChanged": "Agent role permissions changed",
"activityLog.entryType.contactDataExported": "Contact data exported",
"activityLog.entryType.contactDeleted": "Contact deleted",
"activityLog.rolePermissionsAdded": "{actorEmail} ({actorId}) added permission(s) {permissions} to role {roleName} ({roleId})",
"activityLog.rolePermissionsChanged": "{actorEmail} ({actorId}) removed permission(s) {removed} and added permission(s) {added} to role {roleName} ({roleId})",
"activityLog.rolePermissionsRemoved": "{actorEmail} ({actorId}) removed permission(s) {permissions} from role {roleName} ({roleId})",
@@ -305,6 +309,8 @@
"admin.role.contactNotes.read": "View contact notes",
"admin.role.contactNotes.write": "Add contact notes",
"admin.role.contacts.block": "Block contacts",
"admin.role.contacts.delete": "Delete contacts",
"admin.role.contacts.export": "Export contact data",
"admin.role.contacts.read": "View contact details",
"admin.role.contacts.readAll": "View all contacts",
"admin.role.contacts.write": "Edit contact details",
@@ -459,8 +465,12 @@
"contact.blockConfirm": "Are you sure you want to block this contact? They won't be able to chat, and incoming emails from their address will be rejected. This also blocks incoming emails from any other contacts that use the same email.",
"contact.blockContact": "Block contact",
"contact.blockedSuccessfully": "Contact blocked successfully",
"contact.deleteConfirm": "Are you sure you want to delete this contact? All their conversations, messages, notes, and attachments will be permanently deleted. This cannot be undone.",
"contact.deleteContact": "Delete contact",
"contact.deleteNote": "Delete note",
"contact.deletedSuccessfully": "Contact deleted successfully",
"contact.editContact": "Edit contact",
"contact.exportData": "Export data",
"contact.identityNotVerified": "Identity not verified",
"contact.identityVerified": "Identity verified",
"contact.newNote": "New note",
+34
View File
@@ -261,6 +261,40 @@ func (al *Manager) RolePermissionsChanged(actorID int, actorEmail, ip string, ro
)
}
// ContactDeleted records permanent deletion of a contact and their data.
func (al *Manager) ContactDeleted(actorID int, actorEmail, ip string, contactID int, contactEmail string) error {
description := al.i18n.Ts("activityLog.contactDeleted",
"actorEmail", actorEmail,
"actorId", fmt.Sprintf("#%d", actorID),
"contactEmail", contactEmail,
"contactId", fmt.Sprintf("#%d", contactID))
return al.create(
models.ContactDeleted,
description,
actorID,
umodels.UserModel,
contactID,
ip,
)
}
// ContactDataExported records an export of a contact's stored data.
func (al *Manager) ContactDataExported(actorID int, actorEmail, ip string, contactID int, contactEmail string) error {
description := al.i18n.Ts("activityLog.contactDataExported",
"actorEmail", actorEmail,
"actorId", fmt.Sprintf("#%d", actorID),
"contactEmail", contactEmail,
"contactId", fmt.Sprintf("#%d", contactID))
return al.create(
models.ContactDataExported,
description,
actorID,
umodels.UserModel,
contactID,
ip,
)
}
// create creates a new activity log in DB.
func (m *Manager) create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error {
if _, err := m.q.InsertActivity.Exec(activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
+2
View File
@@ -12,6 +12,8 @@ const (
AgentOnline = "agent_online"
AgentPasswordSet = "agent_password_set"
AgentRolePermissionsChanged = "agent_role_permissions_changed"
ContactDeleted = "contact_deleted"
ContactDataExported = "contact_data_exported"
)
type ActivityLog struct {
+4
View File
@@ -81,6 +81,8 @@ const (
PermContactsRead = "contacts:read"
PermContactsWrite = "contacts:write"
PermContactsBlock = "contacts:block"
PermContactsDelete = "contacts:delete"
PermContactsExport = "contacts:export"
// Contact Notes
PermContactNotesRead = "contact_notes:read"
@@ -133,6 +135,8 @@ var validPermissions = map[string]struct{}{
PermContactsRead: {},
PermContactsWrite: {},
PermContactsBlock: {},
PermContactsDelete: {},
PermContactsExport: {},
PermContactNotesRead: {},
PermContactNotesWrite: {},
PermContactNotesDelete: {},
+5 -3
View File
@@ -46,9 +46,11 @@ WHERE model_type = $1
-- name: get-unlinked-message-media
SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta
FROM media
WHERE model_type = 'messages'
AND (model_id IS NULL OR model_id = 0)
AND created_at < NOW() - INTERVAL '7 days';
WHERE model_type = 'messages'
AND (
((model_id IS NULL OR model_id = 0) AND created_at < NOW() - INTERVAL '7 days')
OR (model_id > 0 AND NOT EXISTS (SELECT 1 FROM conversation_messages cm WHERE cm.id = media.model_id))
);
-- name: content-id-exists
SELECT m.uuid
+27
View File
@@ -0,0 +1,27 @@
package migrations
import (
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
func V2_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
for _, permission := range []string{"contacts:delete", "contacts:export"} {
if _, err := db.Exec(`
UPDATE roles
SET permissions = array_append(permissions, $1)
WHERE name = 'Admin' AND NOT ($1 = ANY(permissions));
`, permission); err != nil {
return err
}
}
if _, err := db.Exec(`ALTER TYPE activity_log_type ADD VALUE IF NOT EXISTS 'contact_deleted';`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TYPE activity_log_type ADD VALUE IF NOT EXISTS 'contact_data_exported';`); err != nil {
return err
}
return nil
}
+23
View File
@@ -98,6 +98,29 @@ func (u *Manager) UpdateContact(id int, user models.User) error {
return nil
}
// DeleteContact permanently deletes a contact or visitor; conversations, messages, and notes are removed by DB cascades.
func (u *Manager) DeleteContact(id int) error {
res, err := u.q.DeleteContact.Exec(id)
if err != nil {
u.lo.Error("error deleting contact", "contact_id", id, "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
}
if rows, _ := res.RowsAffected(); rows == 0 {
return envelope.NewError(envelope.NotFoundError, u.i18n.T("validation.notFoundUser"), nil)
}
return nil
}
// ExportContactData returns all stored personal data for a contact as JSON.
func (u *Manager) ExportContactData(id int) ([]byte, error) {
var data []byte
if err := u.q.ExportContactData.Get(&data, id); err != nil {
u.lo.Error("error exporting contact data", "contact_id", id, "error", err)
return nil, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return data, nil
}
// GetAllContacts returns a list of all contacts.
func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filtersJSON, location string) ([]models.UserCompact, error) {
if pageSize > maxListPageSize {
+47
View File
@@ -429,3 +429,50 @@ SELECT
-- name: get-user-ids-by-role
SELECT user_id FROM user_roles WHERE role_id = $1;
-- name: delete-contact
DELETE FROM users
WHERE id = $1 AND type IN ('contact', 'visitor');
-- name: export-contact-data
SELECT jsonb_build_object(
'contact', (
SELECT jsonb_build_object(
'id', id,
'created_at', created_at,
'first_name', first_name,
'last_name', last_name,
'email', email,
'phone_number_country_code', phone_number_country_code,
'phone_number', phone_number,
'country', country,
'avatar_url', avatar_url,
'external_user_id', external_user_id,
'custom_attributes', custom_attributes
)
FROM users
WHERE id = $1 AND type IN ('contact', 'visitor')
),
'conversations', (
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'reference_number', c.reference_number,
'created_at', c.created_at,
'subject', c.subject,
'status', cs.name,
'custom_attributes', c.custom_attributes,
'messages', (
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'created_at', m.created_at,
'type', m.type,
'sender_type', m.sender_type,
'content', m.text_content
) ORDER BY m.created_at), '[]'::jsonb)
FROM conversation_messages m
WHERE m.conversation_id = c.id AND m.private = false AND m.type IN ('incoming', 'outgoing')
)
) ORDER BY c.created_at), '[]'::jsonb)
FROM conversations c
LEFT JOIN conversation_statuses cs ON cs.id = c.status_id
WHERE c.contact_id = $1
)
);
+2
View File
@@ -117,6 +117,8 @@ type queries struct {
UpdateAPIKeyLastUsed *sqlx.Stmt `query:"update-api-key-last-used"`
MergeVisitorToContact *sqlx.Stmt `query:"merge-visitor-to-contact"`
DeleteContact *sqlx.Stmt `query:"delete-contact"`
ExportContactData *sqlx.Stmt `query:"export-contact-data"`
}
// New creates and returns a new instance of the Manager.
+2 -2
View File
@@ -20,7 +20,7 @@ DROP TYPE IF EXISTS "applied_sla_status" CASCADE; CREATE TYPE "applied_sla_statu
DROP TYPE IF EXISTS "sla_event_status" CASCADE; CREATE TYPE "sla_event_status" AS ENUM ('pending', 'breached', 'met');
DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution', 'next_response');
DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online', 'agent_password_set', 'agent_role_permissions_changed');
DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online', 'agent_password_set', 'agent_role_permissions_changed', 'contact_deleted', 'contact_data_exported');
DROP TYPE IF EXISTS "macro_visible_when" CASCADE; CREATE TYPE "macro_visible_when" AS ENUM ('replying', 'starting_conversation', 'adding_private_note');
DROP TYPE IF EXISTS "user_notification_type" CASCADE; CREATE TYPE "user_notification_type" AS ENUM ('mention', 'assignment', 'sla_warning', 'sla_breach');
DROP TYPE IF EXISTS "conversation_status_category" CASCADE; CREATE TYPE "conversation_status_category" AS ENUM ('open', 'waiting', 'resolved');
@@ -771,7 +771,7 @@ VALUES
(
'Admin',
'Role for users who have complete access to everything.',
'{webhooks:manage,context_links:manage,activity_logs:manage,custom_attributes:manage,contacts:read_all,contacts:read,contacts:write,contacts:block,contact_notes:read,contact_notes:write,contact_notes:delete,conversations:write,ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read_team_all,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,shared_views:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
'{webhooks:manage,context_links:manage,activity_logs:manage,custom_attributes:manage,contacts:read_all,contacts:read,contacts:write,contacts:block,contacts:delete,contacts:export,contact_notes:read,contact_notes:write,contact_notes:delete,conversations:write,ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read_team_all,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,shared_views:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
);