@allbluecn/web-app 0.10.3 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +24 -11
- package/public/favicon-dark.svg +1 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +1 -0
- package/public/logo/allblue-logo-app-icon.svg +7 -0
- package/public/logo/allblue-logo-dark-square.svg +4 -0
- package/public/logo/allblue-logo-dark.png +0 -0
- package/public/logo/allblue-logo-dark.svg +4 -0
- package/public/logo/allblue-logo-light-square.svg +4 -0
- package/public/logo/allblue-logo-light.png +0 -0
- package/public/logo/allblue-logo-light.svg +4 -0
- package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
- package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
- package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
- package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
- package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
- package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
- package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
- package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
- package/src/__tests__/security/xss-sanitize.test.ts +99 -0
- package/src/__tests__/setup.ts +9 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
- package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
- package/src/components/auth/icon-input.tsx +9 -1
- package/src/components/auth/password-input.tsx +9 -1
- package/src/components/changelog/changelog-page.tsx +44 -1
- package/src/components/error/global-error-boundary.tsx +2 -2
- package/src/components/forms/app-field.tsx +3 -1
- package/src/components/knowledge/general-groups.ts +15 -0
- package/src/components/knowledge/kb-filter-popover.tsx +143 -0
- package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
- package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
- package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
- package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
- package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
- package/src/components/notifications/settings-notifications.tsx +41 -1
- package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
- package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
- package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
- package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
- package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
- package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
- package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
- package/src/components/story/relations/general-groups.ts +1 -15
- package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
- package/src/components/story/story-list-view.tsx +7 -1
- package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
- package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
- package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
- package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
- package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
- package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
- package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
- package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
- package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
- package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
- package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
- package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
- package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
- package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
- package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
- package/src/lib/__tests__/edition-desktop.test.ts +23 -0
- package/src/lib/__tests__/msw-sample.test.ts +68 -0
- package/src/lib/__tests__/pgvector.test.ts +2 -2
- package/src/lib/app-version.ts +1 -1
- package/src/lib/auth/auth.config.ts +123 -99
- package/src/lib/changelog/sdk-versions.ts +21 -21
- package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
- package/src/lib/collections/reference/workspaces-collection.ts +3 -1
- package/src/lib/edition-desktop.ts +22 -0
- package/src/lib/fetch-json.ts +23 -0
- package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
- package/src/lib/sanitize.ts +14 -1
- package/src/lib/session.ts +10 -2
- package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
- package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
- package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
- package/src/routeTree.gen.ts +114 -0
- package/src/routes/__root.tsx +20 -3
- package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
- package/src/routes/__tests__/-edition-routing.test.tsx +18 -5
- package/src/routes/__tests__/route-tree-structure.test.ts +66 -0
- package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
- package/src/routes/_login/changelog/confirm/route.tsx +28 -0
- package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
- package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
- package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
- package/src/routes/_login/login/route.lazy.tsx +4 -2
- package/src/routes/_login/register/route.lazy.tsx +28 -17
- package/src/routes/_login/register/route.tsx +8 -1
- package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
- package/src/routes/_login/schemas/-auth.ts +30 -7
- package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
- package/src/routes/_login/verify-email/route.tsx +28 -0
- package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
- package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
- package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
- package/src/routes/_nav/route.tsx +26 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
- package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/routes/_public/index/route.tsx +2 -1
- package/src/routes/api/auth/desktop-callback.ts +2 -2
- package/src/routes/api/billing-webhook.ts +4 -0
- package/src/routes/api/cron/changelog-release.ts +100 -0
- package/src/routes/api/cron/subscription-reminders.ts +110 -0
- package/src/routes/api.auth.$.ts +3 -5
- package/src/server/__tests__/agent-crud.test.ts +4 -4
- package/src/server/__tests__/auth-registration.test.ts +60 -0
- package/src/server/__tests__/chat-conversations.test.ts +1 -3
- package/src/server/__tests__/fetch-guard.test.ts +99 -0
- package/src/server/__tests__/permissions.test.ts +1 -3
- package/src/server/__tests__/resources.team-seats.test.ts +2 -0
- package/src/server/__tests__/skill-crud.test.ts +3 -3
- package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
- package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
- package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
- package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
- package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
- package/src/server/ai-gateway/ai-output-guard.ts +77 -0
- package/src/server/ai-gateway/index.ts +5 -0
- package/src/server/ai-gateway/kb-context-guard.ts +46 -0
- package/src/server/ai-gateway/llm-fence.ts +82 -0
- package/src/server/ai-gateway/persistence/run-store.ts +19 -0
- package/src/server/ai-gateway/story-prompt.ts +4 -2
- package/src/server/ai-gateway/tools/definitions.ts +12 -6
- package/src/server/ai-gateway/tools/server.ts +97 -54
- package/src/server/auth-registration.ts +40 -0
- package/src/server/billing-email.ts +39 -0
- package/src/server/builtin-project.ts +21 -0
- package/src/server/email/__tests__/deliverability.test.ts +68 -0
- package/src/server/email/__tests__/index.test.ts +32 -0
- package/src/server/email/__tests__/outbox.test.tsx +129 -0
- package/src/server/email/__tests__/product-templates.test.tsx +106 -0
- package/src/server/email/__tests__/registry.test.tsx +50 -0
- package/src/server/email/deliverability.ts +57 -0
- package/src/server/email/drivers/console.ts +11 -0
- package/src/server/email/drivers/resend.ts +31 -0
- package/src/server/email/drivers/smtp.ts +27 -0
- package/src/server/email/index.ts +56 -0
- package/src/server/email/outbox.ts +141 -0
- package/src/server/email/preferences.ts +20 -0
- package/src/server/email/registry.ts +38 -0
- package/src/server/email/sender.ts +36 -13
- package/src/server/email/templates/_base/button.tsx +12 -0
- package/src/server/email/templates/_base/email-layout.tsx +117 -0
- package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
- package/src/server/email/templates/_base/theme.ts +22 -0
- package/src/server/email/templates/auth/reset-password.tsx +58 -0
- package/src/server/email/templates/auth/verify-email.tsx +59 -0
- package/src/server/email/templates/auth/welcome.tsx +49 -0
- package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
- package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
- package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
- package/src/server/email/templates/notification/mirror.tsx +50 -0
- package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
- package/src/server/email/templates/product/changelog-release.tsx +65 -0
- package/src/server/email/templates/team/apply-notify.tsx +53 -0
- package/src/server/email/templates/team/approved.tsx +53 -0
- package/src/server/email/templates/team/invite.tsx +57 -0
- package/src/server/fetch-guard.ts +170 -0
- package/src/server/middlewares/security-headers.ts +122 -0
- package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
- package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
- package/src/server/notifications/notification-service.ts +71 -1
- package/src/server/notifications/verify-reminder.ts +55 -0
- package/src/server/oauth-config.ts +59 -0
- package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
- package/src/server/serverFns/__tests__/billing.test.ts +263 -0
- package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
- package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
- package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
- package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
- package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
- package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
- package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
- package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
- package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
- package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
- package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
- package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
- package/src/server/serverFns/__tests__/team-invitation.test.ts +5 -5
- package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
- package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
- package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
- package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
- package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
- package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
- package/src/server/serverFns/auth/email-verification.ts +132 -0
- package/src/server/serverFns/auth/forgot-password.ts +22 -2
- package/src/server/serverFns/auth/providers.ts +4 -3
- package/src/server/serverFns/auth/register.ts +63 -19
- package/src/server/serverFns/auth/registration-mode.ts +23 -0
- package/src/server/serverFns/auth/reset-password.ts +1 -1
- package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
- package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
- package/src/server/serverFns/changelog/subscribe.ts +89 -0
- package/src/server/serverFns/email-unsubscribe.ts +66 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
- package/src/server/serverFns/kb-doc-search.ts +111 -0
- package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
- package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
- package/src/server/serverFns/notifications.ts +10 -0
- package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
- package/src/server/serverFns/story/kb-general-consts.ts +39 -0
- package/src/server/serverFns/story/kb-general.ts +30 -17
- package/src/server/serverFns/story/story-assoc.ts +13 -103
- package/src/server/serverFns/story/story-crud.ts +2 -5
- package/src/server/serverFns/story/story-groups.ts +4 -13
- package/src/server/serverFns/team.ts +50 -12
- package/src/server/serverFns/workspace-kb-utils.ts +138 -0
- package/src/server/serverFns/workspace-kb.ts +403 -0
- package/src/server/serverFns/workspace.ts +4 -1
- package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
- package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
- package/src/shared/ai-cli/adapter.ts +2 -2
- package/src/shared/ai-cli/cli-detector.ts +18 -9
- package/src/start.ts +2 -1
- package/src/styles/globals.css +114 -0
- package/src/types/jest-dom-vitest.d.ts +8 -7
- package/vite.shared.ts +105 -4
- package/public/file.svg +0 -1
- package/public/globe.svg +0 -1
- package/public/window.svg +0 -1
- package/src/lib/__tests__/cli-detector.test.ts +0 -80
- /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
- /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
- /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
- /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
- /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
- /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
|
@@ -21,6 +21,11 @@ import { useMemo } from 'react'
|
|
|
21
21
|
|
|
22
22
|
import { m } from '@/paraglide/messages.js'
|
|
23
23
|
import { isDesktopServer } from '@/server/desktop-guest'
|
|
24
|
+
import { listPinnedWorkspacesBriefFn } from '@/server/serverFns/workspace'
|
|
25
|
+
import { listKnowledgeFn } from '@/server/serverFns/knowledge'
|
|
26
|
+
import { ensureVerifyReminderFn } from '@/server/serverFns/auth/verify-reminder'
|
|
27
|
+
import { WORKSPACES_QUERY_KEY } from '@/lib/collections/reference/workspaces-collection'
|
|
28
|
+
import { KNOWLEDGE_BASES_QUERY_KEY } from '@/lib/collections/knowledge/knowledge-bases-collection'
|
|
24
29
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
|
25
30
|
import { Button } from '@/components/ui/button'
|
|
26
31
|
import { GlobalErrorBoundary } from '@/components/error/global-error-boundary'
|
|
@@ -157,6 +162,27 @@ export const Route = createFileRoute('/_nav')({
|
|
|
157
162
|
})
|
|
158
163
|
}
|
|
159
164
|
},
|
|
165
|
+
loader: async ({ context }) => {
|
|
166
|
+
const { queryClient } = context
|
|
167
|
+
// SSR 预取侧边栏常驻菜单(工作空间/知识库)数据,刷新后 hydrate 首帧即有数据,
|
|
168
|
+
// 避免侧边栏菜单每次刷新重新加载
|
|
169
|
+
await Promise.all([
|
|
170
|
+
queryClient.ensureQueryData({
|
|
171
|
+
queryKey: WORKSPACES_QUERY_KEY,
|
|
172
|
+
queryFn: () => listPinnedWorkspacesBriefFn(),
|
|
173
|
+
}),
|
|
174
|
+
queryClient.ensureQueryData({
|
|
175
|
+
queryKey: KNOWLEDGE_BASES_QUERY_KEY,
|
|
176
|
+
queryFn: () => listKnowledgeFn(),
|
|
177
|
+
}),
|
|
178
|
+
]).catch(() => {
|
|
179
|
+
// 预取失败降级为客户端 fetch(与旧行为一致),不阻塞页面
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
if (context.session?.user?.id) {
|
|
183
|
+
ensureVerifyReminderFn({ data: {} }).catch(() => undefined)
|
|
184
|
+
}
|
|
185
|
+
},
|
|
160
186
|
notFoundComponent: NavNotFound,
|
|
161
187
|
errorComponent: GlobalErrorBoundary,
|
|
162
188
|
})
|
|
@@ -20,21 +20,22 @@ import { useEffect, useState } from "react";
|
|
|
20
20
|
import { useTheme } from "next-themes";
|
|
21
21
|
import { GlyphMatrix } from "@/components/magicui/glyph-matrix";
|
|
22
22
|
import { CreateProjectDialog } from "@/components/project/create-project-dialog";
|
|
23
|
-
import { EditWorkspaceDialog } from "@/components/workspace/edit-workspace-dialog";
|
|
23
|
+
import { EditWorkspaceDialog } from "@/components/workspace/dialogs/edit-workspace-dialog";
|
|
24
24
|
import { NameConfirmDialog } from "@/components/shared-kit/dialogs/name-confirm-dialog";
|
|
25
|
-
import { DeleteWorkspaceOptions } from "@/components/workspace/delete-workspace-options";
|
|
25
|
+
import { DeleteWorkspaceOptions } from "@/components/workspace/dialogs/delete-workspace-options";
|
|
26
26
|
import {
|
|
27
27
|
deleteWorkspaceFn,
|
|
28
28
|
listWorkspaceMembersFn,
|
|
29
29
|
listWorkspacesFn,
|
|
30
30
|
} from "@/server/serverFns/workspace";
|
|
31
31
|
import { deleteProjectFn } from "@/server/serverFns/project";
|
|
32
|
-
import { WorkspaceHeader } from "@/components/workspace/workspace-header";
|
|
33
|
-
import { WorkspaceKbCard } from "@/components/workspace/workspace-kb-card";
|
|
34
|
-
import { WorkspaceContributionCard } from "@/components/workspace/workspace-contribution-card";
|
|
35
|
-
import { WorkspaceProjectList } from "@/components/workspace/workspace-project-list";
|
|
36
|
-
import { WorkspaceActivityCard } from "@/components/workspace/workspace-activity-card";
|
|
37
|
-
import { WorkspaceMembersManager } from "@/components/workspace/workspace-members-manager";
|
|
32
|
+
import { WorkspaceHeader } from "@/components/workspace/header/workspace-header";
|
|
33
|
+
import { WorkspaceKbCard } from "@/components/workspace/kb/workspace-kb-card";
|
|
34
|
+
import { WorkspaceContributionCard } from "@/components/workspace/contribution/workspace-contribution-card";
|
|
35
|
+
import { WorkspaceProjectList } from "@/components/workspace/projects/workspace-project-list";
|
|
36
|
+
import { WorkspaceActivityCard } from "@/components/workspace/activity/workspace-activity-card";
|
|
37
|
+
import { WorkspaceMembersManager } from "@/components/workspace/members/workspace-members-manager";
|
|
38
|
+
import type { WorkspaceKbCardData } from "@/server/serverFns/workspace-kb-utils";
|
|
38
39
|
import { m } from "@/paraglide/messages.js";
|
|
39
40
|
|
|
40
41
|
type DetailData = {
|
|
@@ -75,10 +76,11 @@ type DetailData = {
|
|
|
75
76
|
joinedAt: Date;
|
|
76
77
|
user?: { id: string; username: string; email: string; avatarConfig?: unknown; bgShape?: string | null; bgColor?: string | null };
|
|
77
78
|
}[];
|
|
79
|
+
kbCard: WorkspaceKbCardData;
|
|
78
80
|
};
|
|
79
81
|
|
|
80
82
|
function WorkspaceDetailPage() {
|
|
81
|
-
const { workspace, projects, activities, contributionData, members } = Route.useLoaderData() as DetailData;
|
|
83
|
+
const { workspace, projects, activities, contributionData, members, kbCard } = Route.useLoaderData() as DetailData;
|
|
82
84
|
const [editOpen, setEditOpen] = useState(false);
|
|
83
85
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
84
86
|
const [projectEditOpen, setProjectEditOpen] = useState(false);
|
|
@@ -150,7 +152,7 @@ function WorkspaceDetailPage() {
|
|
|
150
152
|
onEdit={() => setEditOpen(true)}
|
|
151
153
|
onDelete={() => setDeleteOpen(true)}
|
|
152
154
|
/>
|
|
153
|
-
<WorkspaceKbCard />
|
|
155
|
+
<WorkspaceKbCard workspaceId={workspace.id} data={kbCard} />
|
|
154
156
|
<div className="grid gap-6 lg:grid-cols-3">
|
|
155
157
|
<div className="lg:col-span-2 space-y-6">
|
|
156
158
|
<WorkspaceContributionCard data={contributionData} />
|
|
@@ -166,7 +168,7 @@ function WorkspaceDetailPage() {
|
|
|
166
168
|
workspaceId={workspace.id}
|
|
167
169
|
workspaceOwnerId={workspace.ownerId}
|
|
168
170
|
members={members}
|
|
169
|
-
onMembersUpdated={() =>
|
|
171
|
+
onMembersUpdated={() => { void router.invalidate(); }}
|
|
170
172
|
/>
|
|
171
173
|
</div>
|
|
172
174
|
</div>
|
|
@@ -23,17 +23,19 @@ import {
|
|
|
23
23
|
getWorkspaceActivityDataFn,
|
|
24
24
|
listWorkspaceMembersFn,
|
|
25
25
|
} from "@/server/serverFns/workspace";
|
|
26
|
+
import { getWorkspaceKbCardFn } from "@/server/serverFns/workspace-kb";
|
|
26
27
|
|
|
27
28
|
export const Route = createFileRoute("/_nav/workspaces/$workspaceId")({
|
|
28
29
|
loader: async ({ params }) => {
|
|
29
|
-
const [workspace, projects, activities, contributionData, members] = await Promise.all([
|
|
30
|
+
const [workspace, projects, activities, contributionData, members, kbCard] = await Promise.all([
|
|
30
31
|
getWorkspaceDetailFn({ data: { workspaceId: params.workspaceId } }),
|
|
31
32
|
listWorkspaceProjectsFn({ data: { workspaceId: params.workspaceId } }),
|
|
32
33
|
listWorkspaceActivitiesFn({ data: { workspaceId: params.workspaceId, take: 5 } }),
|
|
33
34
|
getWorkspaceActivityDataFn({ data: { workspaceId: params.workspaceId } }),
|
|
34
35
|
listWorkspaceMembersFn({ data: { workspaceId: params.workspaceId } }),
|
|
36
|
+
getWorkspaceKbCardFn({ data: { workspaceId: params.workspaceId } }),
|
|
35
37
|
]);
|
|
36
|
-
return { workspace, projects, activities, contributionData, members };
|
|
38
|
+
return { workspace, projects, activities, contributionData, members, kbCard };
|
|
37
39
|
},
|
|
38
40
|
pendingComponent: () => null,
|
|
39
41
|
});
|
|
@@ -28,7 +28,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
28
28
|
import { LucideIconPicker } from "@/components/shared-kit/icons/lucide-icon-picker";
|
|
29
29
|
|
|
30
30
|
import { NameConfirmDialog } from "@/components/shared-kit/dialogs/name-confirm-dialog";
|
|
31
|
-
import { DeleteWorkspaceOptions } from "@/components/workspace/delete-workspace-options";
|
|
31
|
+
import { DeleteWorkspaceOptions } from "@/components/workspace/dialogs/delete-workspace-options";
|
|
32
32
|
import {
|
|
33
33
|
updateWorkspaceFn,
|
|
34
34
|
deleteWorkspaceFn,
|
|
@@ -30,9 +30,9 @@ import {
|
|
|
30
30
|
import { Separator } from "@/components/ui/separator";
|
|
31
31
|
import { GlyphMatrix } from "@/components/magicui/glyph-matrix";
|
|
32
32
|
import { LucideIconByName } from "@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name";
|
|
33
|
-
import { CreateWorkspaceDialog } from "@/components/workspace/create-workspace-dialog";
|
|
34
|
-
import { DeleteWorkspaceOptions } from "@/components/workspace/delete-workspace-options";
|
|
35
|
-
import { EditWorkspaceDialog } from "@/components/workspace/edit-workspace-dialog";
|
|
33
|
+
import { CreateWorkspaceDialog } from "@/components/workspace/dialogs/create-workspace-dialog";
|
|
34
|
+
import { DeleteWorkspaceOptions } from "@/components/workspace/dialogs/delete-workspace-options";
|
|
35
|
+
import { EditWorkspaceDialog } from "@/components/workspace/dialogs/edit-workspace-dialog";
|
|
36
36
|
import { NameConfirmDialog } from "@/components/shared-kit/dialogs/name-confirm-dialog";
|
|
37
37
|
import { toggleWorkspacePinFn, deleteWorkspaceFn } from "@/server/serverFns/workspace";
|
|
38
38
|
import { toast } from "@/components/ui/sonner";
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
19
19
|
import { isSaasEdition } from '@/lib/edition'
|
|
20
|
+
import { isDesktopEdition } from '@/lib/edition-desktop'
|
|
20
21
|
import AgencyHeroSection from '@/components/shadcn-space/blocks/hero-01'
|
|
21
22
|
|
|
22
23
|
export const Route = createFileRoute('/_public/')({
|
|
23
24
|
beforeLoad: () => {
|
|
24
|
-
if (!isSaasEdition) throw redirect({ to: '/dashboard' })
|
|
25
|
+
if (!isSaasEdition || isDesktopEdition()) throw redirect({ to: '/dashboard' })
|
|
25
26
|
},
|
|
26
27
|
component: IndexPage,
|
|
27
28
|
})
|
|
@@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
|
|
|
2
2
|
import { getSession } from 'start-authjs'
|
|
3
3
|
import { prisma } from '@allbluecn/database'
|
|
4
4
|
import { getProService, isProEdition } from '@/server/pro-services'
|
|
5
|
-
import {
|
|
5
|
+
import { getAuthConfig } from '@/lib/auth/auth.config'
|
|
6
6
|
import { generateEntitlementsToken } from '@/server/entitlements-token'
|
|
7
7
|
import type { FeatureKey } from '@allbluecn/kernel'
|
|
8
8
|
|
|
@@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/auth/desktop-callback')({
|
|
|
10
10
|
server: {
|
|
11
11
|
handlers: {
|
|
12
12
|
GET: async ({ request }) => {
|
|
13
|
-
const session = await getSession(request,
|
|
13
|
+
const session = await getSession(request, await getAuthConfig())
|
|
14
14
|
if (!session?.user) {
|
|
15
15
|
return new Response('未登录', { status: 401 })
|
|
16
16
|
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { createFileRoute } from '@tanstack/react-router'
|
|
19
19
|
import { getProService } from '@/server/pro-services'
|
|
20
20
|
import { WebhookSignatureError } from '@allbluecn/kernel'
|
|
21
|
+
import { sendBillingEmailForEvent } from '@/server/billing-email'
|
|
21
22
|
|
|
22
23
|
export const Route = createFileRoute('/api/billing-webhook')({
|
|
23
24
|
server: {
|
|
@@ -32,6 +33,9 @@ export const Route = createFileRoute('/api/billing-webhook')({
|
|
|
32
33
|
const rawBody = await request.text()
|
|
33
34
|
try {
|
|
34
35
|
await (getProService('billing')!)?.handleWebhookEvent(rawBody, signature)
|
|
36
|
+
if (getProService('billing')) {
|
|
37
|
+
await sendBillingEmailForEvent(rawBody)
|
|
38
|
+
}
|
|
35
39
|
return new Response(JSON.stringify({ received: true }), {
|
|
36
40
|
status: 200,
|
|
37
41
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
19
|
+
import { prisma } from '@allbluecn/database'
|
|
20
|
+
import { sendTemplateEmail } from '@/server/email'
|
|
21
|
+
import { APP_VERSION } from '@/lib/app-version'
|
|
22
|
+
|
|
23
|
+
const BATCH_SIZE = 50
|
|
24
|
+
const LAST_RELEASED_KEY = 'changelog.lastReleasedVersion'
|
|
25
|
+
|
|
26
|
+
async function isAuthorized(request: Request): Promise<boolean> {
|
|
27
|
+
const secret = process.env.CRON_SECRET
|
|
28
|
+
if (!secret) {
|
|
29
|
+
return process.env.NODE_ENV !== 'production'
|
|
30
|
+
}
|
|
31
|
+
return (request.headers.get('authorization') ?? '') === `Bearer ${secret}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const Route = createFileRoute('/api/cron/changelog-release')({
|
|
35
|
+
server: {
|
|
36
|
+
handlers: {
|
|
37
|
+
GET: async ({ request }) => {
|
|
38
|
+
if (!(await isAuthorized(request))) {
|
|
39
|
+
return Response.json({ error: 'Forbidden' }, { status: 403 })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const current = APP_VERSION
|
|
43
|
+
const last = await prisma.systemSetting.findUnique({
|
|
44
|
+
where: { key: LAST_RELEASED_KEY },
|
|
45
|
+
})
|
|
46
|
+
if (last?.value === current) {
|
|
47
|
+
return Response.json({ done: true, reason: 'up-to-date' })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
|
|
51
|
+
|
|
52
|
+
// Prisma 无 NOT EXISTS 便捷写法:用 emailLog.dedupeKey 前缀反查已发送 email,
|
|
53
|
+
// 再在 subscriber 查询层用 notIn 排除,避免「取 batch → filter」导致的死循环。
|
|
54
|
+
const sentEmails = await prisma.emailLog.findMany({
|
|
55
|
+
where: { dedupeKey: { startsWith: `release:${current}:` } },
|
|
56
|
+
select: { to: true },
|
|
57
|
+
})
|
|
58
|
+
const sentEmailSet = new Set(sentEmails.map((e) => e.to))
|
|
59
|
+
|
|
60
|
+
const subs = await prisma.emailSubscriber.findMany({
|
|
61
|
+
where: {
|
|
62
|
+
confirmedAt: { not: null },
|
|
63
|
+
unsubscribedAt: null,
|
|
64
|
+
email: { notIn: [...sentEmailSet] },
|
|
65
|
+
},
|
|
66
|
+
select: { email: true, locale: true, confirmToken: true },
|
|
67
|
+
take: BATCH_SIZE,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
let sent = 0
|
|
71
|
+
for (const sub of subs) {
|
|
72
|
+
const dedupeKey = `release:${current}:${sub.email}`
|
|
73
|
+
const res = await sendTemplateEmail({
|
|
74
|
+
key: 'product.changelog-release',
|
|
75
|
+
to: sub.email,
|
|
76
|
+
locale: sub.locale,
|
|
77
|
+
dedupeKey,
|
|
78
|
+
unsubscribeUrl: `${appUrl}/email-unsubscribe?token=${sub.confirmToken}`,
|
|
79
|
+
vars: {
|
|
80
|
+
version: current,
|
|
81
|
+
highlights: '',
|
|
82
|
+
detailUrl: `${appUrl}/changelog`,
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
if (res.sent) sent++
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (subs.length < BATCH_SIZE) {
|
|
89
|
+
await prisma.systemSetting.upsert({
|
|
90
|
+
where: { key: LAST_RELEASED_KEY },
|
|
91
|
+
create: { key: LAST_RELEASED_KEY, value: current, desc: 'changelog 已发布版本号' },
|
|
92
|
+
update: { value: current },
|
|
93
|
+
})
|
|
94
|
+
return Response.json({ done: true, sent })
|
|
95
|
+
}
|
|
96
|
+
return Response.json({ done: false, sent })
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
19
|
+
import { prisma } from '@allbluecn/database'
|
|
20
|
+
import { sendTemplateEmail } from '@/server/email'
|
|
21
|
+
|
|
22
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
23
|
+
|
|
24
|
+
const WINDOWS = [
|
|
25
|
+
{ days: 7, offsetMs: 7 * DAY_MS },
|
|
26
|
+
{ days: 3, offsetMs: 3 * DAY_MS },
|
|
27
|
+
{ days: 1, offsetMs: 1 * DAY_MS },
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
function startOfDay(d: Date): Date {
|
|
31
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dateOnly(d: Date): string {
|
|
35
|
+
return d.toISOString().slice(0, 10)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function isAuthorized(request: Request): Promise<boolean> {
|
|
39
|
+
const secret = process.env.CRON_SECRET
|
|
40
|
+
if (!secret) {
|
|
41
|
+
return process.env.NODE_ENV !== 'production'
|
|
42
|
+
}
|
|
43
|
+
return (request.headers.get('authorization') ?? '') === `Bearer ${secret}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const Route = createFileRoute('/api/cron/subscription-reminders')({
|
|
47
|
+
server: {
|
|
48
|
+
handlers: {
|
|
49
|
+
GET: async ({ request }) => {
|
|
50
|
+
if (!(await isAuthorized(request))) {
|
|
51
|
+
return Response.json({ error: 'Forbidden' }, { status: 403 })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const todayStart = startOfDay(new Date())
|
|
55
|
+
const wideUntil = new Date(todayStart.getTime() + WINDOWS[0].offsetMs)
|
|
56
|
+
|
|
57
|
+
const subs = await prisma.subscription.findMany({
|
|
58
|
+
where: {
|
|
59
|
+
OR: [
|
|
60
|
+
{
|
|
61
|
+
status: 'ACTIVE',
|
|
62
|
+
cancelAtPeriodEnd: true,
|
|
63
|
+
currentPeriodEnd: { gte: todayStart, lte: wideUntil },
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
status: 'TRIALING',
|
|
67
|
+
trialEndsAt: { gte: todayStart, lte: wideUntil },
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
include: {
|
|
72
|
+
user: { select: { email: true, locale: true } },
|
|
73
|
+
plan: { select: { displayName: true } },
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
|
|
78
|
+
let processed = 0
|
|
79
|
+
|
|
80
|
+
for (const sub of subs) {
|
|
81
|
+
if (!sub.user?.email || !sub.plan) continue
|
|
82
|
+
const endDate = sub.status === 'TRIALING' ? sub.trialEndsAt : sub.currentPeriodEnd
|
|
83
|
+
if (!endDate) continue
|
|
84
|
+
const endDateStr = dateOnly(endDate)
|
|
85
|
+
|
|
86
|
+
for (const w of WINDOWS) {
|
|
87
|
+
const until = new Date(todayStart.getTime() + w.offsetMs)
|
|
88
|
+
if (endDate < todayStart || endDate > until) continue
|
|
89
|
+
const dedupeKey = `sub-reminder:${sub.id}:${endDateStr}:${w.days}`
|
|
90
|
+
const res = await sendTemplateEmail({
|
|
91
|
+
key: 'billing.subscription-ending',
|
|
92
|
+
to: sub.user.email,
|
|
93
|
+
locale: sub.user.locale ?? 'zh',
|
|
94
|
+
userId: sub.userId,
|
|
95
|
+
dedupeKey,
|
|
96
|
+
vars: {
|
|
97
|
+
planName: sub.plan.displayName,
|
|
98
|
+
endDate: endDateStr,
|
|
99
|
+
dashboardUrl: `${appUrl}/settings/billing`,
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
if (res.sent) processed++
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return Response.json({ processed })
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
})
|
package/src/routes/api.auth.$.ts
CHANGED
|
@@ -16,16 +16,14 @@
|
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
18
|
import { createFileRoute } from '@tanstack/react-router'
|
|
19
|
-
import {
|
|
19
|
+
import { getAuthConfig } from '@/lib/auth/auth.config'
|
|
20
20
|
import { StartAuthJS } from 'start-authjs'
|
|
21
21
|
|
|
22
|
-
const authHandlers = StartAuthJS(authConfig)
|
|
23
|
-
|
|
24
22
|
export const Route = createFileRoute('/api/auth/$')({
|
|
25
23
|
server: {
|
|
26
24
|
handlers: {
|
|
27
|
-
GET: ({ request }) =>
|
|
28
|
-
POST: ({ request }) =>
|
|
25
|
+
GET: async ({ request }) => (await StartAuthJS(await getAuthConfig())).GET({ request }),
|
|
26
|
+
POST: async ({ request }) => (await StartAuthJS(await getAuthConfig())).POST({ request }),
|
|
29
27
|
},
|
|
30
28
|
},
|
|
31
29
|
})
|
|
@@ -178,14 +178,14 @@ describe('agent-crud serverFn', () => {
|
|
|
178
178
|
|
|
179
179
|
it('FeatureUnavailableError / FeatureForbiddenError → 抛错不建', async () => {
|
|
180
180
|
mocks.requireFeature.mockRejectedValue(new Error('FeatureUnavailableError: xxx'))
|
|
181
|
-
await expect(createAgentFn({ data: { name: 'X', systemPrompt: 'Y' } })).rejects.
|
|
181
|
+
await expect(createAgentFn({ data: { name: 'X', systemPrompt: 'Y' } })).rejects.toThrow(/FeatureUnavailable|FeatureForbidden/)
|
|
182
182
|
expect(mocks.create).not.toHaveBeenCalled()
|
|
183
183
|
})
|
|
184
184
|
|
|
185
185
|
it('ResourceLimitError → 抛错不建', async () => {
|
|
186
186
|
mocks.requireFeature.mockResolvedValue(undefined as any)
|
|
187
187
|
mocks.requireResource.mockRejectedValue(new Error('ResourceLimitError: agentQuota 已达上限'))
|
|
188
|
-
await expect(createAgentFn({ data: { name: 'X', systemPrompt: 'Y' } })).rejects.
|
|
188
|
+
await expect(createAgentFn({ data: { name: 'X', systemPrompt: 'Y' } })).rejects.toThrow(/ResourceLimit|已达上限/)
|
|
189
189
|
expect(mocks.create).not.toHaveBeenCalled()
|
|
190
190
|
})
|
|
191
191
|
|
|
@@ -236,7 +236,7 @@ describe('agent-crud serverFn', () => {
|
|
|
236
236
|
|
|
237
237
|
it('userId 不匹配 → 抛错且不调用 update', async () => {
|
|
238
238
|
mocks.findFirst.mockResolvedValue(null)
|
|
239
|
-
await expect(updateAgentFn({ data: { id: 'ag-other', name: 'X' } })).rejects.
|
|
239
|
+
await expect(updateAgentFn({ data: { id: 'ag-other', name: 'X' } })).rejects.toThrow(/无权限|FORBIDDEN|not found/)
|
|
240
240
|
expect(mocks.update).not.toHaveBeenCalled()
|
|
241
241
|
})
|
|
242
242
|
|
|
@@ -272,7 +272,7 @@ describe('agent-crud serverFn', () => {
|
|
|
272
272
|
|
|
273
273
|
it('userId 不匹配 → 抛错且不调用 update', async () => {
|
|
274
274
|
mocks.findFirst.mockResolvedValue(null)
|
|
275
|
-
await expect(deleteAgentFn({ data: { id: 'ag-other' } })).rejects.
|
|
275
|
+
await expect(deleteAgentFn({ data: { id: 'ag-other' } })).rejects.toThrow(/无权限|FORBIDDEN|not found/)
|
|
276
276
|
expect(mocks.update).not.toHaveBeenCalled()
|
|
277
277
|
})
|
|
278
278
|
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { consumeInvitationCode, type ConsumeResult } from '../auth-registration'
|
|
3
|
+
import type { Prisma } from '@allbluecn/database'
|
|
4
|
+
|
|
5
|
+
type TxMock = {
|
|
6
|
+
invitationCode: {
|
|
7
|
+
findUnique: ReturnType<typeof vi.fn>
|
|
8
|
+
updateMany: ReturnType<typeof vi.fn>
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeTx(overrides: Record<string, unknown> = {}): { tx: Prisma.TransactionClient; mock: TxMock } {
|
|
13
|
+
const mock: TxMock = {
|
|
14
|
+
invitationCode: {
|
|
15
|
+
findUnique: vi.fn().mockResolvedValue({ id: 'code_1', maxUses: 2 }),
|
|
16
|
+
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
|
17
|
+
...overrides,
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
return { tx: mock as unknown as Prisma.TransactionClient, mock }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('consumeInvitationCode', () => {
|
|
24
|
+
it('有效邀请码:占一位并返回 codeId', async () => {
|
|
25
|
+
const { tx, mock } = makeTx()
|
|
26
|
+
const r: ConsumeResult = await consumeInvitationCode(tx, 'ABC')
|
|
27
|
+
expect(r).toEqual({ ok: true, codeId: 'code_1' })
|
|
28
|
+
expect(mock.invitationCode.updateMany).toHaveBeenCalledWith({
|
|
29
|
+
where: expect.objectContaining({
|
|
30
|
+
id: 'code_1',
|
|
31
|
+
usedCount: { lt: 2 },
|
|
32
|
+
disabledAt: null,
|
|
33
|
+
expiresAt: { gt: expect.any(Date) },
|
|
34
|
+
}),
|
|
35
|
+
data: { usedCount: { increment: 1 } },
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('邀请码不存在:返回「邀请码无效」', async () => {
|
|
40
|
+
const { tx, mock } = makeTx({ findUnique: vi.fn().mockResolvedValue(null) })
|
|
41
|
+
const r = await consumeInvitationCode(tx, 'NOPE')
|
|
42
|
+
expect(r).toEqual({ ok: false, error: '邀请码无效' })
|
|
43
|
+
expect(mock.invitationCode.updateMany).not.toHaveBeenCalled()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('配额耗尽/过期/禁用:updateMany count=0 → 返回用尽错误', async () => {
|
|
47
|
+
const { tx } = makeTx({ updateMany: vi.fn().mockResolvedValue({ count: 0 }) })
|
|
48
|
+
const r = await consumeInvitationCode(tx, 'FULL')
|
|
49
|
+
expect(r).toEqual({ ok: false, error: '邀请码已用尽、过期或被禁用' })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('邀请码大小写与首尾空格归一化后匹配', async () => {
|
|
53
|
+
const { tx, mock } = makeTx()
|
|
54
|
+
await consumeInvitationCode(tx, ' abc ')
|
|
55
|
+
expect(mock.invitationCode.findUnique).toHaveBeenCalledWith({
|
|
56
|
+
where: { code: 'ABC' },
|
|
57
|
+
select: { id: true, maxUses: true },
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -145,9 +145,7 @@ describe("createConversationFn", () => {
|
|
|
145
145
|
|
|
146
146
|
await expect(
|
|
147
147
|
createConversationFn({ data: { agentId: "agent-xxx" } }),
|
|
148
|
-
).rejects.
|
|
149
|
-
message: expect.stringContaining("not found"),
|
|
150
|
-
})
|
|
148
|
+
).rejects.toThrow("not found")
|
|
151
149
|
expect(mocks.create).not.toHaveBeenCalled()
|
|
152
150
|
})
|
|
153
151
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { assertFetchableUrl, FetchGuardError } from "../fetch-guard";
|
|
3
|
+
|
|
4
|
+
describe("assertFetchableUrl SSRF 主机防护", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
"http://127.0.0.1:8080/api",
|
|
7
|
+
"http://localhost:3000/x",
|
|
8
|
+
"http://10.0.0.5/internal",
|
|
9
|
+
"http://172.16.1.1/admin",
|
|
10
|
+
"http://192.168.1.1/router",
|
|
11
|
+
"http://169.254.169.254/latest/meta-data", // 云元数据
|
|
12
|
+
"http://0.0.0.0/",
|
|
13
|
+
"http://[::1]:8080/",
|
|
14
|
+
"http://[fe80::1]/", // 链路本地
|
|
15
|
+
"http://[::ffff:127.0.0.1]/", // IPv4-mapped IPv6 回环(SSRF 经典绕过)
|
|
16
|
+
"http://[::ffff:a9fe:a9fe]/", // IPv4-mapped 云元数据
|
|
17
|
+
"http://[::ffff:10.0.0.5]/", // mapped 归一形态 ::ffff:a00:5(URL 压缩组前导零)
|
|
18
|
+
"http://[::ffff:0:0:127.0.0.1]/", // IPv4-translated 变体(::ffff:0:0/96 前缀)
|
|
19
|
+
"http://[::127.0.0.1]/", // IPv4-compatible ::/96 回环
|
|
20
|
+
"http://[::a9fe:a9fe]/", // IPv4-compatible 云元数据
|
|
21
|
+
"http://[::10.0.0.5]/", // IPv4-compatible A 类私网
|
|
22
|
+
"http://[fc00::1]/", // IPv6 ULA 私网段
|
|
23
|
+
"http://[fd12:3456::1]/",
|
|
24
|
+
])("拒绝私网/回环/元数据地址:%s", (url) => {
|
|
25
|
+
expect(() => assertFetchableUrl(url)).toThrow(FetchGuardError);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it.each([
|
|
29
|
+
"https://api.openai.com/v1/models",
|
|
30
|
+
"http://example.com/page",
|
|
31
|
+
])("放行公网地址:%s", (url) => {
|
|
32
|
+
expect(() => assertFetchableUrl(url)).not.toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each([
|
|
36
|
+
["http://[2001:4860:4860::8888]/", "普通公网 IPv6"],
|
|
37
|
+
["http://[::ffff:8.8.8.8]/", "IPv4-mapped 公网(防映射还原分支误伤)"],
|
|
38
|
+
["http://[::ffff:0:0:8.8.8.8]/", "IPv4-mapped 翻译形态公网(防误伤对称覆盖)"],
|
|
39
|
+
["http://[::8.8.8.8]/", "IPv4-compatible 公网(防内嵌分支误伤)"],
|
|
40
|
+
["http://[2606:4700:4700::1111]/", "Cloudflare 公网 IPv6"],
|
|
41
|
+
])("公网 IPv6 地址放行:%s(%s)", (url) => {
|
|
42
|
+
expect(() => assertFetchableUrl(url)).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it.each([
|
|
46
|
+
"file:///etc/passwd",
|
|
47
|
+
"ftp://example.com/f",
|
|
48
|
+
"gopher://example.com/",
|
|
49
|
+
])("仅允许 http/https 协议:%s", (url) => {
|
|
50
|
+
expect(() => assertFetchableUrl(url)).toThrow(FetchGuardError);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("allowPrivate 显式放行内网(SearXNG 自托管场景)", () => {
|
|
54
|
+
expect(() =>
|
|
55
|
+
assertFetchableUrl("http://127.0.0.1:8888/search", { allowPrivate: true }),
|
|
56
|
+
).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("allowHosts 白名单精确匹配放行", () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
assertFetchableUrl("https://searx.internal.corp/search", {
|
|
62
|
+
allowedHosts: ["searx.internal.corp"],
|
|
63
|
+
}),
|
|
64
|
+
).not.toThrow();
|
|
65
|
+
expect(() =>
|
|
66
|
+
assertFetchableUrl("https://evil.corp/search", {
|
|
67
|
+
allowedHosts: ["searx.internal.corp"],
|
|
68
|
+
}),
|
|
69
|
+
).toThrow(FetchGuardError);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it.each([
|
|
73
|
+
["http://2130706433/", "十进制整数 127.0.0.1"],
|
|
74
|
+
["http://0x7f000001/", "十六进制 127.0.0.1"],
|
|
75
|
+
["http://017700000001/", "八进制整数 127.0.0.1"],
|
|
76
|
+
["http://0177.0.0.1/", "点分八进制 127.0.0.1"],
|
|
77
|
+
["http://99999999999999/", "超出 IPv4 范围(URL 构造期抛 Invalid URL)"],
|
|
78
|
+
])("拒绝整数形式 IP 混淆:%s(%s)", (url) => {
|
|
79
|
+
expect(() => assertFetchableUrl(url)).toThrow(FetchGuardError);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("返回规范化 URL 供 fetch 使用", () => {
|
|
83
|
+
expect(assertFetchableUrl("https://example.com/a?q=1").href).toBe(
|
|
84
|
+
"https://example.com/a?q=1",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("大写协议与省略段 IPv4 规范化后仍被拒绝", () => {
|
|
89
|
+
expect(() => assertFetchableUrl("HTTP://127.0.0.1/x")).toThrow(FetchGuardError);
|
|
90
|
+
expect(() => assertFetchableUrl("http://127.1/")).toThrow(FetchGuardError);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("协议被拒绝时错误类型仍是 FetchGuardError", () => {
|
|
94
|
+
expect(() => assertFetchableUrl("file:///etc/passwd")).toThrow(FetchGuardError);
|
|
95
|
+
expect(() => assertFetchableUrl("file:///etc/passwd")).toThrow(
|
|
96
|
+
/不在允许范围/,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -62,8 +62,6 @@ describe('requirePermission', () => {
|
|
|
62
62
|
it('有角色但 checkPermission 返回 none → 拒绝', async () => {
|
|
63
63
|
vi.mocked(prisma.roleAssignment.count).mockResolvedValue(1)
|
|
64
64
|
vi.mocked(getProService('permissions')!.checkPermission).mockResolvedValue('none')
|
|
65
|
-
await expect(requirePermission('u1', 'knowledge', 'write')).rejects.
|
|
66
|
-
name: 'PermissionForbiddenError',
|
|
67
|
-
})
|
|
65
|
+
await expect(requirePermission('u1', 'knowledge', 'write')).rejects.toBeInstanceOf(PermissionForbiddenError)
|
|
68
66
|
})
|
|
69
67
|
})
|