initial commit after split
This commit is contained in:
14
webui/src/utils/arrayOrder.ts
Normal file
14
webui/src/utils/arrayOrder.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function insertAfter<T>(items: T[], index: number, item: T): T[] {
|
||||
const insertionIndex = Math.max(0, Math.min(items.length, index + 1));
|
||||
return [...items.slice(0, insertionIndex), item, ...items.slice(insertionIndex)];
|
||||
}
|
||||
|
||||
export function moveArrayItem<T>(items: T[], fromIndex: number, toIndex: number): T[] {
|
||||
if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex >= items.length || fromIndex === toIndex) {
|
||||
return items;
|
||||
}
|
||||
const next = [...items];
|
||||
const [item] = next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, item);
|
||||
return next;
|
||||
}
|
||||
107
webui/src/utils/emailAddresses.ts
Normal file
107
webui/src/utils/emailAddresses.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export type MailboxAddress = {
|
||||
name?: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export function normalizeEmailAddress(address: MailboxAddress): MailboxAddress {
|
||||
return {
|
||||
name: (address.name ?? "").trim(),
|
||||
email: (address.email ?? "").trim().toLowerCase()
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidEmailAddress(email: string): boolean {
|
||||
const normalized = email.trim();
|
||||
if (!normalized || normalized.length > 254) return false;
|
||||
return /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(normalized);
|
||||
}
|
||||
|
||||
export function addressFromRecord(value: unknown): MailboxAddress | null {
|
||||
const record = asRecord(value);
|
||||
const email = typeof record.email === "string" ? record.email.trim() : "";
|
||||
if (!email) return null;
|
||||
const name = typeof record.name === "string" ? record.name.trim() : "";
|
||||
return normalizeEmailAddress({ name, email });
|
||||
}
|
||||
|
||||
export function addressesFromValue(value: unknown): MailboxAddress[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(addressFromRecord).filter((item): item is MailboxAddress => Boolean(item));
|
||||
}
|
||||
const single = addressFromRecord(value);
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
|
||||
export function parseMailboxAddressText(input: string): MailboxAddress | null {
|
||||
const text = input.trim().replace(/[;,]+$/g, "");
|
||||
if (!text) return null;
|
||||
|
||||
const angleMatch = text.match(/^(.+?)\s*<\s*([^<>\s]+@[^<>\s]+)\s*>$/);
|
||||
if (angleMatch) {
|
||||
return normalizeEmailAddress({ name: cleanAddressName(angleMatch[1]), email: angleMatch[2] });
|
||||
}
|
||||
|
||||
const emailMatch = text.match(/([^\s<>;,]+@[^\s<>;,]+\.[^\s<>;,]+)/);
|
||||
if (!emailMatch) return null;
|
||||
|
||||
const email = emailMatch[1];
|
||||
const name = cleanAddressName(`${text.slice(0, emailMatch.index)} ${text.slice((emailMatch.index ?? 0) + email.length)}`);
|
||||
return normalizeEmailAddress({ name, email });
|
||||
}
|
||||
|
||||
function cleanAddressName(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[<>]/g, "")
|
||||
.replace(/^[\s"'`]+|[\s"'`]+$/g, "")
|
||||
.replace(/[;,]+$/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function collectCampaignAddressSuggestions(draft: Record<string, unknown> | null | undefined): MailboxAddress[] {
|
||||
if (!draft) return [];
|
||||
const suggestions: MailboxAddress[] = [];
|
||||
const recipients = asRecord(draft.recipients);
|
||||
const sender = addressFromRecord(recipients.from);
|
||||
if (sender) suggestions.push(sender);
|
||||
for (const key of ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]) {
|
||||
suggestions.push(...addressesFromValue(recipients[key]));
|
||||
}
|
||||
|
||||
const entries = asRecord(draft.entries);
|
||||
for (const entryValue of asArray(entries.inline)) {
|
||||
const entry = asRecord(entryValue);
|
||||
const toAddresses = asArray(entry.to).map(addressFromRecord).filter((item): item is MailboxAddress => Boolean(item));
|
||||
suggestions.push(...toAddresses);
|
||||
const direct = addressFromRecord(entry.recipient) ?? addressFromRecord(entry);
|
||||
if (direct) suggestions.push(direct);
|
||||
}
|
||||
|
||||
return dedupeAddresses(suggestions);
|
||||
}
|
||||
|
||||
export function dedupeAddresses(addresses: MailboxAddress[]): MailboxAddress[] {
|
||||
const seen = new Map<string, MailboxAddress>();
|
||||
addresses.map(normalizeEmailAddress).forEach((address) => {
|
||||
if (!address.email) return;
|
||||
const existing = seen.get(address.email);
|
||||
if (!existing || (!existing.name && address.name)) {
|
||||
seen.set(address.email, address);
|
||||
}
|
||||
});
|
||||
return [...seen.values()].sort((left, right) => (left.name || left.email).localeCompare(right.name || right.email));
|
||||
}
|
||||
|
||||
export function addressDisplayName(address: MailboxAddress): string {
|
||||
const normalized = normalizeEmailAddress(address);
|
||||
return normalized.name || normalized.email;
|
||||
}
|
||||
86
webui/src/utils/fieldHelp.ts
Normal file
86
webui/src/utils/fieldHelp.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
const FIELD_HELP_BY_LABEL: Record<string, string> = {
|
||||
"API base URL": "Optional backend URL. Leave empty to use the current origin and the configured Vite/backend proxy.",
|
||||
"Automation API key": "Fallback API key for scripted or non-login access. Interactive browser sessions should use login tokens instead.",
|
||||
"Email": "Email address used to identify the user account for login or address entry.",
|
||||
"Password": "Password for the selected account or mail server. Stored and transmitted according to the configured backend flow.",
|
||||
"Name": "Human-readable display name shown in lists, reports or outgoing address headers.",
|
||||
"Email address": "The mailbox address, for example office@example.org.",
|
||||
|
||||
"Campaign ID": "Stable technical identifier used in JSON, reports, filenames and backend references.",
|
||||
"Campaign name": "Human-readable campaign name shown in lists, review screens and reports.",
|
||||
"Mode": "Campaign operating mode. Use draft while editing, test for trial runs and send when preparing the real campaign.",
|
||||
"Scenario": "High-level campaign pattern used by guided setup to prefill sensible defaults.",
|
||||
"Description": "Internal explanation of the campaign purpose. This is not sent to recipients.",
|
||||
|
||||
"Default From address": "Default sender address used for outgoing messages unless individual senders are allowed and set per recipient.",
|
||||
"From name": "Display name shown with the sender address in outgoing messages.",
|
||||
"From email": "Mailbox address used as the sender of outgoing messages.",
|
||||
"Global Reply-To address": "Default reply destination for recipient responses. Leave empty to let replies go to the From address.",
|
||||
"Reply-To": "Address recipients should reply to when it differs from the sender address.",
|
||||
"To": "Global To recipients added to every message in addition to recipient-specific recipients.",
|
||||
"Global recipients": "Default recipients included in every generated message.",
|
||||
"CC": "Global carbon-copy recipients included in every generated message.",
|
||||
"BCC": "Global blind-copy recipients included in every generated message.",
|
||||
|
||||
"Template source": "Where this campaign template comes from. Inline means it is stored directly in the campaign draft.",
|
||||
"Library template": "Reusable template record this campaign should refer to once the template backend is available.",
|
||||
"Subject": "Message subject. Placeholders can reference campaign/global/recipient fields.",
|
||||
"Body": "Template body content shown for review. Placeholders are checked against campaign fields.",
|
||||
"Plain text body": "Plain text version of the email body. It should remain readable without HTML rendering.",
|
||||
"HTML body": "Optional HTML version of the email body for richer formatting.",
|
||||
|
||||
"Attachment base path": "Base folder used to resolve campaign attachment rules and relative file patterns.",
|
||||
"Campaign attachment base path": "Base folder used during campaign creation to resolve attachment files and patterns.",
|
||||
"Default missing behavior": "Default review behavior when an expected attachment cannot be found.",
|
||||
"Default ambiguous behavior": "Default review behavior when a file pattern matches more than one attachment.",
|
||||
"Base directory": "Folder below the campaign attachment base path where this rule starts looking for files.",
|
||||
"File filter": "Filename or pattern used to select matching files. Field placeholders can be introduced later.",
|
||||
"Include subdirectories": "Whether the rule should also search below nested folders.",
|
||||
"Allow multiple matches": "Whether more than one matching file may be attached for this rule.",
|
||||
"Missing behavior": "Action or review severity when this rule finds no matching file.",
|
||||
"Ambiguous behavior": "Action or review severity when this rule finds multiple possible matches.",
|
||||
|
||||
"Host": "Mail server hostname or IP address for the selected protocol.",
|
||||
"SMTP host": "SMTP server hostname or IP address used for sending messages.",
|
||||
"Port": "Network port used by the selected mail protocol.",
|
||||
"SMTP port": "Network port used by the SMTP server, commonly 587 for STARTTLS or 465 for TLS.",
|
||||
"Username": "Login name for the selected mail server. Often the same as the mailbox address.",
|
||||
"Security": "Connection security mode expected by the server: plain, TLS or STARTTLS.",
|
||||
"Timeout seconds": "Maximum time to wait for the mail server before the connection test or send attempt fails.",
|
||||
"Detected/saved sent folder": "IMAP folder name used as the detected or manually configured Sent folder.",
|
||||
"Append folder": "Folder where successfully sent messages should be appended via IMAP.",
|
||||
"IMAP append to Sent": "Whether sent messages should also be copied into the configured Sent folder via IMAP.",
|
||||
|
||||
"Messages per minute": "Rate limit for outgoing messages. Lower values are safer for mail providers and throttled accounts.",
|
||||
"Concurrency": "Number of send operations that may run in parallel. Keep low until account and provider limits are clear.",
|
||||
"Max attempts": "Maximum retry attempts for a message before it remains failed for review.",
|
||||
|
||||
"Source type": "Recipient data source format. Inline data is edited in the campaign; external sources will be parsed later.",
|
||||
"Source path": "Path or identifier for an external recipient source such as a CSV file.",
|
||||
|
||||
"Remember previously used addresses": "Planned opt-in collection for autocomplete across campaigns. Currently autocomplete is campaign-local only.",
|
||||
"External address-book sync": "Placeholder for CardDAV, Google, LDAP or similar address sources.",
|
||||
"Allow individual senders": "Permit recipient rows to override the campaign's default sender address.",
|
||||
"Allow individual From": "Permit per-recipient sender overrides when building messages.",
|
||||
"Allow individual Reply-To": "Permit recipient rows to override the global Reply-To address.",
|
||||
"Allow individual To": "Permit recipient rows to define their own To recipients in addition to global headers.",
|
||||
"Allow individual CC": "Permit recipient rows to define their own CC recipients.",
|
||||
"Allow individual BCC": "Permit recipient rows to define their own BCC recipients.",
|
||||
"Allow individual attachments": "Permit recipient-specific attachment rules in addition to global files.",
|
||||
"Send without attachments": "Allow messages to be sent even when no attachment is resolved. Disable to force review or blocking.",
|
||||
"Status tracking": "Store per-message build, validation, queue, send and IMAP status for review and reporting.",
|
||||
"Suggest addresses from this campaign": "Use addresses already present in this campaign as autocomplete suggestions.",
|
||||
"Remember newly used addresses": "Prepare newly entered addresses for a future user-level address memory.",
|
||||
"Show guided warnings while editing": "Show inline guidance and warnings while campaign data is being edited.",
|
||||
"Required": "Mark this item as mandatory. Missing required data should become a validation or review issue.",
|
||||
"Subdirs": "Search nested folders below the configured base directory.",
|
||||
"Can override": "Allow recipient-specific values to override the global value for this field.",
|
||||
"Enable IMAP": "Enable IMAP settings so the server can test login, list folders and optionally append sent copies.",
|
||||
"Append successfully sent messages to Sent": "After SMTP send succeeds, append a copy of the message to the selected IMAP Sent folder.",
|
||||
"Append successful messages to Sent via IMAP": "After SMTP send succeeds, append a copy of the message to the configured IMAP Sent folder."
|
||||
};
|
||||
|
||||
export function helpForFieldLabel(label: unknown): string | undefined {
|
||||
if (typeof label !== "string") return undefined;
|
||||
return FIELD_HELP_BY_LABEL[label];
|
||||
}
|
||||
66
webui/src/utils/helpContext.ts
Normal file
66
webui/src/utils/helpContext.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export type HelpContext = {
|
||||
id: string;
|
||||
title: string;
|
||||
route: string;
|
||||
};
|
||||
|
||||
const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
|
||||
data: { id: "campaign.settings", title: "Campaign settings" },
|
||||
campaign: { id: "campaign.settings", title: "Campaign settings" },
|
||||
fields: { id: "campaign.fields", title: "Campaign fields" },
|
||||
template: { id: "campaign.template", title: "Template" },
|
||||
files: { id: "campaign.attachments", title: "Attachments" },
|
||||
attachments: { id: "campaign.attachments", title: "Attachments" },
|
||||
recipients: { id: "campaign.recipients", title: "Sender & Recipients" },
|
||||
"recipient-data": { id: "campaign.recipient-data", title: "Recipient data" },
|
||||
"mail-settings": { id: "campaign.server-settings", title: "Server settings" },
|
||||
"server-settings": { id: "campaign.server-settings", title: "Server settings" },
|
||||
mail: { id: "campaign.server-settings", title: "Server settings" },
|
||||
"global-settings": { id: "campaign.global-settings", title: "Policies" },
|
||||
settings: { id: "campaign.global-settings", title: "Policies" },
|
||||
review: { id: "campaign.review-send", title: "Review & Send" },
|
||||
send: { id: "campaign.review-send", title: "Review & Send" },
|
||||
report: { id: "campaign.report", title: "Report" },
|
||||
reports: { id: "campaign.report", title: "Report" },
|
||||
audit: { id: "campaign.audit", title: "Audit log" },
|
||||
json: { id: "campaign.json", title: "JSON" }
|
||||
};
|
||||
|
||||
const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
|
||||
dashboard: { id: "app.dashboard", title: "Dashboard" },
|
||||
campaigns: { id: "campaigns.list", title: "Campaigns" },
|
||||
templates: { id: "templates.list", title: "Templates" },
|
||||
files: { id: "files.list", title: "Files" },
|
||||
"address-book": { id: "address-book.list", title: "Address Book" },
|
||||
reports: { id: "reports.list", title: "Reports" },
|
||||
settings: { id: "app.settings", title: "Settings" },
|
||||
admin: { id: "app.admin", title: "Admin" }
|
||||
};
|
||||
|
||||
export function helpContextForPathname(pathname: string): HelpContext {
|
||||
const route = pathname || "/";
|
||||
const segments = route.split("/").filter(Boolean);
|
||||
|
||||
if (segments[0] === "campaigns" && segments[1]) {
|
||||
if (!segments[2]) return { id: "campaign.overview", title: "Campaign overview", route };
|
||||
if (segments[2] === "wizard") {
|
||||
const step = segments[3] || "create";
|
||||
return { id: `campaign.wizard.${step}`, title: `${capitalize(step)} wizard`, route };
|
||||
}
|
||||
const context = campaignSectionContexts[segments[2]];
|
||||
if (context) return { ...context, route };
|
||||
return { id: "campaign.workspace", title: "Campaign workspace", route };
|
||||
}
|
||||
|
||||
const context = topLevelContexts[segments[0] || "campaigns"];
|
||||
if (context) return { ...context, route };
|
||||
return { id: "app.general", title: "Application", route };
|
||||
}
|
||||
|
||||
export function helpQueryForContext(context: HelpContext): string {
|
||||
return `context=${encodeURIComponent(context.id)}`;
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
130
webui/src/utils/permissions.ts
Normal file
130
webui/src/utils/permissions.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { AuthInfo } from "../types";
|
||||
|
||||
const legacyTenantScopes = [
|
||||
"campaign:read", "campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share",
|
||||
"campaign:validate", "campaign:build", "campaign:review", "campaign:send_test", "campaign:queue", "campaign:control", "campaign:send", "campaign:retry", "campaign:reconcile",
|
||||
"recipients:read", "recipients:write", "recipients:import", "recipients:export",
|
||||
"files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete", "files:admin",
|
||||
"reports:read", "reports:export", "reports:send", "audit:read",
|
||||
"mail_servers:read", "mail_servers:use", "mail_servers:test", "mail_servers:write", "mail_servers:manage_credentials",
|
||||
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
|
||||
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
|
||||
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
|
||||
"admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke",
|
||||
"admin:settings:read", "admin:settings:write", "admin:policies:read", "admin:policies:write"
|
||||
];
|
||||
|
||||
const legacyToModuleScopes: Record<string, string> = {
|
||||
"campaign:read": "campaigns:campaign:read",
|
||||
"campaign:create": "campaigns:campaign:create",
|
||||
"campaign:update": "campaigns:campaign:update",
|
||||
"campaign:copy": "campaigns:campaign:copy",
|
||||
"campaign:archive": "campaigns:campaign:archive",
|
||||
"campaign:delete": "campaigns:campaign:delete",
|
||||
"campaign:share": "campaigns:campaign:share",
|
||||
"campaign:validate": "campaigns:campaign:validate",
|
||||
"campaign:build": "campaigns:campaign:build",
|
||||
"campaign:review": "campaigns:campaign:review",
|
||||
"campaign:send_test": "campaigns:campaign:send_test",
|
||||
"campaign:queue": "campaigns:campaign:queue",
|
||||
"campaign:control": "campaigns:campaign:control",
|
||||
"campaign:send": "campaigns:campaign:send",
|
||||
"campaign:retry": "campaigns:campaign:retry",
|
||||
"campaign:reconcile": "campaigns:campaign:reconcile",
|
||||
"recipients:read": "campaigns:recipient:read",
|
||||
"recipients:write": "campaigns:recipient:write",
|
||||
"recipients:import": "campaigns:recipient:import",
|
||||
"recipients:export": "campaigns:recipient:export",
|
||||
"reports:read": "campaigns:report:read",
|
||||
"reports:export": "campaigns:report:export",
|
||||
"reports:send": "campaigns:report:send",
|
||||
"files:read": "files:file:read",
|
||||
"files:download": "files:file:download",
|
||||
"files:upload": "files:file:upload",
|
||||
"files:organize": "files:file:organize",
|
||||
"files:share": "files:file:share",
|
||||
"files:delete": "files:file:delete",
|
||||
"files:admin": "files:file:admin",
|
||||
"mail_servers:read": "mail:profile:read",
|
||||
"mail_servers:use": "mail:profile:use",
|
||||
"mail_servers:test": "mail:profile:test",
|
||||
"mail_servers:write": "mail:profile:write",
|
||||
"mail_servers:manage_credentials": "mail:secret:manage"
|
||||
};
|
||||
|
||||
const moduleToLegacyScopes = Object.fromEntries(Object.entries(legacyToModuleScopes).map(([legacy, module]) => [module, legacy]));
|
||||
const moduleTenantScopes = new Set(Object.values(legacyToModuleScopes));
|
||||
const tenantScopes = new Set([...legacyTenantScopes, ...moduleTenantScopes]);
|
||||
const tenantWildcardLegacyScopes = new Set(["attachments:read", "attachments:write"]);
|
||||
|
||||
const legacyAliases: Record<string, string[]> = {
|
||||
"campaign:write": ["campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share", "recipients:read", "recipients:write", "recipients:import"],
|
||||
"attachments:read": ["files:read", "files:download"],
|
||||
"attachments:write": ["files:upload", "files:organize", "files:share", "files:delete"],
|
||||
"admin:users": ["admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend", "admin:groups:read", "admin:groups:write", "admin:groups:manage_members", "admin:roles:read", "admin:roles:write", "admin:roles:assign"],
|
||||
"admin:users:write": ["admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"],
|
||||
"admin:api_keys:write": ["admin:api_keys:create", "admin:api_keys:revoke"],
|
||||
"admin:settings": ["admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"],
|
||||
"system:tenants:write": ["system:tenants:create", "system:tenants:update", "system:tenants:suspend"],
|
||||
"system:access:write": ["system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"]
|
||||
};
|
||||
|
||||
function compatibleScopes(scope: string): string[] {
|
||||
const mapped = legacyToModuleScopes[scope] ?? moduleToLegacyScopes[scope];
|
||||
return mapped ? [scope, mapped] : [scope];
|
||||
}
|
||||
|
||||
function primitiveScopeGrants(granted: string, required: string): boolean {
|
||||
if (granted === required) return true;
|
||||
if (legacyAliases[granted]?.some((alias) => primitiveScopeGrants(alias, required))) return true;
|
||||
if (granted === "*" || granted === "tenant:*") {
|
||||
return required === "*" || required === "tenant:*" || tenantScopes.has(required) || tenantWildcardLegacyScopes.has(required);
|
||||
}
|
||||
if (granted === "system:*") return required.startsWith("system:") || required.startsWith("access:");
|
||||
if (granted.endsWith(":*") && required.startsWith(granted.slice(0, -1))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function scopeGrants(granted: string, required: string): boolean {
|
||||
for (const grantedCandidate of compatibleScopes(granted)) {
|
||||
for (const requiredCandidate of compatibleScopes(required)) {
|
||||
if (primitiveScopeGrants(grantedCandidate, requiredCandidate)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
export function hasTenantWildcard(scopes: string[]): boolean {
|
||||
return scopes.includes("*") || scopes.includes("tenant:*");
|
||||
}
|
||||
|
||||
export function hasScope(auth: AuthInfo | null | undefined, required: string): boolean {
|
||||
return Boolean(auth?.scopes?.some((scope) => scopeGrants(scope, required)));
|
||||
}
|
||||
|
||||
export function hasAnyScope(auth: AuthInfo | null | undefined, required: string[]): boolean {
|
||||
return required.some((scope) => hasScope(auth, scope));
|
||||
}
|
||||
|
||||
export const adminReadScopes = [
|
||||
"admin:users:read",
|
||||
"admin:groups:read",
|
||||
"admin:roles:read",
|
||||
"admin:api_keys:read",
|
||||
"admin:settings:read",
|
||||
"admin:policies:read",
|
||||
"mail:profile:read",
|
||||
"mail_servers:read",
|
||||
"audit:read",
|
||||
"system:tenants:read",
|
||||
"system:accounts:read",
|
||||
"system:access:read",
|
||||
"system:roles:read",
|
||||
"system:audit:read",
|
||||
"system:settings:read",
|
||||
"system:governance:read",
|
||||
"access:tenant:read",
|
||||
"access:account:read",
|
||||
"access:governance:read"
|
||||
];
|
||||
Reference in New Issue
Block a user