@flamingo-stack/openframe-frontend-core 0.0.217 → 0.0.218
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/dist/{chunk-L6IBKPVM.js → chunk-EKBM4FHK.js} +2 -2
- package/dist/{chunk-SWZUZYWR.js → chunk-EWA2NFUR.js} +2 -2
- package/dist/{chunk-TYIBMDUZ.cjs → chunk-FZZBCRID.cjs} +7 -7
- package/dist/{chunk-TYIBMDUZ.cjs.map → chunk-FZZBCRID.cjs.map} +1 -1
- package/dist/{chunk-G2HHSZ3S.cjs → chunk-GE64T3JT.cjs} +9 -9
- package/dist/{chunk-G2HHSZ3S.cjs.map → chunk-GE64T3JT.cjs.map} +1 -1
- package/dist/{chunk-YWDC5BXM.cjs → chunk-L5RSJE2I.cjs} +1940 -915
- package/dist/chunk-L5RSJE2I.cjs.map +1 -0
- package/dist/{chunk-BVFRD34B.js → chunk-OHOUSDAY.js} +2 -2
- package/dist/{chunk-MVQ3OODK.cjs → chunk-S4SVD5JI.cjs} +9 -9
- package/dist/{chunk-MVQ3OODK.cjs.map → chunk-S4SVD5JI.cjs.map} +1 -1
- package/dist/{chunk-N5IKPYRL.js → chunk-SWIR5EB2.js} +2 -2
- package/dist/{chunk-6DCKL73F.cjs → chunk-TCJ5B2ZD.cjs} +24 -24
- package/dist/{chunk-6DCKL73F.cjs.map → chunk-TCJ5B2ZD.cjs.map} +1 -1
- package/dist/{chunk-ENBGG2K2.js → chunk-V5JY5RSY.js} +2954 -1929
- package/dist/chunk-V5JY5RSY.js.map +1 -0
- package/dist/components/chat/embeddable-chat.d.ts +13 -0
- package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +104 -10
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-slash-commands.d.ts +6 -0
- package/dist/components/chat/hooks/use-slash-commands.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +81 -0
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/index.cjs +73 -51
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +26 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/app-header.d.ts +7 -0
- package/dist/components/navigation/app-header.d.ts.map +1 -1
- package/dist/components/navigation/app-layout-drawer.d.ts +65 -0
- package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -0
- package/dist/components/navigation/app-layout.d.ts +9 -1
- package/dist/components/navigation/app-layout.d.ts.map +1 -1
- package/dist/components/navigation/header-mingo-button.d.ts +21 -0
- package/dist/components/navigation/header-mingo-button.d.ts.map +1 -0
- package/dist/components/navigation/index.cjs +24 -2
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.d.ts +5 -1
- package/dist/components/navigation/index.d.ts.map +1 -1
- package/dist/components/navigation/index.js +23 -1
- package/dist/components/onboarding-guides/index.cjs +18 -18
- package/dist/components/onboarding-guides/index.js +3 -3
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +80 -66
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.js +20 -6
- package/dist/components/tickets/index.js.map +1 -1
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +26 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +25 -1
- package/dist/utils/embed-authed-fetch.d.ts +80 -0
- package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
- package/dist/utils/index.cjs +70 -5
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +70 -6
- package/dist/utils/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/embeddable-chat.tsx +154 -37
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +601 -23
- package/src/components/chat/hooks/use-slash-commands.ts +10 -1
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +45 -0
- package/src/components/chat/hooks/use-unified-chat.ts +59 -0
- package/src/components/chat/types/unified-chat-state.types.ts +116 -0
- package/src/components/navigation/app-header.tsx +23 -0
- package/src/components/navigation/app-layout-drawer.tsx +620 -0
- package/src/components/navigation/app-layout.tsx +65 -26
- package/src/components/navigation/header-mingo-button.tsx +58 -0
- package/src/components/navigation/index.ts +17 -1
- package/src/components/tickets/hooks/use-ticket-engagements.ts +24 -4
- package/src/stories/AppLayoutDrawer.stories.tsx +228 -0
- package/src/utils/.embed-authed-fetch.md +7 -0
- package/src/utils/__tests__/embed-authed-fetch.test.ts +103 -1
- package/src/utils/embed-authed-fetch.ts +247 -7
- package/src/utils/index.ts +5 -1
- package/dist/chunk-ENBGG2K2.js.map +0 -1
- package/dist/chunk-YWDC5BXM.cjs.map +0 -1
- /package/dist/{chunk-L6IBKPVM.js.map → chunk-EKBM4FHK.js.map} +0 -0
- /package/dist/{chunk-SWZUZYWR.js.map → chunk-EWA2NFUR.js.map} +0 -0
- /package/dist/{chunk-BVFRD34B.js.map → chunk-OHOUSDAY.js.map} +0 -0
- /package/dist/{chunk-N5IKPYRL.js.map → chunk-SWIR5EB2.js.map} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { Suspense, useCallback, useState } from 'react'
|
|
3
|
+
import { createContext, Suspense, useCallback, useContext, useState } from 'react'
|
|
4
4
|
import { NavigationSidebarConfig } from '../../types/navigation'
|
|
5
5
|
import { cn } from '../../utils'
|
|
6
6
|
import { NotificationDrawer } from '../features/notifications/notification-drawer'
|
|
@@ -8,6 +8,18 @@ import { AppHeader, AppHeaderProps } from './app-header'
|
|
|
8
8
|
import { MobileBurgerMenu, MobileBurgerMenuProps } from './mobile-burger-menu'
|
|
9
9
|
import { NavigationSidebar } from './navigation-sidebar'
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Container element that wraps `<main>` and serves as the portal target for
|
|
13
|
+
* `AppLayoutDrawer`. Drawers rendered into this container sit on top of the
|
|
14
|
+
* main content area only — the sidebar and header remain visible and
|
|
15
|
+
* interactive. Null when AppLayout hasn't mounted (or when used outside of it).
|
|
16
|
+
*/
|
|
17
|
+
const AppLayoutDrawerContainerContext = createContext<HTMLElement | null>(null)
|
|
18
|
+
|
|
19
|
+
export function useAppLayoutDrawerContainer(): HTMLElement | null {
|
|
20
|
+
return useContext(AppLayoutDrawerContainerContext)
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
export interface AppLayoutProps {
|
|
12
24
|
children: React.ReactNode
|
|
13
25
|
sidebarConfig: NavigationSidebarConfig
|
|
@@ -22,6 +34,13 @@ export interface AppLayoutProps {
|
|
|
22
34
|
* (`children`) is not affected and stays fully interactive.
|
|
23
35
|
*/
|
|
24
36
|
disabled?: boolean
|
|
37
|
+
/**
|
|
38
|
+
* Slot for an in-layout drawer (typically an `AppLayoutDrawer` tree). Rendered
|
|
39
|
+
* inside the layout's drawer-container context so the drawer can portal into
|
|
40
|
+
* the main-area container. Keeping it separate from `children` clarifies that
|
|
41
|
+
* the drawer is part of the layout chrome, not page content.
|
|
42
|
+
*/
|
|
43
|
+
drawer?: React.ReactNode
|
|
25
44
|
}
|
|
26
45
|
|
|
27
46
|
export function AppLayout({
|
|
@@ -33,8 +52,10 @@ export function AppLayout({
|
|
|
33
52
|
className,
|
|
34
53
|
mobileBurgerMenuProps,
|
|
35
54
|
disabled = false,
|
|
55
|
+
drawer,
|
|
36
56
|
}: AppLayoutProps) {
|
|
37
57
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
|
58
|
+
const [drawerContainer, setDrawerContainer] = useState<HTMLDivElement | null>(null)
|
|
38
59
|
|
|
39
60
|
const handleToggleMobileMenu = useCallback(() => {
|
|
40
61
|
setMobileMenuOpen(prev => !prev)
|
|
@@ -45,34 +66,52 @@ export function AppLayout({
|
|
|
45
66
|
}, [])
|
|
46
67
|
|
|
47
68
|
return (
|
|
48
|
-
<
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
/>
|
|
58
|
-
|
|
59
|
-
{/* Main Content Area */}
|
|
60
|
-
<div className="flex-1 flex flex-col overflow-hidden">
|
|
61
|
-
<AppHeader
|
|
62
|
-
{...headerProps}
|
|
63
|
-
isMobileMenuOpen={mobileMenuOpen}
|
|
64
|
-
onToggleMobileMenu={handleToggleMobileMenu}
|
|
69
|
+
<AppLayoutDrawerContainerContext.Provider value={drawerContainer}>
|
|
70
|
+
<div className={cn("flex h-screen bg-ods-bg", className)}>
|
|
71
|
+
<NavigationSidebar config={sidebarConfig} disabled={disabled} />
|
|
72
|
+
{/* Mobile Burger Menu - opens below header */}
|
|
73
|
+
<MobileBurgerMenu
|
|
74
|
+
{...mobileBurgerMenuProps}
|
|
75
|
+
isOpen={mobileMenuOpen}
|
|
76
|
+
onClose={handleCloseMobileMenu}
|
|
77
|
+
config={sidebarConfig}
|
|
65
78
|
disabled={disabled}
|
|
66
79
|
/>
|
|
67
|
-
<NotificationDrawer />
|
|
68
80
|
|
|
69
|
-
{/* Main Content */}
|
|
70
|
-
<
|
|
71
|
-
<
|
|
72
|
-
{
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
{/* Main Content Area */}
|
|
82
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
83
|
+
<AppHeader
|
|
84
|
+
{...headerProps}
|
|
85
|
+
isMobileMenuOpen={mobileMenuOpen}
|
|
86
|
+
onToggleMobileMenu={handleToggleMobileMenu}
|
|
87
|
+
disabled={disabled}
|
|
88
|
+
/>
|
|
89
|
+
<NotificationDrawer />
|
|
90
|
+
|
|
91
|
+
{/* Main + AppLayoutDrawer portal target. `relative` so the drawer
|
|
92
|
+
can absolutely position within just this area (not over
|
|
93
|
+
header/sidebar); `overflow-hidden` clips the drawer's slide-in
|
|
94
|
+
animation visually AND contains layout overflow so it doesn't
|
|
95
|
+
propagate up to <html>. (Scroll-snap-back below handles the
|
|
96
|
+
browser's programmatic scroll-on-focus side effect.) */}
|
|
97
|
+
<div
|
|
98
|
+
ref={setDrawerContainer}
|
|
99
|
+
className="flex-1 flex flex-col relative overflow-hidden"
|
|
100
|
+
>
|
|
101
|
+
<main className={cn("flex-1 overflow-y-auto", mainClassName)}>
|
|
102
|
+
<Suspense fallback={loadingFallback ?? null}>
|
|
103
|
+
{children}
|
|
104
|
+
</Suspense>
|
|
105
|
+
</main>
|
|
106
|
+
{/* `drawer` slot — rendered here so it sits inside the
|
|
107
|
+
AppLayoutDrawerContainerContext and can portal into this exact
|
|
108
|
+
container. Mount location is irrelevant for visual placement
|
|
109
|
+
(Radix Portal handles that), but keeping it close to the target
|
|
110
|
+
makes the React tree match the visual nesting. */}
|
|
111
|
+
{drawer}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
75
114
|
</div>
|
|
76
|
-
</
|
|
115
|
+
</AppLayoutDrawerContainerContext.Provider>
|
|
77
116
|
)
|
|
78
117
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { cn } from '../../utils/cn'
|
|
5
|
+
import { MingoIcon } from '../icons'
|
|
6
|
+
|
|
7
|
+
export interface HeaderMingoButtonProps
|
|
8
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
9
|
+
/** Active/pressed state — set to `true` while the Mingo drawer is open. */
|
|
10
|
+
isActive?: boolean
|
|
11
|
+
/** When true, hides the "Mingo AI" label and renders only the icon (so the
|
|
12
|
+
* control collapses to a square `HeaderButton`-sized affordance on narrow
|
|
13
|
+
* viewports). Defaults to `false`. */
|
|
14
|
+
iconOnly?: boolean
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* "Mingo AI" launcher button for `AppHeader`. Mirrors the `HeaderButton`
|
|
20
|
+
* visual contract (sticky header height, `ods-card` rest / `ods-bg-hover`
|
|
21
|
+
* hover / `ods-bg-active` active, divider via `AppHeader`'s `divide-x`), but
|
|
22
|
+
* carries both the Mingo logo and the bold "Mingo AI" wordmark.
|
|
23
|
+
*
|
|
24
|
+
* Figma: 7532:222103 — `button-full`.
|
|
25
|
+
*/
|
|
26
|
+
export function HeaderMingoButton({
|
|
27
|
+
isActive = false,
|
|
28
|
+
iconOnly = false,
|
|
29
|
+
className,
|
|
30
|
+
...props
|
|
31
|
+
}: HeaderMingoButtonProps) {
|
|
32
|
+
return (
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
aria-label="Mingo AI"
|
|
36
|
+
aria-pressed={isActive}
|
|
37
|
+
className={cn(
|
|
38
|
+
'flex items-center shrink-0 h-full gap-2 px-4',
|
|
39
|
+
'transition-colors duration-200',
|
|
40
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent',
|
|
41
|
+
isActive
|
|
42
|
+
? 'text-ods-text-primary bg-ods-bg-active'
|
|
43
|
+
: 'text-ods-text-primary bg-ods-card hover:bg-ods-bg-hover',
|
|
44
|
+
className,
|
|
45
|
+
)}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
48
|
+
<MingoIcon className="w-6 h-6 shrink-0" />
|
|
49
|
+
{!iconOnly && (
|
|
50
|
+
<span className="text-h3 font-bold tracking-[-0.36px] whitespace-nowrap">
|
|
51
|
+
Mingo AI
|
|
52
|
+
</span>
|
|
53
|
+
)}
|
|
54
|
+
</button>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default HeaderMingoButton
|
|
@@ -25,15 +25,31 @@ export type { NavigationSidebarProps } from './navigation-sidebar'
|
|
|
25
25
|
export { AppHeader } from './app-header'
|
|
26
26
|
export type { AppHeaderProps } from './app-header'
|
|
27
27
|
|
|
28
|
-
export { AppLayout } from './app-layout'
|
|
28
|
+
export { AppLayout, useAppLayoutDrawerContainer } from './app-layout'
|
|
29
29
|
export type { AppLayoutProps } from './app-layout'
|
|
30
30
|
|
|
31
|
+
export {
|
|
32
|
+
AppLayoutDrawer,
|
|
33
|
+
AppLayoutDrawerTrigger,
|
|
34
|
+
AppLayoutDrawerClose,
|
|
35
|
+
AppLayoutDrawerContent,
|
|
36
|
+
AppLayoutDrawerHeader,
|
|
37
|
+
AppLayoutDrawerTitle,
|
|
38
|
+
AppLayoutDrawerDescription,
|
|
39
|
+
AppLayoutDrawerBody,
|
|
40
|
+
AppLayoutDrawerFooter,
|
|
41
|
+
} from './app-layout-drawer'
|
|
42
|
+
export type { AppLayoutDrawerContentProps } from './app-layout-drawer'
|
|
43
|
+
|
|
31
44
|
export { MobileBurgerMenu } from './mobile-burger-menu'
|
|
32
45
|
export type { MobileBurgerMenuProps } from './mobile-burger-menu'
|
|
33
46
|
|
|
34
47
|
export { HeaderButton } from './header-button'
|
|
35
48
|
export type { HeaderButtonProps } from './header-button'
|
|
36
49
|
|
|
50
|
+
export { HeaderMingoButton } from './header-mingo-button'
|
|
51
|
+
export type { HeaderMingoButtonProps } from './header-mingo-button'
|
|
52
|
+
|
|
37
53
|
export { HeaderGlobalSearch } from './header-global-search'
|
|
38
54
|
export type { HeaderGlobalSearchProps } from './header-global-search'
|
|
39
55
|
|
|
@@ -88,13 +88,18 @@ export function useTicketEngagements(
|
|
|
88
88
|
const identity = useChatIdentity()
|
|
89
89
|
const identityKey = identity.user?.email ?? 'anon'
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
// "Will this ticket fetch its timeline once identity is ready?" — i.e. it's a
|
|
92
|
+
// real, non-optimistic ticket the caller enabled. INDEPENDENT of whether
|
|
93
|
+
// identity has resolved yet, so the loading state is correct from the very
|
|
94
|
+
// first render (before `useChatIdentity` settles).
|
|
95
|
+
const fetchable =
|
|
92
96
|
enabled &&
|
|
93
|
-
identity.authTier !== 'anon' &&
|
|
94
|
-
!!identity.user?.email &&
|
|
95
97
|
!!externalTicketId &&
|
|
96
98
|
!externalTicketId.startsWith('temp-') // optimistic placeholders have no real id yet
|
|
97
99
|
|
|
100
|
+
const queryEnabled =
|
|
101
|
+
fetchable && identity.authTier !== 'anon' && !!identity.user?.email
|
|
102
|
+
|
|
98
103
|
const query = useQuery({
|
|
99
104
|
queryKey: ['ticket-engagements', externalTicketId, identityKey],
|
|
100
105
|
enabled: queryEnabled,
|
|
@@ -126,7 +131,22 @@ export function useTicketEngagements(
|
|
|
126
131
|
|
|
127
132
|
return {
|
|
128
133
|
engagements: query.data ?? [],
|
|
129
|
-
|
|
134
|
+
// Loading-state truth that prevents the "body → blink → skeleton → data"
|
|
135
|
+
// double-flash. The bug: `useChatIdentity` starts at anon defaults and
|
|
136
|
+
// resolves async, so on the first render `queryEnabled` is false and the
|
|
137
|
+
// OLD `queryEnabled && query.isLoading` returned FALSE — the panel rendered
|
|
138
|
+
// the ticket body, THEN identity resolved, the query enabled, isLoading
|
|
139
|
+
// flipped true → skeleton appeared (the blink), then data landed.
|
|
140
|
+
//
|
|
141
|
+
// Fix: for a fetchable ticket we are "loading" whenever we don't yet have
|
|
142
|
+
// the timeline to show — that includes the window while identity is still
|
|
143
|
+
// resolving (so we skeleton from the FIRST render, never the body) AND the
|
|
144
|
+
// cold query fetch (`data === undefined`). A background poll keeps
|
|
145
|
+
// `query.data` defined, so it never re-flashes the skeleton. Non-fetchable
|
|
146
|
+
// (optimistic/disabled) or a resolved-anon viewer → not loading.
|
|
147
|
+
isLoading:
|
|
148
|
+
fetchable &&
|
|
149
|
+
(identity.isLoading || (queryEnabled && query.data === undefined)),
|
|
130
150
|
isFetching: query.isFetching,
|
|
131
151
|
error: (query.error as Error | null) ?? null,
|
|
132
152
|
refetch: () => {
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { fn } from 'storybook/test'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
BracketCurlyIcon,
|
|
7
|
+
ChartDonutIcon,
|
|
8
|
+
IdCardIcon,
|
|
9
|
+
MonitorIcon,
|
|
10
|
+
Settings02Icon,
|
|
11
|
+
} from '../components/icons-v2-generated'
|
|
12
|
+
import {
|
|
13
|
+
AppLayout,
|
|
14
|
+
AppLayoutDrawer,
|
|
15
|
+
AppLayoutDrawerBody,
|
|
16
|
+
AppLayoutDrawerContent,
|
|
17
|
+
AppLayoutDrawerDescription,
|
|
18
|
+
AppLayoutDrawerHeader,
|
|
19
|
+
AppLayoutDrawerTitle,
|
|
20
|
+
} from '../components/navigation'
|
|
21
|
+
import { Button } from '../components/ui/button'
|
|
22
|
+
import { NavigationSidebarConfig } from '../types/navigation'
|
|
23
|
+
|
|
24
|
+
const navigationItems: NavigationSidebarConfig['items'] = [
|
|
25
|
+
{ id: 'dashboard', label: 'Dashboard', icon: <ChartDonutIcon size={24} />, path: '/dashboard', isActive: true },
|
|
26
|
+
{ id: 'customers', label: 'Customers', icon: <IdCardIcon size={24} />, path: '/customers' },
|
|
27
|
+
{ id: 'devices', label: 'Devices', icon: <MonitorIcon size={24} />, path: '/devices' },
|
|
28
|
+
{ id: 'scripts', label: 'Scripts', icon: <BracketCurlyIcon size={24} />, path: '/scripts' },
|
|
29
|
+
{ id: 'settings', label: 'Settings', icon: <Settings02Icon size={24} />, path: '/settings', section: 'secondary' },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const meta = {
|
|
33
|
+
title: 'Navigation/AppLayoutDrawer',
|
|
34
|
+
component: AppLayoutDrawer,
|
|
35
|
+
parameters: {
|
|
36
|
+
layout: 'fullscreen',
|
|
37
|
+
docs: {
|
|
38
|
+
description: {
|
|
39
|
+
component: `
|
|
40
|
+
A Drawer variant that renders **inside** AppLayout's main content area
|
|
41
|
+
instead of as a viewport-level overlay. The sidebar and header remain
|
|
42
|
+
visible and interactive while it is open.
|
|
43
|
+
|
|
44
|
+
## When to use
|
|
45
|
+
- Side panels (chat, details, filters) that should not cover the global
|
|
46
|
+
chrome (header, sidebar).
|
|
47
|
+
- Anything that conceptually belongs to the current page rather than the
|
|
48
|
+
whole app.
|
|
49
|
+
|
|
50
|
+
For an overlay that covers the entire viewport (e.g. notifications,
|
|
51
|
+
auth modals), use the standard \`Drawer\` from \`components/ui\` instead.
|
|
52
|
+
`,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
tags: ['autodocs'],
|
|
57
|
+
} satisfies Meta<typeof AppLayoutDrawer>
|
|
58
|
+
|
|
59
|
+
export default meta
|
|
60
|
+
type Story = StoryObj<typeof meta>
|
|
61
|
+
|
|
62
|
+
function DashboardChildren({ onOpenDrawer }: { onOpenDrawer: () => void }) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="space-y-6 p-6">
|
|
65
|
+
<div>
|
|
66
|
+
<h1 className="text-2xl font-semibold text-ods-text-primary">Devices Overview</h1>
|
|
67
|
+
<p className="text-ods-text-secondary mt-1">8,250 Devices in Total</p>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
70
|
+
{[
|
|
71
|
+
{ label: 'Active Devices', value: '6,930' },
|
|
72
|
+
{ label: 'Active Tickets', value: '136' },
|
|
73
|
+
{ label: 'Resolved Tickets', value: '825' },
|
|
74
|
+
].map((stat) => (
|
|
75
|
+
<div key={stat.label} className="p-4 bg-ods-card rounded-lg border border-ods-border">
|
|
76
|
+
<p className="text-sm text-ods-text-secondary">{stat.label}</p>
|
|
77
|
+
<p className="text-2xl font-semibold text-ods-text-primary mt-1">{stat.value}</p>
|
|
78
|
+
</div>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
<Button onClick={onOpenDrawer}>Open in-layout drawer</Button>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Right-side in-layout drawer, passed via the `drawer` slot prop.
|
|
88
|
+
* The drawer is persistent — clicks on the overlay/header/sidebar
|
|
89
|
+
* do NOT close it; use the X button or Escape.
|
|
90
|
+
*/
|
|
91
|
+
export const Right: Story = {
|
|
92
|
+
render: function Render() {
|
|
93
|
+
const [open, setOpen] = useState(false)
|
|
94
|
+
return (
|
|
95
|
+
<AppLayout
|
|
96
|
+
sidebarConfig={{ items: navigationItems, onNavigate: fn(), onToggleMinimized: fn() }}
|
|
97
|
+
headerProps={{
|
|
98
|
+
showNotifications: true,
|
|
99
|
+
showUser: true,
|
|
100
|
+
userName: 'Mingo AI',
|
|
101
|
+
userEmail: 'mingo@openframe.dev',
|
|
102
|
+
onProfile: fn(),
|
|
103
|
+
onLogout: fn(),
|
|
104
|
+
}}
|
|
105
|
+
mobileBurgerMenuProps={{
|
|
106
|
+
user: {
|
|
107
|
+
userName: 'Mingo AI',
|
|
108
|
+
userEmail: 'mingo@openframe.dev',
|
|
109
|
+
userRole: 'Admin',
|
|
110
|
+
},
|
|
111
|
+
}}
|
|
112
|
+
drawer={
|
|
113
|
+
<AppLayoutDrawer open={open} onOpenChange={setOpen}>
|
|
114
|
+
<AppLayoutDrawerContent side="right">
|
|
115
|
+
<AppLayoutDrawerHeader>
|
|
116
|
+
<AppLayoutDrawerTitle>Current Chats</AppLayoutDrawerTitle>
|
|
117
|
+
<AppLayoutDrawerDescription>
|
|
118
|
+
Persistent: clicks on AppLayout chrome don't close this.
|
|
119
|
+
</AppLayoutDrawerDescription>
|
|
120
|
+
</AppLayoutDrawerHeader>
|
|
121
|
+
<AppLayoutDrawerBody>
|
|
122
|
+
<p className="text-sm text-ods-text-secondary">Chat content goes here.</p>
|
|
123
|
+
</AppLayoutDrawerBody>
|
|
124
|
+
</AppLayoutDrawerContent>
|
|
125
|
+
</AppLayoutDrawer>
|
|
126
|
+
}
|
|
127
|
+
>
|
|
128
|
+
<DashboardChildren onOpenDrawer={() => setOpen(true)} />
|
|
129
|
+
</AppLayout>
|
|
130
|
+
)
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resizable variant — drag the inside edge to resize. Clamped to the
|
|
136
|
+
* AppLayout main-area dimensions (not the viewport).
|
|
137
|
+
*/
|
|
138
|
+
export const ResizableRight: Story = {
|
|
139
|
+
render: function Render() {
|
|
140
|
+
const [open, setOpen] = useState(true)
|
|
141
|
+
return (
|
|
142
|
+
<AppLayout
|
|
143
|
+
sidebarConfig={{ items: navigationItems, onNavigate: fn(), onToggleMinimized: fn() }}
|
|
144
|
+
headerProps={{
|
|
145
|
+
showNotifications: true,
|
|
146
|
+
showUser: true,
|
|
147
|
+
userName: 'Mingo AI',
|
|
148
|
+
userEmail: 'mingo@openframe.dev',
|
|
149
|
+
onProfile: fn(),
|
|
150
|
+
onLogout: fn(),
|
|
151
|
+
}}
|
|
152
|
+
mobileBurgerMenuProps={{
|
|
153
|
+
user: {
|
|
154
|
+
userName: 'Mingo AI',
|
|
155
|
+
userEmail: 'mingo@openframe.dev',
|
|
156
|
+
userRole: 'Admin',
|
|
157
|
+
},
|
|
158
|
+
}}
|
|
159
|
+
drawer={
|
|
160
|
+
<AppLayoutDrawer open={open} onOpenChange={setOpen}>
|
|
161
|
+
<AppLayoutDrawerContent
|
|
162
|
+
side="right"
|
|
163
|
+
resizable
|
|
164
|
+
minSize={360}
|
|
165
|
+
defaultSize={560}
|
|
166
|
+
storageKey="storybook:app-layout-drawer:right"
|
|
167
|
+
>
|
|
168
|
+
<AppLayoutDrawerHeader>
|
|
169
|
+
<AppLayoutDrawerTitle>Resizable Drawer</AppLayoutDrawerTitle>
|
|
170
|
+
</AppLayoutDrawerHeader>
|
|
171
|
+
<AppLayoutDrawerBody>
|
|
172
|
+
<p className="text-sm text-ods-text-secondary">
|
|
173
|
+
Drag the grip on the left edge to resize. The panel can extend all
|
|
174
|
+
the way to the container edge (minus a 16px symmetric gap).
|
|
175
|
+
</p>
|
|
176
|
+
</AppLayoutDrawerBody>
|
|
177
|
+
</AppLayoutDrawerContent>
|
|
178
|
+
</AppLayoutDrawer>
|
|
179
|
+
}
|
|
180
|
+
>
|
|
181
|
+
<DashboardChildren onOpenDrawer={() => setOpen(true)} />
|
|
182
|
+
</AppLayout>
|
|
183
|
+
)
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Left-side variant.
|
|
189
|
+
*/
|
|
190
|
+
export const Left: Story = {
|
|
191
|
+
render: function Render() {
|
|
192
|
+
const [open, setOpen] = useState(false)
|
|
193
|
+
return (
|
|
194
|
+
<AppLayout
|
|
195
|
+
sidebarConfig={{ items: navigationItems, onNavigate: fn(), onToggleMinimized: fn() }}
|
|
196
|
+
headerProps={{
|
|
197
|
+
showNotifications: true,
|
|
198
|
+
showUser: true,
|
|
199
|
+
userName: 'Mingo AI',
|
|
200
|
+
userEmail: 'mingo@openframe.dev',
|
|
201
|
+
onProfile: fn(),
|
|
202
|
+
onLogout: fn(),
|
|
203
|
+
}}
|
|
204
|
+
mobileBurgerMenuProps={{
|
|
205
|
+
user: {
|
|
206
|
+
userName: 'Mingo AI',
|
|
207
|
+
userEmail: 'mingo@openframe.dev',
|
|
208
|
+
userRole: 'Admin',
|
|
209
|
+
},
|
|
210
|
+
}}
|
|
211
|
+
drawer={
|
|
212
|
+
<AppLayoutDrawer open={open} onOpenChange={setOpen}>
|
|
213
|
+
<AppLayoutDrawerContent side="left">
|
|
214
|
+
<AppLayoutDrawerHeader>
|
|
215
|
+
<AppLayoutDrawerTitle>Filters</AppLayoutDrawerTitle>
|
|
216
|
+
</AppLayoutDrawerHeader>
|
|
217
|
+
<AppLayoutDrawerBody>
|
|
218
|
+
<p className="text-sm text-ods-text-secondary">Left-side drawer content.</p>
|
|
219
|
+
</AppLayoutDrawerBody>
|
|
220
|
+
</AppLayoutDrawerContent>
|
|
221
|
+
</AppLayoutDrawer>
|
|
222
|
+
}
|
|
223
|
+
>
|
|
224
|
+
<DashboardChildren onOpenDrawer={() => setOpen(true)} />
|
|
225
|
+
</AppLayout>
|
|
226
|
+
)
|
|
227
|
+
},
|
|
228
|
+
}
|
|
@@ -9,6 +9,13 @@ Drop-in replacement for `fetch()` that:
|
|
|
9
9
|
- Delegates to `applyProxyAuth` to merge `Authorization` and `X-Chat-Act-As` headers — **proxy credentials always win** over caller-supplied headers
|
|
10
10
|
- Defaults `Content-Type: application/json` when no headers are provided
|
|
11
11
|
- Forces `credentials: 'same-origin'` to ensure Supabase auth cookies are included alongside bearer tokens
|
|
12
|
+
- **Self-heals on `401`** when a host registered an `EmbedAuthAdapter` with a `refresh` callback: calls `refresh()` once, and on success retries the same request a single time with freshly-recomputed headers (so a rotated bearer is picked up). Concurrent 401s share ONE refresh. With no adapter / no `refresh`, the 401 passes through unchanged.
|
|
13
|
+
|
|
14
|
+
### `setEmbedAuthAdapter(adapter | null)` + `EmbedAuthAdapter`
|
|
15
|
+
Optional host-owned auth override registered at module level (one chat panel per app). The adapter can supply:
|
|
16
|
+
- `getHeaders()` — headers merged onto every call AFTER proxy-auth (adapter wins), re-read per request so token rotation is seen
|
|
17
|
+
- `credentials` — overrides the default `'same-origin'` (e.g. `'include'` for cross-origin cookie auth)
|
|
18
|
+
- `refresh()` — `Promise<boolean>` 401 self-heal hook (see above). This lifts the openframe `apiClient`'s refresh-then-retry behaviour into the lib so embedded surfaces no longer need a host-side `window.fetch` monkey-patch to survive an expired token mid-session.
|
|
12
19
|
|
|
13
20
|
### `assertSameOrigin(url)` *(internal)*
|
|
14
21
|
Defense-in-depth guard that:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { embedAuthedFetch } from '../embed-authed-fetch'
|
|
2
|
+
import { embedAuthedFetch, setEmbedAuthAdapter } from '../embed-authed-fetch'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Tests focus on the same-origin guard rather than the proxy-auth
|
|
@@ -11,9 +11,15 @@ describe('embedAuthedFetch.assertSameOrigin guard', () => {
|
|
|
11
11
|
|
|
12
12
|
beforeEach(() => {
|
|
13
13
|
globalThis.fetch = vi.fn(async () => new Response('ok')) as typeof fetch
|
|
14
|
+
// The cross-origin guard has a dev-mode escape hatch (warn + allow
|
|
15
|
+
// instead of throw) when `NODE_ENV !== 'production'` — see
|
|
16
|
+
// `embed-authed-fetch.ts`. Pin the env to production so these tests
|
|
17
|
+
// exercise the bearer-leak defense, not the dev convenience path.
|
|
18
|
+
vi.stubEnv('NODE_ENV', 'production')
|
|
14
19
|
})
|
|
15
20
|
afterEach(() => {
|
|
16
21
|
globalThis.fetch = originalFetch
|
|
22
|
+
vi.unstubAllEnvs()
|
|
17
23
|
vi.restoreAllMocks()
|
|
18
24
|
})
|
|
19
25
|
|
|
@@ -64,3 +70,99 @@ describe('embedAuthedFetch.assertSameOrigin guard', () => {
|
|
|
64
70
|
expect(spy).not.toHaveBeenCalled()
|
|
65
71
|
})
|
|
66
72
|
})
|
|
73
|
+
|
|
74
|
+
describe('embedAuthedFetch 401 self-heal (adapter.refresh)', () => {
|
|
75
|
+
const originalFetch = globalThis.fetch
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
globalThis.fetch = originalFetch
|
|
79
|
+
setEmbedAuthAdapter(null)
|
|
80
|
+
vi.restoreAllMocks()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('refreshes once then retries the request on a 401', async () => {
|
|
84
|
+
const fetchSpy = vi
|
|
85
|
+
.fn()
|
|
86
|
+
.mockResolvedValueOnce(new Response('nope', { status: 401 }))
|
|
87
|
+
.mockResolvedValueOnce(new Response('ok', { status: 200 }))
|
|
88
|
+
globalThis.fetch = fetchSpy as typeof fetch
|
|
89
|
+
|
|
90
|
+
const refresh = vi.fn(async () => true)
|
|
91
|
+
setEmbedAuthAdapter({ getHeaders: () => ({ Authorization: 'Bearer t' }), refresh })
|
|
92
|
+
|
|
93
|
+
const res = await embedAuthedFetch('/api/chat/x')
|
|
94
|
+
expect(res.status).toBe(200)
|
|
95
|
+
expect(refresh).toHaveBeenCalledTimes(1)
|
|
96
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('surfaces the 401 (no retry) when refresh resolves false', async () => {
|
|
100
|
+
const fetchSpy = vi.fn(async () => new Response('nope', { status: 401 }))
|
|
101
|
+
globalThis.fetch = fetchSpy as typeof fetch
|
|
102
|
+
|
|
103
|
+
const refresh = vi.fn(async () => false)
|
|
104
|
+
setEmbedAuthAdapter({ refresh })
|
|
105
|
+
|
|
106
|
+
const res = await embedAuthedFetch('/api/chat/x')
|
|
107
|
+
expect(res.status).toBe(401)
|
|
108
|
+
expect(refresh).toHaveBeenCalledTimes(1)
|
|
109
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('does not retry a second time when the refreshed token is also 401', async () => {
|
|
113
|
+
const fetchSpy = vi.fn(async () => new Response('nope', { status: 401 }))
|
|
114
|
+
globalThis.fetch = fetchSpy as typeof fetch
|
|
115
|
+
|
|
116
|
+
const refresh = vi.fn(async () => true)
|
|
117
|
+
setEmbedAuthAdapter({ refresh })
|
|
118
|
+
|
|
119
|
+
const res = await embedAuthedFetch('/api/chat/x')
|
|
120
|
+
expect(res.status).toBe(401)
|
|
121
|
+
// refresh fired once; the retry's 401 is surfaced rather than looping.
|
|
122
|
+
expect(refresh).toHaveBeenCalledTimes(1)
|
|
123
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('de-dupes concurrent 401s into a single refresh', async () => {
|
|
127
|
+
const fetchSpy = vi
|
|
128
|
+
.fn()
|
|
129
|
+
.mockResolvedValueOnce(new Response('nope', { status: 401 }))
|
|
130
|
+
.mockResolvedValueOnce(new Response('nope', { status: 401 }))
|
|
131
|
+
.mockResolvedValue(new Response('ok', { status: 200 }))
|
|
132
|
+
globalThis.fetch = fetchSpy as typeof fetch
|
|
133
|
+
|
|
134
|
+
// A refresh that stays pending until we release it — so BOTH 401s are
|
|
135
|
+
// parked on the single shared slot before it settles. (If it resolved
|
|
136
|
+
// eagerly, the slot could clear between the two 401s and the test would
|
|
137
|
+
// race.)
|
|
138
|
+
let releaseRefresh: (v: boolean) => void = () => {}
|
|
139
|
+
const refreshGate = new Promise<boolean>((resolve) => {
|
|
140
|
+
releaseRefresh = resolve
|
|
141
|
+
})
|
|
142
|
+
const refresh = vi.fn(() => refreshGate)
|
|
143
|
+
setEmbedAuthAdapter({ refresh })
|
|
144
|
+
|
|
145
|
+
const p1 = embedAuthedFetch('/api/chat/a')
|
|
146
|
+
const p2 = embedAuthedFetch('/api/chat/b')
|
|
147
|
+
|
|
148
|
+
// Wait until both initial fetches have 401'd and reached the (still
|
|
149
|
+
// pending) refresh slot, then release it so both retries fire.
|
|
150
|
+
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(2))
|
|
151
|
+
releaseRefresh(true)
|
|
152
|
+
|
|
153
|
+
const [r1, r2] = await Promise.all([p1, p2])
|
|
154
|
+
expect(r1.status).toBe(200)
|
|
155
|
+
expect(r2.status).toBe(200)
|
|
156
|
+
// Both 401s shared ONE refresh call.
|
|
157
|
+
expect(refresh).toHaveBeenCalledTimes(1)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('passes a 401 through untouched when no adapter is registered', async () => {
|
|
161
|
+
const fetchSpy = vi.fn(async () => new Response('nope', { status: 401 }))
|
|
162
|
+
globalThis.fetch = fetchSpy as typeof fetch
|
|
163
|
+
|
|
164
|
+
const res = await embedAuthedFetch('/api/chat/x')
|
|
165
|
+
expect(res.status).toBe(401)
|
|
166
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
167
|
+
})
|
|
168
|
+
})
|