@allbluecn/web-app 0.4.16 → 0.4.18

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.
Files changed (78) hide show
  1. package/package.json +10 -10
  2. package/src/components/billing/checkout-redirect.ts +1 -1
  3. package/src/components/changelog/changelog-page.tsx +1 -1
  4. package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
  5. package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
  6. package/src/components/layout/sidebar-03/user-menu.tsx +5 -4
  7. package/src/components/notifications/notification-list.tsx +232 -0
  8. package/src/components/notifications/notification-preferences.tsx +156 -0
  9. package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
  10. package/src/components/settings/ai/provider-grid.tsx +46 -25
  11. package/src/components/settings/personal-team/team/team-client.tsx +118 -15
  12. package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
  13. package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
  14. package/src/components/story/display/__tests__/display-dropdown.test.tsx +30 -7
  15. package/src/components/story/display/display-dropdown.tsx +6 -7
  16. package/src/components/story/inline-editors/assignee-editor.tsx +72 -57
  17. package/src/components/story/story-list-view.tsx +13 -2
  18. package/src/hooks/__tests__/use-notifications.test.ts +41 -0
  19. package/src/hooks/use-notifications.ts +119 -0
  20. package/src/lib/app-version.ts +4 -1
  21. package/src/lib/auth/auth.config.ts +17 -1
  22. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  23. package/src/lib/changelog/sdk-versions.ts +29 -19
  24. package/src/lib/collections/ai/index.ts +0 -1
  25. package/src/lib/collections/index.ts +0 -1
  26. package/src/plugins/.gen-manifest.json +13 -0
  27. package/src/plugins/modules.gen.ts +20 -0
  28. package/src/plugins/plugins.gen.ts +20 -0
  29. package/src/plugins/ui.gen.ts +10 -0
  30. package/src/routeTree.gen.ts +70 -0
  31. package/src/routes/_login/login/route.tsx +10 -1
  32. package/src/routes/_login/register/route.lazy.tsx +3 -2
  33. package/src/routes/_login/register/route.tsx +5 -0
  34. package/src/routes/_nav/inspiration/route.tsx +8 -0
  35. package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
  36. package/src/routes/_nav/notifications/route.tsx +8 -0
  37. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  38. package/src/routes/_nav/prd/index.tsx +9 -0
  39. package/src/routes/_nav/prd/route.tsx +8 -0
  40. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  41. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  42. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  43. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  44. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  45. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  46. package/src/routes/_nav/stories/$storyId/route.tsx +2 -3
  47. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  48. package/src/routes/_nav/tags/index.tsx +8 -0
  49. package/src/routes/_nav/tags/route.tsx +8 -0
  50. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  51. package/src/routes/api/notifications/stream.ts +88 -0
  52. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  53. package/src/routes/invite/$token/route.tsx +16 -8
  54. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  55. package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
  56. package/src/server/notifications/__tests__/stream.test.ts +30 -0
  57. package/src/server/notifications/event-bus.ts +16 -0
  58. package/src/server/notifications/notification-service.ts +86 -0
  59. package/src/server/resources.ts +10 -2
  60. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  61. package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
  62. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  63. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  64. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  65. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  66. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  67. package/src/server/serverFns/billing.ts +23 -2
  68. package/src/server/serverFns/notifications.ts +155 -0
  69. package/src/server/serverFns/story/__tests__/story-groups.test.ts +4 -0
  70. package/src/server/serverFns/story/story-comments.ts +42 -0
  71. package/src/server/serverFns/story/story-crud/labels.ts +45 -6
  72. package/src/server/serverFns/story/story-crud.ts +8 -2
  73. package/src/server/serverFns/story/story-groups.ts +26 -7
  74. package/src/server/serverFns/story/story-search.ts +4 -0
  75. package/src/server/serverFns/story-display-options.ts +15 -4
  76. package/src/server/serverFns/team.ts +573 -53
  77. package/src/start.ts +23 -1
  78. package/vite.shared.ts +13 -0
@@ -16,7 +16,7 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import { useMemo, useState } from "react";
19
- import { CircleUser, Search, Check } from "lucide-react";
19
+ import { CircleUser, Search, Check, ArrowDownUp } from "lucide-react";
20
20
  import { useTranslation } from "react-i18next";
21
21
  import { Input } from "@/components/ui/input";
22
22
  import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -38,18 +38,22 @@ interface AssigneeEditorProps {
38
38
  className?: string;
39
39
  /** 渲染形态:chip=有值时显示 chip 边框(默认,筛选场景),button=始终 Button(列表/详情场景) */
40
40
  variant?: "chip" | "button";
41
+ /** 成员范围:'project'=仅项目成员(默认),'team'=所有团队成员 */
42
+ scope?: "project" | "team";
41
43
  }
42
44
 
43
- export function AssigneeEditor({ assignee, users, onChange, mode = "single", selected, onMultiChange, className, variant = "chip" }: AssigneeEditorProps) {
45
+ export function AssigneeEditor({ assignee, users, onChange, mode = "single", selected, onMultiChange, className, variant = "chip", scope = "project" }: AssigneeEditorProps) {
44
46
  const { t } = useTranslation("story");
45
47
  const [open, setOpen] = useState(false);
46
48
  const [search, setSearch] = useState("");
47
49
  const isMulti = mode === "multi";
48
50
  const showChip = variant === "chip";
51
+ const [currentScope, setCurrentScope] = useState(scope);
49
52
  const filtered = useMemo(
50
53
  () => (search ? users.filter((u) => u.username.includes(search)) : users),
51
54
  [users, search],
52
55
  );
56
+ const isProjectScope = currentScope === "project";
53
57
 
54
58
  return (
55
59
  <Popover open={open} onOpenChange={(v) => { setOpen(v); if (v) setSearch(""); }}>
@@ -110,69 +114,80 @@ export function AssigneeEditor({ assignee, users, onChange, mode = "single", sel
110
114
  className={className}
111
115
  />
112
116
  )}
113
- <PopoverContent className="w-52 p-1" align="start">
114
- <div className="relative mb-1">
115
- <Search className="absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
117
+ <PopoverContent className="w-52 p-1 flex flex-col gap-0 max-h-72" align="start">
118
+ <div className="relative mb-1 shrink-0 sticky top-0 z-10 bg-popover py-0.5 -mx-1 px-1 -mt-1 flex items-center gap-1">
119
+ <Search className="absolute top-1/2 left-4 size-3.5 -translate-y-1/2 text-muted-foreground" />
116
120
  <Input
117
121
  value={search}
118
122
  onChange={(e) => setSearch(e.target.value)}
119
- placeholder={t("inline.searchMembers")}
120
- className="h-7 pl-7 pr-2 text-xs md:text-xs"
123
+ placeholder={isProjectScope ? t("inline.searchProjectMembers") : t("inline.searchTeamMembers")}
124
+ className="h-6 pl-8 pr-2 text-xs md:text-xs"
121
125
  />
122
- </div>
123
- {!isMulti && (
124
126
  <button
125
127
  type="button"
126
- onClick={() => { onChange(null); setOpen(false); }}
127
- className={cn(
128
- "flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs",
129
- !assignee ? "bg-accent font-medium" : "hover:bg-muted",
130
- )}
128
+ onClick={() => setCurrentScope(isProjectScope ? "team" : "project")}
129
+ className="shrink-0 flex items-center gap-0.5 text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 rounded hover:bg-accent"
130
+ title={isProjectScope ? t("inline.switchToTeam") : t("inline.switchToProject")}
131
131
  >
132
- <CircleUser strokeWidth={1.5} className="size-6 text-muted-foreground" />
133
- {t("filter.unassignedAssignee")}
132
+ <ArrowDownUp className="size-3" />
133
+ {isProjectScope ? t("inline.scopeTeam") : t("inline.scopeProject")}
134
134
  </button>
135
- )}
136
- {filtered.length === 0 ? (
137
- <div className="px-2 py-1.5 text-xs text-muted-foreground">{t("inline.noMatchMembers")}</div>
138
- ) : (
139
- filtered.map((u) => {
140
- const active = isMulti ? (selected ?? []).includes(u.id) : assignee?.id === u.id;
141
- return (
142
- <button
143
- key={u.id}
144
- type="button"
145
- onClick={() => {
146
- if (isMulti) {
147
- onMultiChange?.(u.id);
148
- } else {
149
- onChange(u.id);
150
- setOpen(false);
151
- }
152
- }}
153
- className={cn(
154
- "flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs",
155
- active ? "bg-accent font-medium" : "hover:bg-muted",
156
- )}
157
- >
158
- {isMulti && (
159
- <div className={cn("flex size-4 shrink-0 items-center justify-center rounded-sm border", active ? "border-primary bg-primary text-primary-foreground" : "border-input")}>
160
- {active && <Check className="size-3" />}
161
- </div>
162
- )}
163
- <UserAvatar
164
- avatarConfig={u.avatarConfig}
165
- bgShape={u.bgShape as "circle" | "rounded" | "square" | null}
166
- bgColor={u.bgColor}
167
- name={u.username}
168
- size="sm"
169
- />
170
- {u.username}
171
- {!isMulti && active && <Check className="ml-auto size-3.5 shrink-0 text-primary" />}
172
- </button>
173
- );
174
- })
175
- )}
135
+ </div>
136
+ <div className="overflow-y-auto min-h-0 flex-1 -mx-1 -mb-1 p-1">
137
+ {!isMulti && (
138
+ <button
139
+ type="button"
140
+ onClick={() => { onChange(null); setOpen(false); }}
141
+ className={cn(
142
+ "flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs",
143
+ !assignee ? "bg-accent font-medium" : "hover:bg-muted",
144
+ )}
145
+ >
146
+ <CircleUser strokeWidth={1.5} className="size-6 text-muted-foreground" />
147
+ {t("filter.unassignedAssignee")}
148
+ </button>
149
+ )}
150
+ {filtered.length === 0 ? (
151
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">{t("inline.noMatchMembers")}</div>
152
+ ) : (
153
+ filtered.map((u) => {
154
+ const active = isMulti ? (selected ?? []).includes(u.id) : assignee?.id === u.id;
155
+ return (
156
+ <button
157
+ key={u.id}
158
+ type="button"
159
+ onClick={() => {
160
+ if (isMulti) {
161
+ onMultiChange?.(u.id);
162
+ } else {
163
+ onChange(u.id);
164
+ setOpen(false);
165
+ }
166
+ }}
167
+ className={cn(
168
+ "flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs",
169
+ active ? "bg-accent font-medium" : "hover:bg-muted",
170
+ )}
171
+ >
172
+ {isMulti && (
173
+ <div className={cn("flex size-4 shrink-0 items-center justify-center rounded-sm border", active ? "border-primary bg-primary text-primary-foreground" : "border-input")}>
174
+ {active && <Check className="size-3" />}
175
+ </div>
176
+ )}
177
+ <UserAvatar
178
+ avatarConfig={u.avatarConfig}
179
+ bgShape={u.bgShape as "circle" | "rounded" | "square" | null}
180
+ bgColor={u.bgColor}
181
+ name={u.username}
182
+ size="sm"
183
+ />
184
+ {u.username}
185
+ {!isMulti && active && <Check className="ml-auto size-3.5 shrink-0 text-primary" />}
186
+ </button>
187
+ );
188
+ })
189
+ )}
190
+ </div>
176
191
  </PopoverContent>
177
192
  </Popover>
178
193
  );
@@ -28,7 +28,7 @@ import { CalendarView } from "./views/calendar-view";
28
28
  import { TimelineView } from "./views/timeline-view";
29
29
  import { StoryList } from "./views/story-list";
30
30
  import { serializeFilters, type FilterCondition } from "./filters";
31
- import { listStoriesFn, archiveStoryFn } from "@/server/serverFns/story";
31
+ import { listStoriesFn, archiveStoryFn, listUsersBriefFn } from "@/server/serverFns/story";
32
32
  import { listProjectsBriefFn } from "@/server/serverFns/project";
33
33
  import {
34
34
  listStoryViewsFn, createStoryViewFn, updateStoryViewFn, deleteStoryViewFn,
@@ -64,7 +64,7 @@ interface StoryListViewProps {
64
64
  const STORY_PROJECT_FILTER_KEY = "story.projectFilter";
65
65
  const STORY_SEARCH_KEY = "story.search";
66
66
 
67
- export function StoryListView({ stories: initialStories, users, projects: initialProjects, labels }: StoryListViewProps) {
67
+ export function StoryListView({ stories: initialStories, users: initialUsers, projects: initialProjects, labels }: StoryListViewProps) {
68
68
  const { t } = useTranslation("story");
69
69
  const { session } = useRouteContext({ from: "/_nav/stories/" });
70
70
  const navigate = useNavigate({ from: "/stories" });
@@ -77,6 +77,7 @@ export function StoryListView({ stories: initialStories, users, projects: initia
77
77
  const [filterConditions, setFilterConditions] = useState<FilterCondition[]>([]);
78
78
  const [stories, setStories] = useState(initialStories);
79
79
  const [projects, setProjects] = useState(initialProjects);
80
+ const [users, setUsers] = useState<UserBrief[]>(initialUsers);
80
81
  const [projectId, setProjectId] = useState<string | undefined>(() => {
81
82
  try {
82
83
  return localStorage.getItem(STORY_PROJECT_FILTER_KEY) ?? undefined;
@@ -105,6 +106,16 @@ export function StoryListView({ stories: initialStories, users, projects: initia
105
106
  setProjects(initialProjects);
106
107
  }, [initialStories, initialProjects]);
107
108
 
109
+ useEffect(() => {
110
+ if (!projectId || projectId === "__archived__") {
111
+ setUsers(initialUsers);
112
+ return;
113
+ }
114
+ listUsersBriefFn({ data: { projectId } })
115
+ .then((u: UserBrief[]) => setUsers(u))
116
+ .catch(() => {});
117
+ }, [projectId]);
118
+
108
119
  useEffect(() => {
109
120
  if (!projectId && !searchText) return;
110
121
  const isArchived = projectId === "__archived__";
@@ -0,0 +1,41 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { describe, it, expect } from 'vitest'
3
+ import { parseNotificationEvent } from '../use-notifications'
4
+
5
+ describe('parseNotificationEvent', () => {
6
+ it('解析合法 notification 事件', () => {
7
+ const raw = JSON.stringify({
8
+ type: 'CUSTOM',
9
+ name: 'notifications',
10
+ value: {
11
+ kind: 'notification',
12
+ notification: {
13
+ id: 'n1',
14
+ category: 'team',
15
+ type: 'team.invite_request',
16
+ title: 't',
17
+ body: null,
18
+ link: null,
19
+ readAt: null,
20
+ createdAt: '2026-08-26T00:00:00Z',
21
+ },
22
+ },
23
+ })
24
+ const parsed = parseNotificationEvent(raw)
25
+ expect(parsed?.kind).toBe('notification')
26
+ })
27
+
28
+ it('解析 unread_snapshot 事件', () => {
29
+ const raw = JSON.stringify({ type: 'CUSTOM', name: 'notifications', value: { kind: 'unread_snapshot', count: 5 } })
30
+ expect(parseNotificationEvent(raw)).toEqual({ kind: 'unread_snapshot', count: 5 })
31
+ })
32
+
33
+ it('非法 JSON 返回 null', () => {
34
+ expect(parseNotificationEvent('not json')).toBeNull()
35
+ })
36
+
37
+ it('非 notifications 通道的 CUSTOM 事件返回 null', () => {
38
+ const raw = JSON.stringify({ type: 'CUSTOM', name: 'other', value: { kind: 'unread_snapshot', count: 5 } })
39
+ expect(parseNotificationEvent(raw)).toBeNull()
40
+ })
41
+ })
@@ -0,0 +1,119 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { useCallback, useEffect } from 'react'
3
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { useRouter } from '@tanstack/react-router'
5
+ import { toast } from 'sonner'
6
+ import { useTranslation } from 'react-i18next'
7
+ import type { NotificationSseValue, NotificationItem } from '@allbluecn/shared'
8
+ import { isInDndWindow } from '@allbluecn/shared'
9
+ import { getUnreadCountFn, getNotificationPreferencesFn } from '@/server/serverFns/notifications'
10
+
11
+ export const NOTIFICATION_LIST_KEY = ['notifications', 'list'] as const
12
+ export const NOTIFICATION_UNREAD_KEY = ['notifications', 'unread'] as const
13
+ export const NOTIFICATION_PREFERENCES_KEY = ['notifications', 'preferences'] as const
14
+
15
+ export function parseNotificationEvent(raw: string): NotificationSseValue | null {
16
+ try {
17
+ const parsed = JSON.parse(raw)
18
+ if (parsed?.type === 'CUSTOM' && parsed?.name === 'notifications' && parsed?.value?.kind) {
19
+ return parsed.value as NotificationSseValue
20
+ }
21
+ return null
22
+ } catch {
23
+ return null
24
+ }
25
+ }
26
+
27
+ function toastNotification(item: NotificationItem, t: (key: string) => string, navigate: (opts: { to: string }) => void) {
28
+ toast(item.title, {
29
+ description: item.body ?? undefined,
30
+ action: item.link
31
+ ? {
32
+ label: t('actions.view'),
33
+ onClick: () => {
34
+ navigate({ to: item.link! })
35
+ },
36
+ }
37
+ : undefined,
38
+ })
39
+ }
40
+
41
+ let source: EventSource | null = null
42
+ let refCount = 0
43
+
44
+ export function useNotifications(options?: { toastEnabled?: boolean }) {
45
+ const { t } = useTranslation('notifications')
46
+ const queryClient = useQueryClient()
47
+ const router = useRouter()
48
+ const toastEnabled = options?.toastEnabled !== false
49
+
50
+ const { data: unreadData } = useQuery({
51
+ queryKey: NOTIFICATION_UNREAD_KEY,
52
+ queryFn: () => getUnreadCountFn({ data: {} }),
53
+ })
54
+
55
+ const unreadCount = unreadData?.count ?? 0
56
+
57
+ // N2 修复:预加载偏好数据到缓存,确保 DND 判断在任何页面均可用
58
+ useQuery({
59
+ queryKey: NOTIFICATION_PREFERENCES_KEY,
60
+ queryFn: () => getNotificationPreferencesFn({ data: {} }),
61
+ staleTime: 60000,
62
+ })
63
+
64
+ const setUnreadCount = useCallback(
65
+ (updater: number | ((c: number) => number)) => {
66
+ if (typeof updater === 'function') {
67
+ queryClient.setQueryData(NOTIFICATION_UNREAD_KEY, (old) => {
68
+ const count = (old as { count?: number } | undefined)?.count ?? 0
69
+ return { count: updater(count) }
70
+ })
71
+ } else {
72
+ queryClient.setQueryData(NOTIFICATION_UNREAD_KEY, { count: updater })
73
+ }
74
+ },
75
+ [queryClient]
76
+ )
77
+
78
+ useEffect(() => {
79
+ refCount++
80
+ if (!source) {
81
+ source = new EventSource('/api/notifications/stream')
82
+ }
83
+ const es = source
84
+
85
+ const handleMessage = (event: MessageEvent) => {
86
+ const value = parseNotificationEvent(event.data)
87
+ if (!value) return
88
+ if (value.kind === 'unread_snapshot') {
89
+ queryClient.setQueryData(NOTIFICATION_UNREAD_KEY, { count: value.count })
90
+ } else {
91
+ queryClient.setQueryData(NOTIFICATION_UNREAD_KEY, (old) => {
92
+ const count = (old as { count?: number } | undefined)?.count ?? 0
93
+ return { count: count + 1 }
94
+ })
95
+ queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
96
+ if (toastEnabled) {
97
+ const prefs = queryClient.getQueryData<{ dndEnabled: boolean; dndStart: string; dndEnd: string }>(NOTIFICATION_PREFERENCES_KEY)
98
+ const now = new Date()
99
+ const hhmm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
100
+ const inDnd = !!prefs && isInDndWindow(prefs, hhmm)
101
+ if (!inDnd) toastNotification(value.notification, t, (opts) => router.navigate({ to: opts.to as never }))
102
+ }
103
+ }
104
+ }
105
+
106
+ es.addEventListener('message', handleMessage as EventListener)
107
+ return () => {
108
+ es.removeEventListener('message', handleMessage as EventListener)
109
+ refCount--
110
+ if (refCount === 0) {
111
+ source?.close()
112
+ source = null
113
+ }
114
+ }
115
+ // eslint-disable-next-line react-hooks/exhaustive-deps
116
+ }, [toastEnabled, t, router])
117
+
118
+ return { unreadCount, setUnreadCount }
119
+ }
@@ -15,6 +15,9 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- export const APP_VERSION = '0.5.0'
18
+ // 版本号由 vite.shared.ts 构建时注入(__APP_VERSION__),无需运行时读取 package.json
19
+ declare const __APP_VERSION__: string
20
+ const APP_VERSION: string = __APP_VERSION__
21
+
19
22
  export const APP_VERSION_DISPLAY = `v${APP_VERSION}`
20
23
  export const APP_FULL_VERSION = `AllBlue v${APP_VERSION}`
@@ -139,8 +139,8 @@ export const authConfig: StartAuthJSConfig = {
139
139
  // 安全提取 IP/UA(start-authjs 传递的 request 类型可能不标准,失败不阻塞登录)
140
140
  let ip: string | null = null
141
141
  let userAgent: string | null = null
142
+ const headers = request?.headers as { get?: (name: string) => string | null } | undefined
142
143
  try {
143
- const headers = request?.headers as { get?: (name: string) => string | null } | undefined
144
144
  if (headers && typeof headers.get === 'function') {
145
145
  ip = getClientIp(request as Request)
146
146
  userAgent = headers.get('user-agent') || null
@@ -194,6 +194,22 @@ export const authConfig: StartAuthJSConfig = {
194
194
 
195
195
  const remember = creds.remember === 'true'
196
196
  const stayLoggedIn = creds.stayLoggedIn === 'true'
197
+
198
+ const isNewDevice = (() => {
199
+ const cookieHeader = headers?.get?.('cookie') || ''
200
+ return !cookieHeader.includes('allblue_device_seen=')
201
+ })()
202
+ void (async () => {
203
+ if (!isNewDevice) return
204
+ const { createNotification } = await import('@/server/notifications/notification-service')
205
+ await createNotification({
206
+ userId: user.id,
207
+ category: 'security',
208
+ type: 'security.new_device_login',
209
+ title: '检测到新设备登录',
210
+ body: `登录时间 ${new Date().toLocaleString('zh-CN')}`,
211
+ })
212
+ })()
197
213
  return {
198
214
  id: user.id,
199
215
  email: user.email,
@@ -1,5 +1,6 @@
1
1
  export interface SdkVersion {
2
2
  name: string
3
+ displayName?: string
3
4
  version: string
4
5
  lastUpdate: string
5
6
  source: string
@@ -8,71 +8,81 @@ export const SDK_GROUPS: SdkGroup[] = [
8
8
  "items": [
9
9
  {
10
10
  "name": "@tanstack/react-start",
11
+ "displayName": "TanStack Start",
11
12
  "version": "v1.168.46",
12
- "lastUpdate": "Aug 23, 2026",
13
+ "lastUpdate": "Aug 27, 2026",
13
14
  "source": "apps/web/package.json#dependencies",
14
15
  "docsUrl": "https://tanstack.com/start/latest"
15
16
  },
16
17
  {
17
18
  "name": "@tanstack/react-router",
19
+ "displayName": "TanStack Router",
18
20
  "version": "v1.170.29",
19
- "lastUpdate": "Aug 23, 2026",
21
+ "lastUpdate": "Aug 27, 2026",
20
22
  "source": "apps/web/package.json#dependencies",
21
23
  "docsUrl": "https://tanstack.com/router/latest"
22
24
  },
23
25
  {
24
26
  "name": "@tanstack/react-query",
27
+ "displayName": "TanStack Query",
25
28
  "version": "v5.101.2",
26
- "lastUpdate": "Aug 23, 2026",
29
+ "lastUpdate": "Aug 27, 2026",
27
30
  "source": "apps/web/package.json#dependencies",
28
31
  "docsUrl": "https://tanstack.com/query/latest"
29
32
  },
30
33
  {
31
34
  "name": "@tanstack/ai",
35
+ "displayName": "TanStack AI",
32
36
  "version": "v0.43.0",
33
- "lastUpdate": "Aug 23, 2026",
37
+ "lastUpdate": "Aug 27, 2026",
34
38
  "source": "apps/web/package.json#dependencies",
35
39
  "docsUrl": "https://tanstack.com/ai/latest"
36
40
  },
37
41
  {
38
42
  "name": "@tanstack/react-table",
43
+ "displayName": "TanStack Table",
39
44
  "version": "v9.1.0",
40
- "lastUpdate": "Aug 23, 2026",
45
+ "lastUpdate": "Aug 27, 2026",
41
46
  "source": "apps/web/package.json#dependencies",
42
47
  "docsUrl": "https://tanstack.com/table/latest"
43
48
  },
44
49
  {
45
50
  "name": "@tanstack/react-form",
51
+ "displayName": "TanStack Form",
46
52
  "version": "v2.0.0-alpha.0",
47
- "lastUpdate": "Aug 23, 2026",
53
+ "lastUpdate": "Aug 27, 2026",
48
54
  "source": "apps/web/package.json#dependencies",
49
55
  "docsUrl": "https://tanstack.com/form/latest"
50
56
  },
51
57
  {
52
58
  "name": "@tanstack/charts",
59
+ "displayName": "TanStack Charts",
53
60
  "version": "v0.9.0",
54
- "lastUpdate": "Aug 23, 2026",
61
+ "lastUpdate": "Aug 27, 2026",
55
62
  "source": "apps/admin/package.json#dependencies",
56
63
  "docsUrl": "https://tanstack.com/charts/latest"
57
64
  },
58
65
  {
59
66
  "name": "@tanstack/react-pacer",
67
+ "displayName": "TanStack Pacer",
60
68
  "version": "v0.22.1",
61
- "lastUpdate": "Aug 23, 2026",
69
+ "lastUpdate": "Aug 27, 2026",
62
70
  "source": "apps/web/package.json#dependencies",
63
71
  "docsUrl": "https://tanstack.com/pacer/latest"
64
72
  },
65
73
  {
66
74
  "name": "@tanstack/react-hotkeys",
75
+ "displayName": "TanStack Hotkeys",
67
76
  "version": "v0.10.0",
68
- "lastUpdate": "Aug 23, 2026",
77
+ "lastUpdate": "Aug 27, 2026",
69
78
  "source": "apps/web/package.json#dependencies",
70
79
  "docsUrl": "https://tanstack.com/hotkeys/latest"
71
80
  },
72
81
  {
73
82
  "name": "@tanstack/react-virtual",
83
+ "displayName": "TanStack Virtual",
74
84
  "version": "v3.14.9",
75
- "lastUpdate": "Aug 23, 2026",
85
+ "lastUpdate": "Aug 27, 2026",
76
86
  "source": "apps/admin/package.json#dependencies",
77
87
  "docsUrl": "https://tanstack.com/virtual/latest"
78
88
  }
@@ -85,15 +95,15 @@ export const SDK_GROUPS: SdkGroup[] = [
85
95
  "items": [
86
96
  {
87
97
  "name": "@allbluecn/web-app",
88
- "version": "v0.4.16",
89
- "lastUpdate": "Aug 23, 2026",
98
+ "version": "v0.4.18",
99
+ "lastUpdate": "Aug 27, 2026",
90
100
  "source": "apps/web/package.json",
91
101
  "docsUrl": "https://github.com/allbluecn/allblue"
92
102
  },
93
103
  {
94
104
  "name": "@allbluecn/kernel",
95
105
  "version": "v0.3.0",
96
- "lastUpdate": "Aug 23, 2026",
106
+ "lastUpdate": "Aug 27, 2026",
97
107
  "source": "packages/kernel/package.json",
98
108
  "docsUrl": "https://github.com/allbluecn/allblue"
99
109
  }
@@ -107,42 +117,42 @@ export const SDK_GROUPS: SdkGroup[] = [
107
117
  {
108
118
  "name": "Vite",
109
119
  "version": "v8.1.5",
110
- "lastUpdate": "Aug 23, 2026",
120
+ "lastUpdate": "Aug 27, 2026",
111
121
  "source": "apps/web/package.json#devDependencies",
112
122
  "docsUrl": "https://vite.dev/"
113
123
  },
114
124
  {
115
125
  "name": "Node.js",
116
126
  "version": "v24.19.0",
117
- "lastUpdate": "Aug 23, 2026",
127
+ "lastUpdate": "Aug 27, 2026",
118
128
  "source": ".nvmrc",
119
129
  "docsUrl": "https://nodejs.org/"
120
130
  },
121
131
  {
122
132
  "name": "Prisma",
123
133
  "version": "v7.8.0",
124
- "lastUpdate": "Aug 23, 2026",
134
+ "lastUpdate": "Aug 27, 2026",
125
135
  "source": "packages/database/package.json#devDependencies",
126
136
  "docsUrl": "https://www.prisma.io/docs"
127
137
  },
128
138
  {
129
139
  "name": "shadcn UI",
130
140
  "version": "v1.2.4",
131
- "lastUpdate": "Aug 23, 2026",
141
+ "lastUpdate": "Aug 27, 2026",
132
142
  "source": "apps/web/package.json#dependencies",
133
143
  "docsUrl": "https://ui.shadcn.com/"
134
144
  },
135
145
  {
136
146
  "name": "TipTap",
137
147
  "version": "v3.27.4",
138
- "lastUpdate": "Aug 23, 2026",
148
+ "lastUpdate": "Aug 27, 2026",
139
149
  "source": "packages/shared/package.json#devDependencies",
140
150
  "docsUrl": "https://tiptap.dev/"
141
151
  },
142
152
  {
143
153
  "name": "Lucide React",
144
154
  "version": "v1.24.0",
145
- "lastUpdate": "Aug 23, 2026",
155
+ "lastUpdate": "Aug 27, 2026",
146
156
  "source": "packages/ui/package.json#dependencies",
147
157
  "docsUrl": "https://lucide.dev/"
148
158
  }
@@ -15,4 +15,3 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- export { aiProvidersCollection } from './ai-providers-collection'
@@ -20,5 +20,4 @@ export { projectsCollection } from './reference/projects-collection'
20
20
  export { usersBriefCollection } from './reference/users-brief-collection'
21
21
  export { labelsCollection } from './reference/labels-collection'
22
22
  export { bookmarkCategoriesCollection } from './reference/bookmark-categories-collection'
23
- export { aiProvidersCollection } from './ai/ai-providers-collection'
24
23
  export { knowledgeBasesCollection } from './knowledge/knowledge-bases-collection'
@@ -0,0 +1,13 @@
1
+ {
2
+ "routeFiles": [
3
+ "src/routes/_nav/tags/route.tsx",
4
+ "src/routes/_nav/tags/index.tsx",
5
+ "src/routes/_nav/tags/$id/route.tsx",
6
+ "src/routes/_nav/tags/settings/route.tsx",
7
+ "src/routes/_nav/inspiration/route.tsx",
8
+ "src/routes/_nav/prd/route.tsx",
9
+ "src/routes/_nav/prd/index.tsx",
10
+ "src/routes/_nav/prd/$id/route.tsx",
11
+ "src/routes/share/prd/$prdId/route.tsx"
12
+ ]
13
+ }
@@ -0,0 +1,20 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import type { ProFeature } from '@allbluecn/kernel'
3
+
4
+ export const activePluginIds = ['@allbluecn/tags', '@allbluecn/inspiration', '@allbluecn/prd'] as const
5
+ export type ActivePluginId = (typeof activePluginIds)[number]
6
+
7
+ export const implementedModules: Record<ProFeature, boolean> = {
8
+ billing: false,
9
+ hostedAi: false,
10
+ sso: false,
11
+ audit: false,
12
+ permissions: false,
13
+ license: false,
14
+ versionHistory: false,
15
+ automation: false,
16
+ templates: false,
17
+ analytics: false,
18
+ globalSearch: false,
19
+ compliance: false,
20
+ }