feat(auth): 后台登录、角色权限与会话访问策略
管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
1 parent
5e0615f36d
commit
e24d3e2a65
123 files changed
+25293
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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']),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user