@object-ui/app-shell 6.1.0 → 6.2.0

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 (81) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +10 -1
  3. package/dist/console/AppContent.js +24 -2
  4. package/dist/console/ai/AiChatPage.d.ts +8 -0
  5. package/dist/console/ai/AiChatPage.js +188 -0
  6. package/dist/console/ai/ConversationsSidebar.d.ts +7 -0
  7. package/dist/console/ai/ConversationsSidebar.js +111 -0
  8. package/dist/console/auth/LoginPage.js +19 -2
  9. package/dist/console/auth/RegisterPage.js +30 -1
  10. package/dist/console/marketplace/MarketplaceAccessDenied.js +3 -1
  11. package/dist/console/marketplace/MarketplaceInstalledPage.js +11 -3
  12. package/dist/console/marketplace/MarketplacePackagePage.js +57 -19
  13. package/dist/console/marketplace/MarketplacePage.js +55 -18
  14. package/dist/console/marketplace/marketplaceApi.d.ts +20 -0
  15. package/dist/console/marketplace/usePackageL10n.d.ts +38 -0
  16. package/dist/console/marketplace/usePackageL10n.js +110 -0
  17. package/dist/console/organizations/CreateWorkspaceDialog.js +29 -1
  18. package/dist/console/organizations/OrganizationsPage.js +24 -3
  19. package/dist/context/FavoritesProvider.d.ts +40 -2
  20. package/dist/context/FavoritesProvider.js +201 -20
  21. package/dist/hooks/index.d.ts +1 -0
  22. package/dist/hooks/index.js +1 -0
  23. package/dist/hooks/useChatConversation.d.ts +7 -0
  24. package/dist/hooks/useChatConversation.js +37 -5
  25. package/dist/hooks/useConversationList.d.ts +25 -0
  26. package/dist/hooks/useConversationList.js +131 -0
  27. package/dist/hooks/useNavPins.d.ts +11 -4
  28. package/dist/hooks/useNavPins.js +104 -53
  29. package/dist/index.d.ts +7 -0
  30. package/dist/index.js +14 -0
  31. package/dist/layout/AppHeader.js +2 -2
  32. package/dist/layout/AppSidebar.js +20 -1
  33. package/dist/layout/UnifiedSidebar.js +1 -1
  34. package/dist/providers/ExpressionProvider.d.ts +11 -1
  35. package/dist/providers/ExpressionProvider.js +11 -6
  36. package/dist/services/builtinComponents.d.ts +1 -0
  37. package/dist/services/builtinComponents.js +166 -0
  38. package/dist/services/componentRegistry.d.ts +63 -0
  39. package/dist/services/componentRegistry.js +36 -0
  40. package/dist/views/ComponentNavView.d.ts +6 -0
  41. package/dist/views/ComponentNavView.js +26 -0
  42. package/dist/views/RecordDetailView.js +66 -6
  43. package/dist/views/RecordFormPage.js +15 -3
  44. package/dist/views/SearchResultsPage.js +4 -0
  45. package/dist/views/metadata-admin/DesignerEditorWrapper.d.ts +58 -0
  46. package/dist/views/metadata-admin/DesignerEditorWrapper.js +140 -0
  47. package/dist/views/metadata-admin/DirectoryPage.d.ts +1 -0
  48. package/dist/views/metadata-admin/DirectoryPage.js +135 -0
  49. package/dist/views/metadata-admin/LayeredDiff.d.ts +6 -0
  50. package/dist/views/metadata-admin/LayeredDiff.js +26 -0
  51. package/dist/views/metadata-admin/PageShell.d.ts +34 -0
  52. package/dist/views/metadata-admin/PageShell.js +33 -0
  53. package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +5 -0
  54. package/dist/views/metadata-admin/PermissionMatrixEditor.js +288 -0
  55. package/dist/views/metadata-admin/QuickFind.d.ts +5 -0
  56. package/dist/views/metadata-admin/QuickFind.js +152 -0
  57. package/dist/views/metadata-admin/ResourceEditPage.d.ts +7 -0
  58. package/dist/views/metadata-admin/ResourceEditPage.js +256 -0
  59. package/dist/views/metadata-admin/ResourceHistoryPage.d.ts +5 -0
  60. package/dist/views/metadata-admin/ResourceHistoryPage.js +97 -0
  61. package/dist/views/metadata-admin/ResourceListPage.d.ts +4 -0
  62. package/dist/views/metadata-admin/ResourceListPage.js +144 -0
  63. package/dist/views/metadata-admin/ResourceRouter.d.ts +10 -0
  64. package/dist/views/metadata-admin/ResourceRouter.js +47 -0
  65. package/dist/views/metadata-admin/SchemaForm.d.ts +99 -0
  66. package/dist/views/metadata-admin/SchemaForm.js +556 -0
  67. package/dist/views/metadata-admin/default-schemas.d.ts +6 -0
  68. package/dist/views/metadata-admin/default-schemas.js +207 -0
  69. package/dist/views/metadata-admin/i18n.d.ts +33 -0
  70. package/dist/views/metadata-admin/i18n.js +303 -0
  71. package/dist/views/metadata-admin/index.d.ts +31 -0
  72. package/dist/views/metadata-admin/index.js +33 -0
  73. package/dist/views/metadata-admin/predicate.d.ts +31 -0
  74. package/dist/views/metadata-admin/predicate.js +150 -0
  75. package/dist/views/metadata-admin/registry.d.ts +125 -0
  76. package/dist/views/metadata-admin/registry.js +48 -0
  77. package/dist/views/metadata-admin/useMetadata.d.ts +37 -0
  78. package/dist/views/metadata-admin/useMetadata.js +96 -0
  79. package/dist/views/metadata-admin/widgets.d.ts +68 -0
  80. package/dist/views/metadata-admin/widgets.js +287 -0
  81. package/package.json +27 -26
package/CHANGELOG.md CHANGED
@@ -1,5 +1,115 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 6.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fe3c1d3: Metadata Admin engine — unified UI for all 27 metadata types.
8
+
9
+ A generic, schema-driven admin shell that replaces the old per-type
10
+ bespoke pages with a single registry-driven engine. Admins can now browse,
11
+ create, override, diff, and roll back every registered metadata type from
12
+ the Setup app → _All Metadata Types_.
13
+
14
+ ### New: `@object-ui/app-shell` views/metadata-admin
15
+ - **`MetadataDirectoryPage`** — auto-grouped tile directory by domain, with
16
+ free-text search, domain chips, and a _Writable only_ filter.
17
+ - **`MetadataResourceListPage` / `MetadataResourceEditPage` / `…CreatePage` / `…HistoryPage`** —
18
+ generic CRUD shell. Uses the new `/meta/types` schema field to render
19
+ SchemaForm; uses `?layers=code,overlay,effective` to power a 3-state diff
20
+ tab; uses `/references` to warn before destructive deletes.
21
+ - **`MetadataQuickFind`** — Cmd+Shift+M palette searching across types and
22
+ items.
23
+ - **`PermissionMatrixEditor`** — Salesforce-style matrix custom editor for
24
+ `type=permission`. Objects × CRUD/VAMA/lifecycle columns with cascade
25
+ rules (viewAllRecords ⟹ allowRead, etc.), expandable per-object field
26
+ R/W subtable, bulk-set (R / CRUD / All / None), filter, _only granted_
27
+ toggle, destructive-change confirmation, profile switch.
28
+ - **`DesignerEditorWrapper`** — generic load–edit–save shell that hosts any
29
+ bespoke designer (`ObjectViewConfigurator`, `DashboardEditor`,
30
+ `PageCanvasEditor`, …). Handles dirty tracking, Save / Reset / Refresh /
31
+ History buttons, and the read-only fallback when `allowOrgOverride` is
32
+ false.
33
+ - **`i18n.ts`** — bilingual (`en-US`, `zh-CN`) bundle for built-in type
34
+ labels, domain labels, and engine UI strings, with `detectLocale()` and a
35
+ `t(key)` helper.
36
+
37
+ ### New routing variant
38
+ - App nav now supports `{ type: 'component', componentRef, params? }` items.
39
+ `AppContent` resolves them through the existing `ComponentRegistry`.
40
+ - Built-in components registered: `metadata:directory`, `metadata:resource`,
41
+ `metadata:object/edit` (FieldsPage), `metadata:permission/edit`
42
+ (PermissionMatrixEditor), and lazy designer wrappers for view / dashboard
43
+ / page.
44
+
45
+ ### Plugin-designer
46
+ - Lazy-exported `ObjectManager`, `FieldDesigner`, `ObjectViewConfigurator`,
47
+ `DashboardEditor`, `PageCanvasEditor`, `MetadataObjectsPage`, and
48
+ `MetadataFieldsPage` so the engine can mount them on demand.
49
+
50
+ The temporary `/dev/meta` route is removed. Setup app navigation flows
51
+ through the new component routes.
52
+
53
+ - ca685ab: Add ChatGPT-style AI chat history surface at `/ai` and `/ai/:conversationId`.
54
+ - New `DefaultAiChatPage` with conversations sidebar (list, create, select, delete) and chat pane on the right.
55
+ - New `ConversationsSidebar` component and `useConversationList` hook for listing and managing `ai_conversations`.
56
+ - `useChatConversation` now accepts an optional `activeId` to hydrate a specific conversation (bypassing the localStorage cache), and guards against duplicate conversation creation when sibling state (e.g. selected agent / scope) changes during the same visit.
57
+ - Deleting the active conversation navigates back to `/ai` so the URL doesn't reference a stale id.
58
+ - Auto-title new conversations from the first user message (truncated to 40 chars) via `PATCH /api/v1/ai/conversations/:id`; resumed conversations are left alone.
59
+ - Manual rename in the sidebar: pencil icon opens an inline editor with optimistic update and rollback on server error.
60
+ - Client-side search input filters the sidebar by title/preview substring.
61
+
62
+ - 0335ec4: Polish the AI chat surface based on real-world dogfooding feedback.
63
+
64
+ **`@object-ui/plugin-chatbot`** — new display helpers shared by `ChatbotEnhanced`:
65
+ - `unwrapToolResult(value)` peels the MCP-style `{ type: 'text', value: '<json>' }`
66
+ envelope that backend tools emit (`@objectstack/service-ai`'s data/metadata
67
+ tools, in particular), and JSON-parses the inner payload. The result panel
68
+ now renders a structured object tree instead of a doubly-escaped wall of
69
+ `\\\"objects\\\":[…]`.
70
+ - `humanizeToolName(name)` converts snake_case / kebab-case / camelCase tool
71
+ ids into sentence case ("list_objects" → "List objects"), preserving known
72
+ acronyms (API, ID, SQL, …). Tool-call cards now show the friendly title with
73
+ the raw id as a small monospace badge for power users.
74
+ - `summarizeChatError(err)` strips the AI SDK's
75
+ `"Failed after N attempts. Last error: "` prefix and keeps the first
76
+ sentence as a headline; the full text is exposed via an optional `details`
77
+ field so the new error banner can render a "Details" disclosure plus a
78
+ prominent Retry button instead of a 300-character single-line wall.
79
+
80
+ A new `⌘⏎ to send` hint is shown in the prompt footer (hidden on narrow
81
+ screens). `ToolHeader.title` now accepts `ReactNode` (previously `string`)
82
+ so wrappers can compose richer titles.
83
+
84
+ **`@object-ui/app-shell`** — `AiChatPage`:
85
+ - Removes the fake "Hello! I'm X" assistant welcome bubble so the empty-state
86
+ suggestion chips can actually render.
87
+ - Adds per-agent default suggestion sets (`data_chat`, `metadata_assistant`)
88
+ with a generic fallback. New conversations open with three actionable
89
+ starter prompts tailored to the selected agent.
90
+ - Surfaces agent-fetch failures as an inline warning on the agent picker
91
+ instead of hijacking the welcome message.
92
+ - Placeholder text now hints at the first suggestion (e.g. `Ask Data
93
+ Assistant… (try "系统里有多少个用户?")`).
94
+
95
+ ### Patch Changes
96
+
97
+ - Updated dependencies [fe3c1d3]
98
+ - Updated dependencies [ec8dcde]
99
+ - @object-ui/data-objectstack@6.2.0
100
+ - @object-ui/react@6.2.0
101
+ - @object-ui/components@6.2.0
102
+ - @object-ui/fields@6.2.0
103
+ - @object-ui/layout@6.2.0
104
+ - @object-ui/plugin-editor@6.2.0
105
+ - @object-ui/types@6.2.0
106
+ - @object-ui/core@6.2.0
107
+ - @object-ui/i18n@6.2.0
108
+ - @object-ui/auth@6.2.0
109
+ - @object-ui/permissions@6.2.0
110
+ - @object-ui/collaboration@6.2.0
111
+ - @object-ui/providers@6.2.0
112
+
3
113
  ## 6.1.0
4
114
 
5
115
  ### Patch Changes
package/README.md CHANGED
@@ -241,12 +241,21 @@ The official ObjectStack adapter lives in `@object-ui/data-objectstack`
241
241
  (`createObjectStackUserStateAdapter`).
242
242
 
243
243
  ```tsx
244
- import { useFavorites, useRecentItems } from '@object-ui/app-shell';
244
+ import { useFavorites, useRecentItems, useNavPins } from '@object-ui/app-shell';
245
245
 
246
246
  const { favorites, toggleFavorite, isFavorite } = useFavorites();
247
247
  const { recentItems, addRecentItem } = useRecentItems();
248
+ // Sidebar pins live in the same store as Favorites — synced to the backend
249
+ // via the same `UserDataAdapter<FavoriteItem>` when one is attached.
250
+ const { pinnedIds, togglePin, isPinned, applyPins } = useNavPins();
248
251
  ```
249
252
 
253
+ Nav pins and Favorites share a single `favorites` collection. `FavoriteItem`
254
+ carries optional `type: 'nav'`, `pinned`, and `navId` fields so a single
255
+ adapter syncs both flows. The legacy `objectui-nav-pins` localStorage key is
256
+ migrated on first mount and then removed. Content favorites (20) and nav
257
+ pins (20) each have an independent cap. See the guide below for details.
258
+
250
259
  See [User-Scoped State Persistence](../../content/docs/guide/user-state-persistence.md)
251
260
  for the adapter contract, backend schema, and how to plug in your own backend.
252
261
 
@@ -39,6 +39,7 @@ const PageView = lazy(() => import('../views/PageView').then(m => ({ default: m.
39
39
  const ReportView = lazy(() => import('../views/ReportView').then(m => ({ default: m.ReportView })));
40
40
  const SearchResultsPage = lazy(() => import('../views/SearchResultsPage').then(m => ({ default: m.SearchResultsPage })));
41
41
  const RecordFormPage = lazy(() => import('../views/RecordFormPage').then(m => ({ default: m.RecordFormPage })));
42
+ const ComponentNavView = lazy(() => import('../views/ComponentNavView').then(m => ({ default: m.ComponentNavView })));
42
43
  // Designer pages — sourced from @object-ui/plugin-designer so third-party hosts
43
44
  // can opt out by not registering these routes.
44
45
  const CreateAppPage = lazy(() => import('@object-ui/plugin-designer').then(m => ({ default: m.CreateAppPage })));
@@ -52,8 +53,29 @@ const MarketplacePackagePage = lazy(() => import('./marketplace/MarketplacePacka
52
53
  const MarketplaceInstalledPage = lazy(() => import('./marketplace/MarketplaceInstalledPage').then(m => ({ default: m.MarketplaceInstalledPage })));
53
54
  export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
54
55
  const [connectionState, setConnectionState] = useState('disconnected');
55
- const { user } = useAuth();
56
+ const { user, getAuthConfig } = useAuth();
56
57
  const dataSource = useAdapter();
58
+ // Deployment-level feature flags from `/api/v1/auth/config`. Used by
59
+ // CEL predicates on metadata actions (e.g. `sys_organization`'s
60
+ // create button is hidden when `multiOrgEnabled === false`). We keep
61
+ // it empty until the fetch resolves so predicates default to "visible"
62
+ // and we don't briefly hide UI on slow networks.
63
+ const [features, setFeatures] = useState({});
64
+ useEffect(() => {
65
+ let cancelled = false;
66
+ getAuthConfig()
67
+ .then((cfg) => {
68
+ if (cancelled)
69
+ return;
70
+ setFeatures(cfg?.features ?? {});
71
+ })
72
+ .catch(() => {
73
+ /* leave empty — predicates default to visible */
74
+ });
75
+ return () => {
76
+ cancelled = true;
77
+ };
78
+ }, [getAuthConfig]);
57
79
  const navigate = useNavigate();
58
80
  const location = useLocation();
59
81
  const { appName } = useParams();
@@ -237,7 +259,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
237
259
  const expressionUser = user
238
260
  ? { name: user.name, email: user.email, role: user.role ?? 'user' }
239
261
  : { name: 'Anonymous', email: '', role: 'guest' };
240
- return (_jsxs(ExpressionProvider, { user: expressionUser, app: activeApp, data: {}, children: [_jsx(NavigationSyncEffect, {}), _jsxs(ConsoleLayout, { activeAppName: activeApp.name, activeApp: activeApp, onAppChange: handleAppChange, objects: allObjects, connectionState: connectionState, userId: user?.id, children: [_jsx(CommandPalette, { apps: apps, activeApp: activeApp, objects: allObjects, onAppChange: handleAppChange, dataSource: dataSource }), _jsx(KeyboardShortcutsDialog, {}), _jsx(OnboardingWalkthrough, {}), _jsx(ErrorBoundary, { children: _jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsx(RouteFader, { children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: resolveLandingRoute(activeApp), replace: true }) }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/new", element: _jsx(RecordFormPage, { mode: "create" }) }), _jsx(Route, { path: ":objectName/view/:viewId", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }, refreshKey) }), _jsx(Route, { path: ":objectName/record/:recordId/edit", element: _jsx(RecordFormPage, { mode: "edit" }) }), _jsx(Route, { path: "dashboard/:dashboardName", element: _jsx(DashboardView, { dataSource: dataSource }) }), _jsx(Route, { path: "report/:reportName", element: _jsx(ReportView, { dataSource: dataSource }) }), _jsx(Route, { path: "page/:pageName", element: _jsx(PageView, {}) }), _jsx(Route, { path: "design/page/:pageName", element: _jsx(PageDesignPage, {}) }), _jsx(Route, { path: "design/dashboard/:dashboardName", element: _jsx(DashboardDesignPage, {}) }), _jsx(Route, { path: "search", element: _jsx(SearchResultsPage, {}) }), _jsx(Route, { path: "create-app", element: _jsx(CreateAppPage, {}) }), _jsx(Route, { path: "edit-app/:editAppName", element: _jsx(EditAppPage, {}) }), _jsx(Route, { path: "system/marketplace", element: _jsx(MarketplacePage, {}) }), _jsx(Route, { path: "system/marketplace/installed", element: _jsx(MarketplaceInstalledPage, {}) }), _jsx(Route, { path: "system/marketplace/:packageId", element: _jsx(MarketplacePackagePage, {}) }), extraRoutes, _jsx(Route, { path: ":objectName/:maybeRecordId", element: _jsx(ShorthandRecordRedirect, {}) }), _jsx(Route, { path: "*", element: _jsx(RouteNotFound, {}) })] }) }) }) }), currentObjectDef && (_jsx(ModalForm, { schema: {
262
+ return (_jsxs(ExpressionProvider, { user: expressionUser, app: activeApp, data: {}, features: features, children: [_jsx(NavigationSyncEffect, {}), _jsxs(ConsoleLayout, { activeAppName: activeApp.name, activeApp: activeApp, onAppChange: handleAppChange, objects: allObjects, connectionState: connectionState, userId: user?.id, children: [_jsx(CommandPalette, { apps: apps, activeApp: activeApp, objects: allObjects, onAppChange: handleAppChange, dataSource: dataSource }), _jsx(KeyboardShortcutsDialog, {}), _jsx(OnboardingWalkthrough, {}), _jsx(ErrorBoundary, { children: _jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsx(RouteFader, { children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: resolveLandingRoute(activeApp), replace: true }) }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/new", element: _jsx(RecordFormPage, { mode: "create" }) }), _jsx(Route, { path: ":objectName/view/:viewId", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }, refreshKey) }), _jsx(Route, { path: ":objectName/record/:recordId/edit", element: _jsx(RecordFormPage, { mode: "edit" }) }), _jsx(Route, { path: "dashboard/:dashboardName", element: _jsx(DashboardView, { dataSource: dataSource }) }), _jsx(Route, { path: "report/:reportName", element: _jsx(ReportView, { dataSource: dataSource }) }), _jsx(Route, { path: "page/:pageName", element: _jsx(PageView, {}) }), _jsx(Route, { path: "component/:ns/:name/*", element: _jsx(ComponentNavView, {}) }), _jsx(Route, { path: "design/page/:pageName", element: _jsx(PageDesignPage, {}) }), _jsx(Route, { path: "design/dashboard/:dashboardName", element: _jsx(DashboardDesignPage, {}) }), _jsx(Route, { path: "search", element: _jsx(SearchResultsPage, {}) }), _jsx(Route, { path: "create-app", element: _jsx(CreateAppPage, {}) }), _jsx(Route, { path: "edit-app/:editAppName", element: _jsx(EditAppPage, {}) }), _jsx(Route, { path: "system/marketplace", element: _jsx(MarketplacePage, {}) }), _jsx(Route, { path: "system/marketplace/installed", element: _jsx(MarketplaceInstalledPage, {}) }), _jsx(Route, { path: "system/marketplace/:packageId", element: _jsx(MarketplacePackagePage, {}) }), extraRoutes, _jsx(Route, { path: ":objectName/:maybeRecordId", element: _jsx(ShorthandRecordRedirect, {}) }), _jsx(Route, { path: "*", element: _jsx(RouteNotFound, {}) })] }) }) }) }), currentObjectDef && (_jsx(ModalForm, { schema: {
241
263
  type: 'object-form',
242
264
  formType: 'modal',
243
265
  objectName: currentObjectDef.name,
@@ -0,0 +1,8 @@
1
+ export interface AiChatPageProps {
2
+ /** Override the resolved AI service base URL. */
3
+ apiBase?: string;
4
+ /** Default agent to select on first render. */
5
+ defaultAgent?: string;
6
+ }
7
+ export declare function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentProp }?: AiChatPageProps): import("react/jsx-runtime").JSX.Element;
8
+ export default AiChatPage;
@@ -0,0 +1,188 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
3
+ /**
4
+ * AiChatPage — full-page ChatGPT-style AI surface.
5
+ *
6
+ * Mounted at `/ai` (new chat) and `/ai/:conversationId` (resume an existing
7
+ * conversation). Left rail lists the signed-in user's `ai_conversations`;
8
+ * right pane embeds `ChatbotEnhanced` wired to
9
+ * `POST /api/v1/ai/agents/:name/chat`.
10
+ *
11
+ * Auto-persist is handled server-side in `@objectstack/service-ai`: as long
12
+ * as the request body carries `conversationId`, the user + assistant + tool
13
+ * turns are appended to `ai_messages` automatically.
14
+ */
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
+ import { useNavigate, useParams } from 'react-router-dom';
17
+ import { useAuth } from '@object-ui/auth';
18
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@object-ui/components';
19
+ import { ChatbotEnhanced, useAgents, useObjectChat, useHitlInChat, } from '@object-ui/plugin-chatbot';
20
+ import { AppHeader } from '../../layout/AppHeader';
21
+ import { useNavigationContext } from '../../context/NavigationContext';
22
+ import { useChatConversation } from '../../hooks/useChatConversation';
23
+ import { ConversationsSidebar } from './ConversationsSidebar';
24
+ const DEFAULT_AI_PATH = '/api/v1/ai';
25
+ function resolveApiBase(explicit) {
26
+ if (explicit)
27
+ return explicit.replace(/\/$/, '');
28
+ const env = import.meta.env ?? {};
29
+ const fromEnv = env.VITE_AI_BASE_URL;
30
+ if (fromEnv)
31
+ return fromEnv.replace(/\/$/, '');
32
+ const serverUrl = env.VITE_SERVER_URL ?? '';
33
+ return `${serverUrl.replace(/\/$/, '')}${DEFAULT_AI_PATH}`;
34
+ }
35
+ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentProp } = {}) {
36
+ const { user } = useAuth();
37
+ const userId = user?.id;
38
+ const { conversationId: urlConversationId } = useParams();
39
+ const navigate = useNavigate();
40
+ const { setContext } = useNavigationContext();
41
+ useEffect(() => {
42
+ setContext('home');
43
+ }, [setContext]);
44
+ const apiBase = useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]);
45
+ const env = import.meta.env ?? {};
46
+ const envDefaultAgent = env.VITE_AI_DEFAULT_AGENT;
47
+ const { agents, isLoading: agentsLoading, error: agentsError } = useAgents({ apiBase });
48
+ const [activeAgent, setActiveAgent] = useState(undefined);
49
+ useEffect(() => {
50
+ if (!activeAgent && agents.length > 0) {
51
+ const preferred = defaultAgentProp ?? envDefaultAgent;
52
+ const match = preferred ? agents.find((a) => a.name === preferred) : undefined;
53
+ setActiveAgent((match ?? agents[0]).name);
54
+ }
55
+ }, [agents, activeAgent, defaultAgentProp, envDefaultAgent]);
56
+ const chatApi = activeAgent
57
+ ? `${apiBase}/agents/${encodeURIComponent(activeAgent)}/chat`
58
+ : undefined;
59
+ const { conversationId, initialMessages, isLoading: convoLoading } = useChatConversation({
60
+ userId,
61
+ scope: activeAgent,
62
+ apiBase,
63
+ activeId: urlConversationId,
64
+ });
65
+ const [refreshKey, setRefreshKey] = useState(0);
66
+ // After the hook resolves a real id for a fresh `/ai` visit, mirror it into
67
+ // the URL so the sidebar's active-row + share/refresh both work.
68
+ useEffect(() => {
69
+ if (!urlConversationId && conversationId) {
70
+ navigate(`/ai/${conversationId}`, { replace: true });
71
+ }
72
+ }, [urlConversationId, conversationId, navigate]);
73
+ const titledRef = useRef(new Set());
74
+ // A resumed conversation already has history; treat it as already-titled
75
+ // so we don't clobber the original title on the next user turn.
76
+ useEffect(() => {
77
+ if (conversationId && initialMessages.length > 0) {
78
+ titledRef.current.add(conversationId);
79
+ }
80
+ }, [conversationId, initialMessages.length]);
81
+ const handleSent = useCallback((firstUserMessage) => {
82
+ // New user turn → bump sidebar list so the row's preview/timestamp refreshes.
83
+ setRefreshKey((k) => k + 1);
84
+ // Auto-title the conversation from the first user message. Server only
85
+ // tracks `title` when we PATCH it; without this every row shows the same
86
+ // "New conversation" placeholder. We mark `conversationId` as titled in a
87
+ // ref so subsequent turns don't re-rename and clobber a manual rename.
88
+ if (!firstUserMessage || !conversationId)
89
+ return;
90
+ if (titledRef.current.has(conversationId))
91
+ return;
92
+ titledRef.current.add(conversationId);
93
+ const text = firstUserMessage.trim();
94
+ if (!text)
95
+ return;
96
+ const truncated = text.length > 40 ? `${text.slice(0, 40)}…` : text;
97
+ fetch(`${apiBase}/conversations/${encodeURIComponent(conversationId)}`, {
98
+ method: 'PATCH',
99
+ credentials: 'include',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify({ title: truncated }),
102
+ })
103
+ .then(() => setRefreshKey((k) => k + 1))
104
+ .catch(() => {
105
+ // Leave it untitled; user can rename manually.
106
+ titledRef.current.delete(conversationId);
107
+ });
108
+ }, [apiBase, conversationId]);
109
+ return (_jsxs("div", { className: "flex h-svh w-full flex-col bg-background", "data-testid": "ai-chat-page", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "home" }) }), _jsxs("div", { className: "flex flex-1 min-h-0 w-full", children: [_jsx(ConversationsSidebar, { userId: userId, apiBase: apiBase, refreshKey: refreshKey, className: "hidden w-72 shrink-0 border-r md:flex" }), _jsx("main", { className: "flex flex-1 min-w-0 flex-col", children: _jsx(ChatPane, { agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, onAgentChange: setActiveAgent, chatApi: chatApi, apiBase: apiBase, conversationId: conversationId, initialMessages: initialMessages, hydrating: convoLoading, onSent: handleSent }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`) })] })] }));
110
+ }
111
+ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, onAgentChange, chatApi, apiBase, conversationId, initialMessages, hydrating, onSent, }) {
112
+ const activeAgentLabel = useMemo(() => {
113
+ const found = agents.find((a) => a.name === activeAgent);
114
+ return found?.label ?? activeAgent ?? 'Assistant';
115
+ }, [agents, activeAgent]);
116
+ const hydrated = useMemo(() => {
117
+ return initialMessages.map((m) => ({
118
+ id: m.id,
119
+ role: m.role,
120
+ content: m.parts.map((p) => p.text).join(''),
121
+ }));
122
+ }, [initialMessages]);
123
+ const suggestions = useMemo(() => {
124
+ if (hydrated.length > 0)
125
+ return undefined;
126
+ return buildAgentSuggestions(activeAgent, activeAgentLabel);
127
+ }, [hydrated.length, activeAgent, activeAgentLabel]);
128
+ const { messages, isLoading, error, sendMessage, stop, reload, clear, } = useObjectChat({
129
+ api: chatApi,
130
+ conversationId,
131
+ body: {
132
+ context: {
133
+ activeApp: 'AI',
134
+ agentName: activeAgent,
135
+ },
136
+ },
137
+ initialMessages: hydrated,
138
+ autoResponse: !chatApi,
139
+ autoResponseText: "Thanks for your message! I'm here to help.",
140
+ autoResponseDelay: 600,
141
+ });
142
+ const hitl = useHitlInChat({
143
+ messages: messages,
144
+ apiBase,
145
+ continueConversation: (prompt) => {
146
+ sendMessage(prompt);
147
+ },
148
+ });
149
+ const handleSend = useCallback((content, files) => {
150
+ sendMessage(content, files);
151
+ onSent(content);
152
+ }, [sendMessage, onSent]);
153
+ const headerSlot = agents.length > 0 ? (_jsxs("div", { className: "flex flex-wrap items-center gap-2 px-4 py-2", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: "Agent:" }), _jsxs(Select, { value: activeAgent, onValueChange: onAgentChange, disabled: agentsLoading, children: [_jsx(SelectTrigger, { className: "h-7 w-[220px] text-xs", "data-testid": "ai-chat-agent-picker", children: _jsx(SelectValue, { placeholder: "Choose agent..." }) }), _jsx(SelectContent, { align: "start", children: agents.map((agent) => (_jsxs(SelectItem, { value: agent.name, className: "text-xs", children: [_jsx("span", { className: "font-medium", children: agent.label }), agent.description ? (_jsx("span", { className: "block text-muted-foreground text-[10px] truncate max-w-[260px]", children: agent.description })) : null] }, agent.name))) })] }), hydrating ? (_jsx("span", { className: "text-[10px] text-muted-foreground", children: "Loading history\u2026" })) : null, agentsError ? (_jsx("span", { className: "text-[10px] text-amber-700 dark:text-amber-400", title: agentsError.message, children: "\u26A0 Offline demo mode \u2014 agent list unavailable" })) : null] })) : null;
154
+ return (_jsx(ChatbotEnhanced, { className: "flex-1 min-h-0 rounded-none border-0", maxHeight: "100%", headerSlot: headerSlot, messages: messages, placeholder: activeAgent
155
+ ? `Ask ${activeAgentLabel}… (try “${(suggestions?.[0]) ?? 'How can you help?'}”)`
156
+ : agentsLoading
157
+ ? 'Loading agents…'
158
+ : 'Ask anything…', suggestions: suggestions, onSendMessage: handleSend, onClear: clear, onStop: isLoading ? stop : undefined, onReload: reload, isLoading: isLoading, error: error, enableMarkdown: true, onToolApprove: hitl.decide, toolDecisions: hitl.decisions, toolApproveLabel: "Approve & run", toolDenyLabel: "Reject", toolDenyReason: "Operator rejected from chat", "data-testid": "ai-chat-panel" }));
159
+ }
160
+ const AGENT_SUGGESTIONS = {
161
+ data_chat: [
162
+ '系统里有多少个用户?列出他们的邮箱。',
163
+ '帮我列出最近创建的 5 条记录。',
164
+ '统计每个对象的记录数。',
165
+ ],
166
+ metadata_assistant: [
167
+ '系统里注册了哪些对象类型?',
168
+ 'sys_user 对象有哪些字段?',
169
+ '描述一下用户相关的对象关系。',
170
+ ],
171
+ };
172
+ const GENERIC_SUGGESTIONS = [
173
+ 'What can you help me with?',
174
+ 'List the available data objects.',
175
+ 'Summarize my recent activity.',
176
+ ];
177
+ function buildAgentSuggestions(agentName, agentLabel) {
178
+ if (agentName && AGENT_SUGGESTIONS[agentName]) {
179
+ return AGENT_SUGGESTIONS[agentName];
180
+ }
181
+ const lower = (agentName ?? agentLabel).toLowerCase();
182
+ if (lower.includes('data'))
183
+ return AGENT_SUGGESTIONS.data_chat;
184
+ if (lower.includes('metadata'))
185
+ return AGENT_SUGGESTIONS.metadata_assistant;
186
+ return GENERIC_SUGGESTIONS;
187
+ }
188
+ export default AiChatPage;
@@ -0,0 +1,7 @@
1
+ export interface ConversationsSidebarProps {
2
+ userId: string | undefined;
3
+ apiBase: string;
4
+ className?: string;
5
+ refreshKey?: number | string;
6
+ }
7
+ export declare function ConversationsSidebar({ userId, apiBase, className, refreshKey, }: ConversationsSidebarProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
3
+ /**
4
+ * Left sidebar listing the signed-in user's AI conversations. Active row is
5
+ * derived from `useParams<{ conversationId }>()`; clicking a row navigates to
6
+ * `/ai/:id`, the "New chat" button navigates to `/ai`.
7
+ */
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+ import { useNavigate, useParams } from 'react-router-dom';
10
+ import { Plus, Trash2, Pencil, MessageSquare, Search, Check, X } from 'lucide-react';
11
+ import { Button, Input, ScrollArea, Empty, EmptyTitle, EmptyDescription, cn, } from '@object-ui/components';
12
+ import { useConversationList } from '../../hooks/useConversationList';
13
+ function formatTimestamp(iso) {
14
+ if (!iso)
15
+ return '';
16
+ const d = new Date(iso);
17
+ if (Number.isNaN(d.getTime()))
18
+ return '';
19
+ const now = Date.now();
20
+ const diff = now - d.getTime();
21
+ const min = 60 * 1000;
22
+ const hour = 60 * min;
23
+ const day = 24 * hour;
24
+ if (diff < min)
25
+ return 'just now';
26
+ if (diff < hour)
27
+ return `${Math.floor(diff / min)}m ago`;
28
+ if (diff < day)
29
+ return `${Math.floor(diff / hour)}h ago`;
30
+ if (diff < 7 * day)
31
+ return `${Math.floor(diff / day)}d ago`;
32
+ return d.toLocaleDateString();
33
+ }
34
+ export function ConversationsSidebar({ userId, apiBase, className, refreshKey, }) {
35
+ const navigate = useNavigate();
36
+ const { conversationId: activeId } = useParams();
37
+ const { conversations, isLoading, error, remove, rename } = useConversationList({
38
+ userId,
39
+ apiBase,
40
+ refreshKey,
41
+ });
42
+ const [filter, setFilter] = useState('');
43
+ const [renamingId, setRenamingId] = useState(undefined);
44
+ const visible = useMemo(() => {
45
+ const q = filter.trim().toLowerCase();
46
+ if (!q)
47
+ return conversations;
48
+ return conversations.filter((c) => {
49
+ const hay = `${c.title ?? ''} ${c.preview ?? ''}`.toLowerCase();
50
+ return hay.includes(q);
51
+ });
52
+ }, [conversations, filter]);
53
+ const handleNew = useCallback(() => navigate('/ai'), [navigate]);
54
+ const handleDelete = useCallback(async (e, id) => {
55
+ e.stopPropagation();
56
+ await remove(id);
57
+ if (id === activeId)
58
+ navigate('/ai', { replace: true });
59
+ }, [remove, activeId, navigate]);
60
+ const handleRenameSubmit = useCallback(async (id, title) => {
61
+ setRenamingId(undefined);
62
+ try {
63
+ await rename(id, title);
64
+ }
65
+ catch {
66
+ // optimistic update already rolled back via refetch in the hook
67
+ }
68
+ }, [rename]);
69
+ return (_jsxs("aside", { className: cn('flex h-full min-h-0 flex-col bg-muted/30', className), "data-testid": "ai-conversations-sidebar", children: [_jsxs("div", { className: "flex shrink-0 flex-col gap-2 border-b px-3 py-2", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-sm font-medium", children: "Chats" }), _jsxs(Button, { size: "sm", variant: "outline", onClick: handleNew, "data-testid": "ai-new-chat", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "New"] })] }), _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: filter, onChange: (e) => setFilter(e.target.value), placeholder: "Search chats...", className: "h-7 pl-7 text-xs", "data-testid": "ai-conversations-search" })] })] }), _jsx(ScrollArea, { className: "flex-1 min-h-0", children: isLoading && conversations.length === 0 ? (_jsx("div", { className: "px-3 py-4 text-xs text-muted-foreground", children: "Loading\u2026" })) : error ? (_jsx("div", { className: "px-3 py-4 text-xs text-destructive", children: error.message })) : conversations.length === 0 ? (_jsxs(Empty, { className: "px-3 py-8", children: [_jsx(MessageSquare, { className: "h-8 w-8 text-muted-foreground" }), _jsx(EmptyTitle, { children: "No chats yet" }), _jsx(EmptyDescription, { children: "Start a new conversation to see it here." })] })) : visible.length === 0 ? (_jsx("div", { className: "px-3 py-4 text-xs text-muted-foreground", children: "No matching chats." })) : (_jsx("ul", { className: "flex flex-col py-1", children: visible.map((c) => (_jsx(ConversationRow, { conversation: c, active: c.id === activeId, renaming: c.id === renamingId, onSelect: () => navigate(`/ai/${c.id}`), onDelete: (e) => handleDelete(e, c.id), onStartRename: () => setRenamingId(c.id), onCancelRename: () => setRenamingId(undefined), onSubmitRename: (title) => handleRenameSubmit(c.id, title) }, c.id))) })) })] }));
70
+ }
71
+ function ConversationRow({ conversation, active, renaming, onSelect, onDelete, onStartRename, onCancelRename, onSubmitRename, }) {
72
+ const title = conversation.title?.trim() || conversation.preview?.trim() || 'New conversation';
73
+ const [draft, setDraft] = useState(title);
74
+ const inputRef = useRef(null);
75
+ useEffect(() => {
76
+ if (renaming) {
77
+ setDraft(conversation.title?.trim() || conversation.preview?.trim() || '');
78
+ // Focus the input on next paint so the click that opened it doesn't blur immediately.
79
+ setTimeout(() => inputRef.current?.select(), 0);
80
+ }
81
+ }, [renaming, conversation.title, conversation.preview]);
82
+ if (renaming) {
83
+ return (_jsx("li", { children: _jsxs("div", { className: cn('flex w-full items-center gap-1 border-l-2 px-3 py-2', active ? 'border-primary bg-accent' : 'border-transparent'), "data-testid": `ai-conversation-rename-row-${conversation.id}`, children: [_jsx(Input, { ref: inputRef, value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: (e) => {
84
+ if (e.key === 'Enter') {
85
+ e.preventDefault();
86
+ onSubmitRename(draft);
87
+ }
88
+ else if (e.key === 'Escape') {
89
+ e.preventDefault();
90
+ onCancelRename();
91
+ }
92
+ }, className: "h-7 flex-1 text-sm", "data-testid": `ai-conversation-rename-input-${conversation.id}`, "aria-label": "Rename conversation" }), _jsx(Button, { size: "sm", variant: "ghost", className: "h-7 w-7 p-0", onClick: () => onSubmitRename(draft), "data-testid": `ai-conversation-rename-confirm-${conversation.id}`, "aria-label": "Save rename", children: _jsx(Check, { className: "h-3.5 w-3.5" }) }), _jsx(Button, { size: "sm", variant: "ghost", className: "h-7 w-7 p-0", onClick: onCancelRename, "aria-label": "Cancel rename", children: _jsx(X, { className: "h-3.5 w-3.5" }) })] }) }));
93
+ }
94
+ return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: onSelect, className: cn('group flex w-full flex-col gap-0.5 border-l-2 border-transparent px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50', active && 'border-primary bg-accent'), "data-testid": `ai-conversation-row-${conversation.id}`, children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx("span", { className: "line-clamp-1 flex-1 font-medium", children: title }), _jsxs("div", { className: "flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100", children: [_jsx("span", { role: "button", tabIndex: 0, onClick: (e) => {
95
+ e.stopPropagation();
96
+ onStartRename();
97
+ }, onKeyDown: (e) => {
98
+ if (e.key === 'Enter' || e.key === ' ') {
99
+ e.stopPropagation();
100
+ onStartRename();
101
+ }
102
+ }, className: "hover:text-primary", "data-testid": `ai-conversation-rename-${conversation.id}`, "aria-label": "Rename conversation", children: _jsx(Pencil, { className: "h-3.5 w-3.5" }) }), _jsx("span", { role: "button", tabIndex: 0, onClick: (e) => {
103
+ e.stopPropagation();
104
+ onDelete(e);
105
+ }, onKeyDown: (e) => {
106
+ if (e.key === 'Enter' || e.key === ' ') {
107
+ e.stopPropagation();
108
+ onDelete(e);
109
+ }
110
+ }, className: "hover:text-destructive", "data-testid": `ai-conversation-delete-${conversation.id}`, "aria-label": "Delete conversation", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] })] }), conversation.preview && conversation.preview !== title ? (_jsx("span", { className: "line-clamp-1 text-xs text-muted-foreground", children: conversation.preview })) : null, _jsx("span", { className: "text-[10px] text-muted-foreground", children: formatTimestamp(conversation.updatedAt ?? conversation.createdAt) })] }) }));
111
+ }
@@ -2,15 +2,32 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /**
3
3
  * Login Page for ObjectStack Console
4
4
  */
5
+ import { useEffect, useState } from 'react';
5
6
  import { useNavigate, Link } from 'react-router-dom';
6
- import { LoginForm } from '@object-ui/auth';
7
+ import { LoginForm, useAuth } from '@object-ui/auth';
7
8
  import { useObjectTranslation } from '@object-ui/i18n';
8
9
  import { AuthPageLayout } from './AuthPageLayout';
9
10
  const RouterLink = ({ href, className, children }) => (_jsx(Link, { to: href, className: className, children: children }));
10
11
  export function LoginPage() {
11
12
  const navigate = useNavigate();
12
13
  const { t } = useObjectTranslation();
13
- return (_jsx(AuthPageLayout, { children: _jsx(LoginForm, { onSuccess: () => navigate('/'), registerUrl: "/register", forgotPasswordUrl: "/forgot-password", title: t('auth.login.title'), description: t('auth.login.description'), linkComponent: RouterLink, labels: {
14
+ const { getAuthConfig } = useAuth();
15
+ // Hide the "Sign up" link when the deployment has disabled
16
+ // self-service registration (env `OS_DISABLE_SIGNUP=true` or
17
+ // `emailAndPassword.disableSignUp` in objectstack.config.ts). We start
18
+ // undefined so we don't flicker the link on first paint, and pass
19
+ // `undefined` (LoginForm hides the link) once we know signup is off.
20
+ const [signUpDisabled, setSignUpDisabled] = useState(undefined);
21
+ useEffect(() => {
22
+ let cancelled = false;
23
+ getAuthConfig()
24
+ .then(cfg => { if (!cancelled)
25
+ setSignUpDisabled(cfg?.emailPassword?.disableSignUp === true); })
26
+ .catch(() => { if (!cancelled)
27
+ setSignUpDisabled(false); });
28
+ return () => { cancelled = true; };
29
+ }, [getAuthConfig]);
30
+ return (_jsx(AuthPageLayout, { children: _jsx(LoginForm, { onSuccess: () => navigate('/'), registerUrl: signUpDisabled ? undefined : '/register', forgotPasswordUrl: "/forgot-password", title: t('auth.login.title'), description: t('auth.login.description'), linkComponent: RouterLink, labels: {
14
31
  emailLabel: t('auth.login.emailLabel'),
15
32
  emailPlaceholder: t('auth.login.emailPlaceholder'),
16
33
  passwordLabel: t('auth.login.passwordLabel'),
@@ -2,14 +2,43 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /**
3
3
  * Register Page for ObjectStack Console
4
4
  */
5
+ import { useEffect, useState } from 'react';
5
6
  import { useNavigate, Link } from 'react-router-dom';
6
- import { RegisterForm } from '@object-ui/auth';
7
+ import { RegisterForm, useAuth } from '@object-ui/auth';
7
8
  import { useObjectTranslation } from '@object-ui/i18n';
8
9
  import { AuthPageLayout } from './AuthPageLayout';
9
10
  const RouterLink = ({ href, className, children }) => (_jsx(Link, { to: href, className: className, children: children }));
10
11
  export function RegisterPage() {
11
12
  const navigate = useNavigate();
12
13
  const { t } = useObjectTranslation();
14
+ const { getAuthConfig } = useAuth();
15
+ // Defense-in-depth: even if a user lands on /register directly when
16
+ // signup is disabled, bounce them to /login. The server-side
17
+ // `disableSignUp` (set by env `OS_DISABLE_SIGNUP=true` or the
18
+ // `emailAndPassword.disableSignUp` config option) will still 403 any
19
+ // submission, but redirecting here avoids a confusing form.
20
+ const [allowed, setAllowed] = useState(undefined);
21
+ useEffect(() => {
22
+ let cancelled = false;
23
+ getAuthConfig()
24
+ .then(cfg => {
25
+ if (cancelled)
26
+ return;
27
+ if (cfg?.emailPassword?.disableSignUp === true) {
28
+ navigate('/login', { replace: true });
29
+ }
30
+ else {
31
+ setAllowed(true);
32
+ }
33
+ })
34
+ .catch(() => { if (!cancelled)
35
+ setAllowed(true); });
36
+ return () => { cancelled = true; };
37
+ }, [getAuthConfig, navigate]);
38
+ if (allowed !== true) {
39
+ // Render nothing until we know the flag — prevents a flash of the form.
40
+ return _jsx(AuthPageLayout, { children: null });
41
+ }
13
42
  return (_jsx(AuthPageLayout, { children: _jsx(RegisterForm, { onSuccess: () => navigate('/'), loginUrl: "/login", title: t('auth.register.title'), description: t('auth.register.description'), linkComponent: RouterLink, labels: {
14
43
  nameLabel: t('auth.register.nameLabel'),
15
44
  namePlaceholder: t('auth.register.namePlaceholder'),
@@ -10,9 +10,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  import { Card, CardContent, Button } from '@object-ui/components';
11
11
  import { Lock } from 'lucide-react';
12
12
  import { useNavigate, useParams } from 'react-router-dom';
13
+ import { useObjectTranslation } from '@object-ui/i18n';
13
14
  export function MarketplaceAccessDenied() {
14
15
  const navigate = useNavigate();
15
16
  const { appName } = useParams();
17
+ const { t } = useObjectTranslation();
16
18
  const home = appName ? `/apps/${appName}` : '/';
17
- return (_jsx("div", { className: "container mx-auto max-w-2xl px-6 py-16", children: _jsx(Card, { children: _jsxs(CardContent, { className: "flex flex-col items-center gap-4 py-12 text-center", children: [_jsx("div", { className: "rounded-full bg-muted p-3", children: _jsx(Lock, { className: "h-6 w-6 text-muted-foreground" }) }), _jsxs("div", { children: [_jsx("h2", { className: "text-lg font-semibold", children: "App Marketplace is admin-only" }), _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: "You don't have permission to install apps in this environment. Ask an owner or admin of this organization for access." })] }), _jsx(Button, { variant: "outline", onClick: () => navigate(home), children: "Back to home" })] }) }) }));
19
+ return (_jsx("div", { className: "container mx-auto max-w-2xl px-6 py-16", children: _jsx(Card, { children: _jsxs(CardContent, { className: "flex flex-col items-center gap-4 py-12 text-center", children: [_jsx("div", { className: "rounded-full bg-muted p-3", children: _jsx(Lock, { className: "h-6 w-6 text-muted-foreground" }) }), _jsxs("div", { children: [_jsx("h2", { className: "text-lg font-semibold", children: t('marketplace.accessDenied.title') }), _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: t('marketplace.accessDenied.description') })] }), _jsx(Button, { variant: "outline", onClick: () => navigate(home), children: t('marketplace.action.backHome') })] }) }) }));
18
20
  }