feat(auth): 后台登录、角色权限与会话访问策略

管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
WangShen007 committed 2026-09-24 21:35:00 +08:00
1 parent 5e0615f36d
commit e24d3e2a65
123 files changed
+25293

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/** One validation problem in FastAPI's 422 body. */
interface ValidationDetail {
msg?: string
}
/**
* A sentence to show for a failed API call. FastAPI sends `detail` as a
* string for errors we raise, but as a list of objects for request
* validation (422) - shown raw, that list reads "[object Object]".
*/
export function apiErrorMessage(error: any, fallback: string): string {
const data = error?.response?.data
const detail = data?.detail
if (typeof detail === 'string' && detail) return detail
if (Array.isArray(detail)) {
const msg = (detail as ValidationDetail[]).find((d) => typeof d?.msg === 'string')?.msg
// Pydantic prefixes a validator's own message with "Value error, "
if (msg) return msg.replace(/^Value error, /, '')
}
if (typeof data?.message === 'string' && data.message) return data.message
return fallback
}
+62
View File
@@ -0,0 +1,62 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* True when `path` is already an absolute URL (e.g. a signed S3/CDN link) and
* should be used as-is instead of being prefixed with the API base URL.
*
* We test for an absolute http(s) scheme rather than matching a host substring
* such as "amazonaws.com": substring host checks are unreliable — the host can
* appear anywhere in an attacker-influenced string — and are flagged by static
* analysis (CodeQL js/incomplete-url-substring-sanitization).
*/
export const isAbsoluteUrl = (path?: string | null): boolean =>
!!path && (/^https?:\/\//i.test(path) || path.startsWith('data:'))
/**
* Build a browser-usable URL for a stored upload path.
*
* `store_upload` returns local-storage paths that already carry the API prefix
* (`/api/v1/uploads/...`, matching the static mount), so the API base must be
* reduced to its origin before concatenating — otherwise the prefix is emitted
* twice and the request 404s. Absolute URLs (signed S3/CDN links, data URIs,
* blob previews) are returned untouched.
*
* `apiBase` is passed in rather than resolved here because the dashboard and
* the embeddable widget read their API URL from different resolvers with
* different fallbacks; see getApiUrl() and widgetEnv.API_URL.
*/
export const buildUploadUrl = (stored: string | null | undefined, apiBase: string): string => {
if (!stored) return ''
if (isAbsoluteUrl(stored) || stored.startsWith('blob:')) return stored
const origin = apiBase.replace(/\/api\/v1\/?$/, '')
return `${origin}${stored.startsWith('/') ? '' : '/'}${stored}`
}
/**
* Browser URL for a chat attachment in the dashboard.
*
* The API hands attachments out signed — an S3 presigned URL, or a
* `/api/v1/files/local/...?exp=&sig=` link for local storage — and those
* resolve like any other upload. A bare `/uploads/chat_attachments/...` path
* is not publicly served, so it goes through the authenticated download
* endpoint instead.
*/
export const buildAttachmentUrl = (fileUrl: string | null | undefined, apiBase: string): string => {
const unsigned = fileUrl?.match(/^\/?uploads\/(.+)$/)
if (unsigned) return `${apiBase.replace(/\/+$/, '')}/files/download/${unsigned[1]}`
return buildUploadUrl(fileUrl, apiBase)
}
+54
View File
@@ -0,0 +1,54 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export interface Rgb {
r: number
g: number
b: number
}
export function hexToRgb(hex: string): Rgb | null {
let value = hex.trim().replace(/^#/, '')
if (value.length === 3) {
value = value
.split('')
.map((ch) => ch + ch)
.join('')
}
if (!/^[0-9a-fA-F]{6}$/.test(value)) return null
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
}
}
/** WCAG relative luminance (0 = black, 1 = white). */
export function relativeLuminance({ r, g, b }: Rgb): number {
const linear = (channel: number) => {
const c = channel / 255
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
}
/** Dark or light ink over an arbitrary brand color (mirrors the backend's
* contrast_ink so admin preview and public page always agree). */
export function contrastInk(hex: string): '#12131A' | '#FFFFFF' {
const rgb = hexToRgb(hex)
if (!rgb) return '#FFFFFF'
return relativeLuminance(rgb) > 0.45 ? '#12131A' : '#FFFFFF'
}
+26
View File
@@ -0,0 +1,26 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import mitt from 'mitt'
type Events = {
'socket:reconnected': void
'knowledge-updated': void
// Add other events here as needed
}
const emitter = mitt<Events>()
export default emitter
+69
View File
@@ -0,0 +1,69 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Permission groups, mirroring the constants in backend/app/core/auth.py.
*
* A leaf module on purpose: it imports nothing. The route map reads these at
* module scope, and there is an import cycle around the router
* (router -> routePermissions -> permissions -> services/user -> services/api
* -> router). Reading a constant from anywhere inside that cycle at module
* scope throws "Cannot access X before initialization" in the browser — where
* unit tests never see it, because they mock services/user and break the loop.
*/
/**
* The two the user form's chat-scope toggles map to. Named individually
* because the form reads and writes them one at a time; they mirror
* AI_QUEUE_PERMISSION / ALL_CHATS_PERMISSION in services/chat_scope_roles.py.
*/
export const AI_QUEUE_PERMISSION = 'view_unassigned_chats'
export const ALL_CHATS_PERMISSION = 'view_all_chats'
/** Seeing conversations — CHAT_VIEW_PERMISSIONS. */
export const CHAT_VIEW_PERMISSIONS = [
ALL_CHATS_PERMISSION,
'view_assigned_chats',
AI_QUEUE_PERMISSION,
]
/**
* Seeing chats nobody owns — UNASSIGNED_CHAT_VIEWERS in
* services/chat_notifications.py, which also gates the org inbox room.
*/
export const UNASSIGNED_CHAT_VIEW_PERMISSIONS = [
ALL_CHATS_PERMISSION,
'manage_all_chats',
AI_QUEUE_PERMISSION,
]
/** Acting on one: takeover, reassign, outbound — CHAT_MANAGE_PERMISSIONS. */
export const CHAT_MANAGE_PERMISSIONS = ['manage_all_chats', 'manage_assigned_chats']
/** Working the inbox at all — INBOX_PERMISSIONS. */
export const INBOX_PERMISSIONS = [...CHAT_VIEW_PERMISSIONS, ...CHAT_MANAGE_PERMISSIONS]
/** The people directory — PEOPLE_READ_PERMISSIONS. Writes match reads. */
export const PEOPLE_PERMISSIONS = ['view_people', ...INBOX_PERMISSIONS]
// The remaining view/manage pairs, named once so the route map and the
// permission checks cannot drift apart.
export const AGENT_PERMISSIONS = ['manage_agents', 'view_agents']
export const KNOWLEDGE_PERMISSIONS = ['manage_knowledge', 'view_knowledge']
export const TICKET_PERMISSIONS = ['view_tickets', 'manage_tickets']
export const ORGANIZATION_PERMISSIONS = ['manage_organization', 'view_organization']
export const AI_CONFIG_PERMISSIONS = ['manage_ai_config', 'view_ai_config']
export const SUBSCRIPTION_PERMISSIONS = ['manage_subscription', 'view_subscription']
+74
View File
@@ -0,0 +1,74 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { userService } from '@/services/user'
import {
AGENT_PERMISSIONS,
AI_CONFIG_PERMISSIONS,
CHAT_MANAGE_PERMISSIONS,
CHAT_VIEW_PERMISSIONS,
KNOWLEDGE_PERMISSIONS,
UNASSIGNED_CHAT_VIEW_PERMISSIONS,
ORGANIZATION_PERMISSIONS,
PEOPLE_PERMISSIONS,
SUBSCRIPTION_PERMISSIONS,
TICKET_PERMISSIONS,
} from '@/utils/permissionGroups'
export function hasPermission(permission: string): boolean {
const user = userService.getCurrentUser()
return user?.role?.permissions?.some(p => p.name === permission) || false
}
export function hasAnyPermission(permissions: string[]): boolean {
// super_admin holds everything — the backend's has_any_permission bypasses
// on it, and the two layers disagreeing means a page renders then 403s.
return hasPermission('super_admin') || permissions.some(permission => hasPermission(permission))
}
// The groups live in a leaf module (see permissionGroups.ts for why) and are
// re-exported here so existing importers keep working.
export * from '@/utils/permissionGroups'
// Common permission checks.
//
// Every check goes through hasAnyPermission, never bare hasPermission: it is
// the only one that honours super_admin, and the backend's check_permissions
// bypasses on it. When the two layers disagree the page renders and then 403s.
export const permissionChecks = {
canManageOrganization: () => hasAnyPermission(['manage_organization']),
canViewOrganization: () => hasAnyPermission(ORGANIZATION_PERMISSIONS),
canManageAIConfig: () => hasAnyPermission(['manage_ai_config']),
canViewAIConfig: () => hasAnyPermission(AI_CONFIG_PERMISSIONS),
canManageUsers: () => hasAnyPermission(['manage_users']),
canViewAgents: () => hasAnyPermission(AGENT_PERMISSIONS),
canManageAgents: () => hasAnyPermission(['manage_agents']),
canViewChats: () => hasAnyPermission(CHAT_VIEW_PERMISSIONS),
canViewUnassignedChats: () => hasAnyPermission(UNASSIGNED_CHAT_VIEW_PERMISSIONS),
canViewPeople: () => hasAnyPermission(PEOPLE_PERMISSIONS),
// Correcting a person or marking them a customer is inbox work, not
// administration — PEOPLE_WRITE_PERMISSIONS equals the read set.
canManagePeople: () => hasAnyPermission(PEOPLE_PERMISSIONS),
canTakeOverChats: () => hasAnyPermission(CHAT_MANAGE_PERMISSIONS),
canManageKnowledge: () => hasAnyPermission(['manage_knowledge']),
canViewKnowledge: () => hasAnyPermission(KNOWLEDGE_PERMISSIONS),
canViewAnalytics: () => hasAnyPermission(['view_analytics']),
canViewTickets: () => hasAnyPermission(TICKET_PERMISSIONS),
canManageTickets: () => hasAnyPermission(['manage_tickets']),
canApproveTicketActions: () => hasAnyPermission(['approve_ticket_actions']),
canViewSubscription: () => hasAnyPermission(SUBSCRIPTION_PERMISSIONS),
canManageSubscription: () => hasAnyPermission(['manage_subscription']),
}
+62
View File
@@ -0,0 +1,62 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { markRaw } from 'vue'
import { toast } from 'vue-sonner'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
/** Server messages that mean "your plan caps this" rather than "it broke"
* (backend/app/api/{knowledge,agent,users}.py). Deliberately narrow: a
* generic "maximum number of retries" must stay an error, not an upsell. */
const PLAN_LIMIT_PATTERNS = [
/maximum number of (knowledge sources|agents|users|sub-?pages)/i,
/your plan allows/i,
/upgrade (your|to a) plan/i,
/not (available|included) (on|in) your (current )?plan/i,
]
function isPlanLimitError(message: string | undefined | null): boolean {
if (!message) return false
return PLAN_LIMIT_PATTERNS.some((pattern) => pattern.test(message))
}
/**
* Show a blocked-action error. When the enterprise module is present and a
* promo applies, the toast carries the offer (design 2b B3); otherwise it is
* the plain error toast. Returns true when it was a plan-limit message.
*/
export async function showPlanLimitError(title: string, message: string | undefined | null): Promise<boolean> {
if (!isPlanLimitError(message)) {
toast.error(title, { description: message || undefined })
return false
}
const { hasEnterpriseModule, loadModule, moduleImports } = useEnterpriseFeatures()
const module = hasEnterpriseModule ? await loadModule(moduleImports.promoLimitToast) : null
const component = module?.default
if (!component) {
toast.error(title, { description: message || undefined })
return true
}
const id = toast.custom(markRaw(component), {
duration: 12000,
componentProps: {
title,
message,
onDismiss: () => toast.dismiss(id),
},
})
return true
}
+261
View File
@@ -0,0 +1,261 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import DOMPurify from 'dompurify'
// Register DOMPurify hooks once on module load to prevent duplicate registrations
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
// Block SVG elements entirely
if (data.tagName === 'svg') {
node.parentNode?.removeChild(node)
return
}
// Block math elements
if (data.tagName === 'math') {
node.parentNode?.removeChild(node)
return
}
// Block any foreign objects
if (data.tagName === 'foreignobject') {
node.parentNode?.removeChild(node)
return
}
// SECURITY: Remove <img>, <area>, and <map> tags completely. Anchors are kept
// (clickable links) but hardened in afterSanitizeAttributes: protocol-restricted
// to http(s)/mailto and forced target="_blank" rel="noopener noreferrer nofollow".
const element = node as HTMLElement
const tagName = data.tagName?.toUpperCase()
if (tagName === 'IMG' || tagName === 'AREA' || tagName === 'MAP') {
element.parentNode?.removeChild(element)
}
})
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
// Block javascript: URIs (including encoded variants)
if (node.hasAttribute('href')) {
const href = node.getAttribute('href') || ''
try {
const decodedHref = decodeURIComponent(href.toLowerCase())
if (
decodedHref.includes('javascript:') ||
decodedHref.includes('data:text/html') ||
decodedHref.includes('vbscript:') ||
decodedHref.includes('about:') ||
decodedHref.includes('file:')
) {
node.removeAttribute('href')
}
} catch {
// If decoding fails, use original value for comparison
if (
href.toLowerCase().includes('javascript:') ||
href.toLowerCase().includes('data:text/html') ||
href.toLowerCase().includes('vbscript:') ||
href.toLowerCase().includes('about:') ||
href.toLowerCase().includes('file:')
) {
node.removeAttribute('href')
}
}
}
// Harden anchors so markdown links render clickable but safely: positively allow
// only http(s)/mailto, and always open in a new tab with noopener.
if (node.nodeName === 'A') {
const href = (node.getAttribute('href') || '').trim()
if (!/^(https?:|mailto:)/i.test(href)) {
node.removeAttribute('href')
} else {
node.setAttribute('target', '_blank')
node.setAttribute('rel', 'noopener noreferrer nofollow')
}
}
// Block javascript: and malicious data: URIs in src
if (node.hasAttribute('src')) {
const src = node.getAttribute('src') || ''
try {
const decodedSrc = decodeURIComponent(src.toLowerCase())
if (
decodedSrc.includes('javascript:') ||
decodedSrc.includes('data:text/html') ||
decodedSrc.includes('vbscript:') ||
decodedSrc.includes('about:') ||
decodedSrc.includes('file:')
) {
node.removeAttribute('src')
}
} catch {
// If decoding fails, use original value for comparison
if (
src.toLowerCase().includes('javascript:') ||
src.toLowerCase().includes('data:text/html') ||
src.toLowerCase().includes('vbscript:') ||
src.toLowerCase().includes('about:') ||
src.toLowerCase().includes('file:')
) {
node.removeAttribute('src')
}
}
}
// Check for CSS expressions in style attribute
if (node.hasAttribute('style')) {
const style = node.getAttribute('style') || ''
try {
const decodedStyle = decodeURIComponent(style.toLowerCase())
if (
decodedStyle.includes('expression(') ||
decodedStyle.includes('behavior:') ||
decodedStyle.includes('-moz-binding') ||
decodedStyle.includes('import') ||
decodedStyle.includes('javascript:') ||
decodedStyle.includes('vbscript:')
) {
node.removeAttribute('style')
}
} catch {
// If decoding fails, use original value for comparison
if (
style.toLowerCase().includes('expression(') ||
style.toLowerCase().includes('behavior:') ||
style.toLowerCase().includes('-moz-binding') ||
style.toLowerCase().includes('import') ||
style.toLowerCase().includes('javascript:') ||
style.toLowerCase().includes('vbscript:')
) {
node.removeAttribute('style')
}
}
}
// Remove any attribute that starts with "on" (event handlers)
Array.from(node.attributes).forEach((attr) => {
if (attr.name.toLowerCase().startsWith('on')) {
node.removeAttribute(attr.name)
}
})
})
/**
* Comprehensive HTML sanitization to prevent all XSS attacks including:
* - Encoded payloads (HTML entities, URL encoding, Unicode)
* - SVG-based XSS vectors
* - JavaScript URIs and protocol handlers
* - Data URIs (except images)
* - CSS expressions and injections
* - Event handlers
* - All dangerous HTML tags
*/
export function sanitizeHtml(html: string): string {
// Configure DOMPurify with strict security settings
const config = {
// Block all dangerous tags including SVG, form elements and images.
// NOTE: 'a' must NOT be in this list — FORBID_TAGS beats ALLOWED_TAGS, and
// anchors are intentionally kept (markdown links) then hardened by the
// afterSanitizeAttributes hook (http(s)/mailto only, forced target+rel).
FORBID_TAGS: [
'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'script',
'base', 'link', 'meta', 'style', 'svg', 'math', 'form', 'input',
'button', 'textarea', 'select', 'option', 'xml', 'xss', 'import',
'video', 'audio', 'track', 'source', 'canvas', 'details', 'template',
'slot', 'noscript', 'marquee', 'bgsound', 'keygen', 'command',
'img', 'area', 'map' // SECURITY: Remove image/map tags completely
],
// Block dangerous attributes
FORBID_ATTR: [
// Event handlers
'onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', 'onmousemove',
'onkeydown', 'onkeyup', 'onkeypress', 'onfocus', 'onblur', 'onchange',
'onsubmit', 'ondblclick', 'oncontextmenu', 'oninput', 'oninvalid',
'onreset', 'onsearch', 'onselect', 'onabort', 'oncanplay', 'oncanplaythrough',
'oncuechange', 'ondurationchange', 'onemptied', 'onended', 'onloadeddata',
'onloadedmetadata', 'onloadstart', 'onpause', 'onplay', 'onplaying',
'onprogress', 'onratechange', 'onseeked', 'onseeking', 'onstalled',
'onsuspend', 'ontimeupdate', 'onvolumechange', 'onwaiting', 'ontoggle',
'onauxclick', 'ongotpointercapture', 'onlostpointercapture', 'onpointercancel',
'onpointerdown', 'onpointerenter', 'onpointerleave', 'onpointermove',
'onpointerout', 'onpointerover', 'onpointerup', 'onwheel', 'onanimationcancel',
'onanimationend', 'onanimationiteration', 'onanimationstart', 'ontransitioncancel',
'ontransitionend', 'ontransitionrun', 'ontransitionstart', 'ondrag', 'ondragend',
'ondragenter', 'ondragleave', 'ondragover', 'ondragstart', 'ondrop', 'oncopy',
'oncut', 'onpaste', 'onscroll', 'onmessage', 'onmouseenter', 'onmouseleave',
'onmousewheel', 'onbeforeunload', 'onerrorupdate', 'onhelp', 'onmove',
'onreadystatechange', 'onresize', 'onstart', 'onstop', 'onunload',
'onactivate', 'onafterprint', 'onafterupdate', 'onbeforeactivate',
'onbeforecopy', 'onbeforecut', 'onbeforedeactivate', 'onbeforeeditfocus',
'onbeforepaste', 'onbeforeprint', 'onbeforeupdate', 'onbounce',
'oncellchange', 'oncontrolselect', 'ondataavailable', 'ondatasetchanged',
'ondatasetcomplete', 'ondeactivate', 'onfilterchange', 'onfinish',
'onfocusin', 'onfocusout', 'onlayoutcomplete', 'onlosecapture',
'onmoveend', 'onmovestart', 'onpropertychange', 'onresizeend',
'onresizestart', 'onrowenter', 'onrowexit', 'onrowsdelete', 'onrowsinserted',
'onselectionchange', 'onselectstart', 'onshow', 'onsort', 'onpointerrawupdate',
// Dangerous attributes
'formaction', 'action', 'form', 'srcdoc', 'srcset', 'dynsrc', 'lowsrc',
'ping', 'poster', 'background', 'code', 'codebase', 'archive', 'profile',
'xmlns', 'xlink:href', 'attributename', 'from', 'to', 'values', 'begin',
'autofocus', 'autoplay', 'controls', 'manifest', 'sandbox',
// SECURITY: block resource-loading attributes. 'href' is intentionally NOT
// here (FORBID_ATTR beats ALLOWED_ATTR): markdown links need it, and the
// afterSanitizeAttributes hook strips any href that isn't http(s)/mailto.
'src', 'data'
],
// Only allow safe protocols
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
// SECURITY: Strip ALL HTML tags to prevent rendering exploits
// Only allow basic text formatting for markdown (no links, images, or any potentially dangerous tags)
ALLOWED_TAGS: [
'a',
'b', 'i', 'u', 'strong', 'em', 'p', 'br', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre',
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'span', 'div',
'del', 'hr', 'sup', 'sub', 'abbr', 'cite', 'dfn', 'kbd', 'mark',
'q', 'samp', 'small', 'time', 'var'
],
// Allow safe link attributes only (href is protocol-restricted + target/rel are
// forced by the afterSanitizeAttributes hook above). No src or resource-loading attrs.
ALLOWED_ATTR: [
'href', 'target', 'rel',
'title', 'class', 'id', 'align', 'colspan', 'rowspan'
],
// Return a string instead of a document
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
// Keep HTML comments removed
ALLOW_DATA_ATTR: false
// NOTE: do NOT set USE_PROFILES here — it overrides ALLOWED_TAGS/ALLOWED_ATTR,
// which would drop the <a> tags we explicitly allow above. The explicit
// ALLOWED_TAGS/ALLOWED_ATTR allowlist is authoritative; protocols are still
// restricted by the afterSanitizeAttributes hook (href → http/https/mailto only).
}
// Sanitize the HTML using the pre-configured hooks
return DOMPurify.sanitize(html, config)
}
+26
View File
@@ -0,0 +1,26 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* The setup wizard announces that the org's setup moved on (an agent, a
* source, a finished run). Anything showing setup progress listens and
* refreshes (the enterprise module's setup bar).
*/
export const SETUP_CHANGED_EVENT = 'chattermate:setup-changed'
export function notifySetupChanged(): void {
window.dispatchEvent(new Event(SETUP_CHANGED_EVENT))
}
+391
View File
@@ -0,0 +1,391 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { Agent } from '@/types/agent'
import type { UserGroup } from '@/types/user'
// Define interfaces locally to avoid direct imports from enterprise module
// These will be used as fallbacks when enterprise module isn't available
interface BasePlan {
id?: string
name?: string
max_agents?: number
max_messages?: number
max_knowledge_sources?: number
max_sub_pages?: number
data_retention_days?: number
features?: Record<string, boolean>
}
interface BaseSubscription {
id?: string
status?: string
plan?: BasePlan
quantity?: number
current_period_end?: string
trial_end?: string
}
// Use these types throughout the file
type Plan = BasePlan
type CurrentSubscription = BaseSubscription
const STORAGE_KEYS = {
ACTIVE_AGENT: 'active_agent',
AGENTS: 'agents',
CURRENT_SUBSCRIPTION: 'current_subscription',
AVAILABLE_PLANS: 'available_plans',
} as const
export const agentStorage = {
// Store all agents
setAgents(agents: Agent[]): void {
localStorage.setItem(STORAGE_KEYS.AGENTS, JSON.stringify(agents))
},
// Get all agents
getAgents(): Agent[] {
const stored = localStorage.getItem(STORAGE_KEYS.AGENTS)
if (!stored) return []
try {
return JSON.parse(stored) as Agent[]
} catch (e) {
console.error('Failed to parse stored agents:', e)
return []
}
},
setAgent(agent: Agent): void {
const agents = this.getAgents()
const index = agents.findIndex((a) => a.id === agent.id)
if (index !== -1) {
agents[index] = agent
}
},
// Update specific agent
updateAgent(updatedAgent: Agent): void {
const agents = this.getAgents()
const index = agents.findIndex((a) => a.id === updatedAgent.id)
if (index !== -1) {
agents[index] = updatedAgent
this.setAgents(agents)
}
},
// Update agent knowledge
updateAgentKnowledge(agentId: string, knowledge: Agent['knowledge']): void {
const agents = this.getAgents()
const index = agents.findIndex((a) => a.id === agentId)
if (index !== -1) {
agents[index] = {
...agents[index],
knowledge: knowledge,
}
this.setAgents(agents)
// Update active agent if it's the same one
const activeAgent = this.getActiveAgent()
if (activeAgent && activeAgent.id === agentId) {
this.setActiveAgent(agents[index])
}
}
},
// Get active agent
getActiveAgent(): Agent | null {
const stored = localStorage.getItem(STORAGE_KEYS.ACTIVE_AGENT)
if (!stored) return null
try {
return JSON.parse(stored) as Agent
} catch (e) {
console.error('Failed to parse active agent:', e)
return null
}
},
// Set active agent
setActiveAgent(agent: Agent): void {
localStorage.setItem(STORAGE_KEYS.ACTIVE_AGENT, JSON.stringify(agent))
},
// Clear all stored agent data
clear(): void {
localStorage.removeItem(STORAGE_KEYS.ACTIVE_AGENT)
localStorage.removeItem(STORAGE_KEYS.AGENTS)
},
}
// Subscription storage
export const subscriptionStorage = {
// Store current subscription
setCurrentSubscription(subscription: CurrentSubscription | null): void {
if (subscription) {
localStorage.setItem(STORAGE_KEYS.CURRENT_SUBSCRIPTION, JSON.stringify(subscription))
} else {
localStorage.removeItem(STORAGE_KEYS.CURRENT_SUBSCRIPTION)
}
},
// Get current subscription
getCurrentSubscription(): CurrentSubscription | null {
const stored = localStorage.getItem(STORAGE_KEYS.CURRENT_SUBSCRIPTION)
if (!stored) return null
try {
return JSON.parse(stored) as CurrentSubscription
} catch (e) {
console.error('Failed to parse stored subscription:', e)
return null
}
},
// Store available plans
setAvailablePlans(plans: Plan[]): void {
localStorage.setItem(STORAGE_KEYS.AVAILABLE_PLANS, JSON.stringify(plans))
},
// Get available plans
getAvailablePlans(): Plan[] {
const stored = localStorage.getItem(STORAGE_KEYS.AVAILABLE_PLANS)
if (!stored) return []
try {
return JSON.parse(stored) as Plan[]
} catch (e) {
console.error('Failed to parse stored plans:', e)
return []
}
},
// Clear subscription data
clearSubscriptionData(): void {
localStorage.removeItem(STORAGE_KEYS.CURRENT_SUBSCRIPTION)
localStorage.removeItem(STORAGE_KEYS.AVAILABLE_PLANS)
},
// Check if a feature is enabled in the current plan
hasFeature(featureName: string): boolean {
const subscription = this.getCurrentSubscription()
if (!subscription?.plan?.features) {
return false
}
return Boolean(subscription.plan.features[featureName])
},
// Check multiple features at once
hasFeatures(featureNames: string[]): { [key: string]: boolean } {
const subscription = this.getCurrentSubscription()
const result: { [key: string]: boolean } = {}
featureNames.forEach(featureName => {
result[featureName] = subscription?.plan?.features?.[featureName] || false
})
return result
},
// Get all available features for the current plan
getAllFeatures(): { [key: string]: boolean } {
const subscription = this.getCurrentSubscription()
return subscription?.plan?.features || {}
},
// Check if subscription is active (not expired or cancelled)
isSubscriptionActive(): boolean {
const subscription = this.getCurrentSubscription()
if (!subscription) return false
const now = new Date()
// If cancelled, check if still within current period
if (subscription.status === 'cancelled') {
if (subscription.current_period_end) {
const periodEnd = new Date(subscription.current_period_end)
return now <= periodEnd
}
return false
}
// If trial, check if trial is still active
if (subscription.status === 'trial' && subscription.trial_end) {
const trialEnd = new Date(subscription.trial_end)
return now <= trialEnd
}
// For active subscriptions
return subscription.status === 'active'
},
// Get plan limits
getPlanLimits(): {
maxAgents: number | null
maxKnowledgeSources: number
maxSubPages: number
dataRetentionDays: number
} {
const subscription = this.getCurrentSubscription()
if (!subscription?.plan) {
return {
maxAgents: 0,
maxKnowledgeSources: 0,
maxSubPages: 0,
dataRetentionDays: 0
}
}
return {
// null means unlimited (as on the backend) — `?? 0` locked agent creation
// for every unlimited plan.
maxAgents: subscription.plan.max_agents ?? null,
maxKnowledgeSources: subscription.plan.max_knowledge_sources ?? 0,
maxSubPages: subscription.plan.max_sub_pages ?? 0,
dataRetentionDays: subscription.plan.data_retention_days ?? 0
}
},
}
// Workflow cache storage
export const workflowCacheStorage = {
// Generate cache key for a workflow
getCacheKey(workflowId: string): string {
return `workflow_info_${workflowId}`
},
// Get workflow cache for a specific workflow
getWorkflowCache(workflowId: string): any {
const stored = localStorage.getItem(this.getCacheKey(workflowId))
if (!stored) return null
try {
return JSON.parse(stored)
} catch (e) {
console.error('Failed to parse workflow cache:', e)
return null
}
},
// Set workflow cache for a specific workflow
setWorkflowCache(workflowId: string, data: any): void {
const cacheData = {
...data,
lastUpdated: new Date().toISOString()
}
localStorage.setItem(this.getCacheKey(workflowId), JSON.stringify(cacheData))
},
// Clear specific workflow cache
clearWorkflowCache(workflowId: string): void {
localStorage.removeItem(this.getCacheKey(workflowId))
},
// Clear all workflow caches (for cleanup)
clearAllWorkflowCache(): void {
// Find and remove all workflow cache keys
const keysToRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith('workflow_info_')) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => localStorage.removeItem(key))
},
// Update a single node in the cache
updateNodeInCache(workflowId: string, node: any): void {
const cache = this.getWorkflowCache(workflowId)
if (!cache) return
const nodeIndex = cache.nodes.findIndex((n: any) => n.id === node.id)
if (nodeIndex !== -1) {
cache.nodes[nodeIndex] = node
} else {
cache.nodes.push(node)
}
this.setWorkflowCache(workflowId, cache)
},
// Remove a node from the cache
removeNodeFromCache(workflowId: string, nodeId: string): void {
const cache = this.getWorkflowCache(workflowId)
if (!cache) return
console.log('Removing node from cache:', nodeId)
cache.nodes = cache.nodes.filter((n: any) => n.id !== nodeId)
// Also remove any connections involving this node
cache.connections = cache.connections.filter((c: any) =>
c.source_node_id !== nodeId && c.target_node_id !== nodeId
)
this.setWorkflowCache(workflowId, cache)
},
// Add a connection to the cache
addConnectionToCache(workflowId: string, connection: any): void {
const cache = this.getWorkflowCache(workflowId)
if (!cache) return
const existingIndex = cache.connections.findIndex((c: any) => c.id === connection.id)
if (existingIndex !== -1) {
cache.connections[existingIndex] = connection
} else {
cache.connections.push(connection)
}
this.setWorkflowCache(workflowId, cache)
},
// Remove a connection from the cache
removeConnectionFromCache(workflowId: string, connectionId: string): void {
const cache = this.getWorkflowCache(workflowId)
if (!cache) return
cache.connections = cache.connections.filter((c: any) => c.id !== connectionId)
this.setWorkflowCache(workflowId, cache)
}
}
// Create a composable for agent storage
export function useAgentStorage() {
return {
...agentStorage,
}
}
// Create a composable for workflow cache storage
export function useWorkflowCacheStorage() {
return {
...workflowCacheStorage,
}
}
// Create a composable for subscription storage
export function useSubscriptionStorage() {
// Import enterprise types if available
try {
// This will be a dynamic import in the future when needed
return {
...subscriptionStorage,
}
} catch (error) {
console.warn('Enterprise module not available, using basic subscription storage')
return {
...subscriptionStorage,
}
}
}
+40
View File
@@ -0,0 +1,40 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Avatar initials from a display name or email ("John Doe" → "JD",
* "john.doe@x.com" → "JD"). Single source of truth — UserList and GroupList
* still carry older local variants that should migrate here.
*
* Names split on whitespace only, so "Jean-Luc Picard" stays "JP" rather than
* losing the surname to the hyphen. The ./_/- separators apply to emails,
* where they're the only word boundaries available.
*/
export function getInitials(name?: string | null, fallback = '?'): string {
const value = (name || '').trim()
if (!value) return fallback
const isEmail = value.includes('@') && !/\s/.test(value)
const words = isEmail ? value.split('@')[0].split(/[._-]+/) : value.split(/\s+/)
return (
words
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0]?.toUpperCase() || '')
.join('') || fallback
)
}
+45
View File
@@ -0,0 +1,45 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { clientTz, listTz } from 'timezone-select-js'
export interface TimezoneOption {
value: string
label: string
/** Other IANA names the library files under this entry. */
included?: string[]
}
const UTC_OPTION: TimezoneOption = { value: 'UTC', label: '(GMT+00:00) UTC' }
/**
* The timezones an organization can pick. The library's list has no plain UTC —
* the zone servers, containers and CI browsers report — so it is added.
*/
export function timezoneOptions(): TimezoneOption[] {
const options: TimezoneOption[] = listTz()
return options.some((option) => option.value === UTC_OPTION.value) ? options : [UTC_OPTION, ...options]
}
/**
* The browser's timezone as one of `options` — by name, then by alias — or UTC.
* A value the select doesn't list leaves the required field empty, and the
* first-run setup form could not be submitted at all.
*/
export function defaultTimezone(options: TimezoneOption[] = timezoneOptions(), zone: string = clientTz()): string {
const match = options.find((option) => option.value === zone || option.included?.includes(zone))
return match ? match.value : UTC_OPTION.value
}
+72
View File
@@ -0,0 +1,72 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export interface PasswordStrength {
score: number
hasMinLength: boolean
hasUpperCase: boolean
hasLowerCase: boolean
hasNumber: boolean
hasSpecialChar: boolean
}
export const validatePassword = (password: string): PasswordStrength => {
const strength = {
score: 0,
hasMinLength: password.length >= 8,
hasUpperCase: /[A-Z]/.test(password),
hasLowerCase: /[a-z]/.test(password),
hasNumber: /[0-9]/.test(password),
hasSpecialChar: /[!@#$%^&*]/.test(password),
}
strength.score = [
strength.hasMinLength,
strength.hasUpperCase,
strength.hasLowerCase,
strength.hasNumber,
strength.hasSpecialChar,
].filter(Boolean).length
return strength
}
/**
* The bar the API enforces (app/core/security.py: validate_password_strength):
* 8 characters plus at least three of the four character classes. Since the
* length check is one of the five criteria the meter counts, "length met and
* score >= 4" is exactly that rule. Keep the two in step.
*/
export const meetsPasswordPolicy = (strength: PasswordStrength): boolean =>
strength.hasMinLength && strength.score >= 4
export const validateDomain = (domain: string): boolean => {
const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
return domainRegex.test(domain)
}
export const validateEmail = (email: string): boolean => {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
return emailRegex.test(email)
}
export const validateName = (name: string): boolean => {
return name.length >= 2 && name.length <= 100 && /^[a-zA-Z0-9\s\-']+$/.test(name)
}
export const validateOrgName = (name: string): boolean => {
return name.length >= 2 && name.length <= 100 && /^[a-zA-Z0-9\s\-'&.]+$/.test(name)
}