@taruvi/navkit 0.0.48-beta.8 → 0.0.48
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/README.md +728 -29
- package/package.json +2 -2
- package/src/App.styles.ts +4 -4
- package/src/App.tsx +9 -54
- package/src/NavkitContext.tsx +30 -34
- package/src/components/AppLauncher/AppLauncher.tsx +37 -13
- package/src/components/DraggableResizable.tsx +1 -11
- package/src/components/MattermostChat/MattermostChat.tsx +107 -69
- package/src/components/Profile/ProfileMenu.tsx +1 -10
- package/src/components/Search/Search.tsx +4 -15
- package/src/components/Shortucts/Shortcuts.styles.ts +1 -3
- package/src/components/Shortucts/Shortcuts.tsx +20 -4
- package/src/components/Shortucts/ShortcutsMenu.tsx +25 -4
- package/src/types.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taruvi/navkit",
|
|
3
|
-
"version": "0.0.48
|
|
3
|
+
"version": "0.0.48",
|
|
4
4
|
"main": "src/App.tsx",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -48,6 +48,6 @@
|
|
|
48
48
|
"vitest": "^4.0.18"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@taruvi/sdk": "
|
|
51
|
+
"@taruvi/sdk": "latest"
|
|
52
52
|
}
|
|
53
53
|
}
|
package/src/App.styles.ts
CHANGED
|
@@ -53,9 +53,9 @@ export const appStyles = {
|
|
|
53
53
|
padding: '8px',
|
|
54
54
|
},
|
|
55
55
|
chatOpenButton: {
|
|
56
|
-
backgroundColor:
|
|
56
|
+
backgroundColor: colours.bg.white,
|
|
57
57
|
'&:hover': {
|
|
58
|
-
backgroundColor:
|
|
58
|
+
backgroundColor: colours.bg.light,
|
|
59
59
|
},
|
|
60
60
|
},
|
|
61
61
|
iconStyle: {
|
|
@@ -139,9 +139,9 @@ export const getAppStyles = (mode: ThemeMode) => {
|
|
|
139
139
|
padding: '8px',
|
|
140
140
|
},
|
|
141
141
|
chatOpenButton: {
|
|
142
|
-
backgroundColor:
|
|
142
|
+
backgroundColor: colors.bg.white,
|
|
143
143
|
'&:hover': {
|
|
144
|
-
backgroundColor:
|
|
144
|
+
backgroundColor: colors.bg.light,
|
|
145
145
|
},
|
|
146
146
|
},
|
|
147
147
|
iconStyle: {
|
package/src/App.tsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import AppBar from "@mui/material/AppBar"
|
|
2
2
|
import Toolbar from "@mui/material/Toolbar"
|
|
3
3
|
import IconButton from "@mui/material/IconButton"
|
|
4
|
-
import { SvgIcon } from "@mui/material"
|
|
5
4
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
|
6
5
|
import { useState } from "react"
|
|
7
6
|
import { AppLauncher, Profile, ProfileMenu, Shortcuts, ShortcutsMenu, MattermostChat } from "./components"
|
|
@@ -13,31 +12,8 @@ import taruviLogo from "./assets/logo.svg";
|
|
|
13
12
|
import taruviLogoWhite from "./assets/taruvi-logo-white.png";
|
|
14
13
|
import type { Client } from "@taruvi/sdk"
|
|
15
14
|
|
|
16
|
-
/** Top-level storage access for the chat origin (Chromium). Call synchronously from the chat button click
|
|
17
|
-
* so transient activation is preserved; pairs with the iframe autologin bridge so no second in-frame login
|
|
18
|
-
* click is needed when the browser supports this API. */
|
|
19
|
-
function requestMattermostStorageAccessFromUserGesture(chatUrl: string) {
|
|
20
|
-
try {
|
|
21
|
-
const origin = new URL(chatUrl).origin
|
|
22
|
-
const doc = document as Document & {
|
|
23
|
-
requestStorageAccessFor?: (requestedOrigin: string) => Promise<void>
|
|
24
|
-
}
|
|
25
|
-
if (typeof doc.requestStorageAccessFor === "function") {
|
|
26
|
-
void doc.requestStorageAccessFor(origin).catch(() => {})
|
|
27
|
-
}
|
|
28
|
-
} catch {
|
|
29
|
-
// invalid chatUrl or API unavailable
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const ChatIcon = ({ color }: { color?: string }) => (
|
|
34
|
-
<SvgIcon viewBox="-2 -2 24 24" sx={{ fontSize: 23, display: 'block', position: 'relative', top: 2 }}>
|
|
35
|
-
<path d="M18 0H2C0.9 0 0 0.9 0 2V20L4 16H18C19.1 16 20 15.1 20 14V2C20 0.9 19.1 0 18 0Z" fill={color || "#9DE5FD"} />
|
|
36
|
-
</SvgIcon>
|
|
37
|
-
)
|
|
38
|
-
|
|
39
15
|
const NavkitContent = () => {
|
|
40
|
-
const { isUserAuthenticated, appSettings, appSettingsLoaded, appName, userData,
|
|
16
|
+
const { isUserAuthenticated, siteSettings, appSettings, appSettingsLoaded, appName, userData, jwtToken, themeMode, navbarColor, iconColor } = useNavigation();
|
|
41
17
|
const resolvedAppName = appName || appSettings?.displayName
|
|
42
18
|
const showTaruviLogo = appSettingsLoaded && !resolvedAppName && !appSettings?.icon
|
|
43
19
|
const styles = getAppStyles(themeMode)
|
|
@@ -76,18 +52,7 @@ const NavkitContent = () => {
|
|
|
76
52
|
) : null}
|
|
77
53
|
</Box>
|
|
78
54
|
<Box component={"div"} sx={styles.rightSection}>
|
|
79
|
-
<Shortcuts onMenuToggle={() => setShowShortcutsMenu(!showShortcutsMenu)} iconColor={iconColor} />
|
|
80
|
-
{isUserAuthenticated && appsList.some(a => a.id === 'chat') && chatUrl && (
|
|
81
|
-
<Box
|
|
82
|
-
onClick={() => {
|
|
83
|
-
requestMattermostStorageAccessFromUserGesture(chatUrl)
|
|
84
|
-
setShowChat(true)
|
|
85
|
-
}}
|
|
86
|
-
sx={{ ...styles.appLauncherContainer, cursor: 'pointer' }}
|
|
87
|
-
>
|
|
88
|
-
<ChatIcon color={iconColor} />
|
|
89
|
-
</Box>
|
|
90
|
-
)}
|
|
55
|
+
<Shortcuts showChat={setShowChat} onMenuToggle={() => setShowShortcutsMenu(!showShortcutsMenu)} iconColor={iconColor} />
|
|
91
56
|
<Box
|
|
92
57
|
onClick={() => {
|
|
93
58
|
if (isUserAuthenticated) {
|
|
@@ -134,28 +99,18 @@ const NavkitContent = () => {
|
|
|
134
99
|
|
|
135
100
|
{showShortcutsMenu && (
|
|
136
101
|
<Box>
|
|
137
|
-
<ShortcutsMenu />
|
|
102
|
+
<ShortcutsMenu showChat={setShowChat} />
|
|
138
103
|
</Box>
|
|
139
104
|
)}
|
|
140
105
|
|
|
141
|
-
{showChat &&
|
|
142
|
-
<DraggableResizable
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
initialHeight={Math.min(600, window.innerHeight * 0.8)}
|
|
146
|
-
titleBarActions={
|
|
147
|
-
<IconButton onClick={() => window.open(chatUrl, "_blank")} sx={styles.chatOpenButton}>
|
|
106
|
+
{showChat && siteSettings['chat-url'] && (
|
|
107
|
+
<DraggableResizable onClose={() => setShowChat(false)} initialWidth={Math.min(1200, window.innerWidth * 0.9)} initialHeight={Math.min(600, window.innerHeight * 0.8)}>
|
|
108
|
+
<Box sx={styles.chatHeader}>
|
|
109
|
+
<IconButton onClick={() => window.open(siteSettings['chat-url'], "_blank")} sx={styles.chatOpenButton}>
|
|
148
110
|
<FontAwesomeIcon icon={["fas", "external-link-alt"]} style={{ fontSize: "10px" }} />
|
|
149
111
|
</IconButton>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<MattermostChat
|
|
153
|
-
mattermostUrl={chatUrl}
|
|
154
|
-
loginId={userData?.username ?? ''}
|
|
155
|
-
sessionToken={sessionToken ?? ''}
|
|
156
|
-
width="100%"
|
|
157
|
-
height="100%"
|
|
158
|
-
/>
|
|
112
|
+
</Box>
|
|
113
|
+
<MattermostChat jwtToken={jwtToken} mattermostUrl={siteSettings['chat-url']} loginId={userData?.email || ''} width="100%" height="100%" />
|
|
159
114
|
</DraggableResizable>
|
|
160
115
|
)}
|
|
161
116
|
</>
|
package/src/NavkitContext.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { version } from '../package.json' with { type: 'json' }
|
|
|
2
2
|
import { createContext, startTransition, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
3
3
|
import type { AppData, AppSettings, NavigationContextType, NavkitProviderProps, SiteSettings, UserData } from './types'
|
|
4
4
|
import type { ThemeMode } from './styles/variables'
|
|
5
|
-
import { User, Settings, Auth, App
|
|
5
|
+
import { User, Settings, Auth, App } from "@taruvi/sdk"
|
|
6
6
|
import { library } from '@fortawesome/fontawesome-svg-core'
|
|
7
7
|
import { fas } from '@fortawesome/free-solid-svg-icons'
|
|
8
8
|
import { far } from '@fortawesome/free-regular-svg-icons'
|
|
@@ -24,7 +24,7 @@ export const NavkitProvider = ({ children, client, onThemeChange, appName, navba
|
|
|
24
24
|
|
|
25
25
|
const user = useRef<any>(null)
|
|
26
26
|
const settings = useRef<any>(null)
|
|
27
|
-
const
|
|
27
|
+
const siteSettings = useRef<SiteSettings>({
|
|
28
28
|
shortcuts: [],
|
|
29
29
|
logo: '',
|
|
30
30
|
'show-chat': false,
|
|
@@ -38,8 +38,7 @@ export const NavkitProvider = ({ children, client, onThemeChange, appName, navba
|
|
|
38
38
|
const [isDesk, setIsDesk] = useState<boolean>(false)
|
|
39
39
|
const [appsList, setAppsList] = useState<AppData[]>([])
|
|
40
40
|
const [userData, setUserData] = useState<UserData | null>(null)
|
|
41
|
-
const [
|
|
42
|
-
const [sessionToken, setSessionToken] = useState<string>('')
|
|
41
|
+
const [jwtToken, setJwtToken] = useState<string>('')
|
|
43
42
|
const [isUserAuthenticated, setIsUserAuthenticated] = useState<boolean>(false)
|
|
44
43
|
const [themeMode, setThemeMode] = useState<ThemeMode>(getInitialTheme)
|
|
45
44
|
|
|
@@ -76,8 +75,8 @@ export const NavkitProvider = ({ children, client, onThemeChange, appName, navba
|
|
|
76
75
|
|
|
77
76
|
// Get JWT token if authenticated from sdk
|
|
78
77
|
if (authenticated) {
|
|
79
|
-
const token =
|
|
80
|
-
|
|
78
|
+
const token = client.tokenClient.getToken() || ''
|
|
79
|
+
setJwtToken(token)
|
|
81
80
|
}
|
|
82
81
|
}
|
|
83
82
|
|
|
@@ -105,48 +104,45 @@ export const NavkitProvider = ({ children, client, onThemeChange, appName, navba
|
|
|
105
104
|
}
|
|
106
105
|
|
|
107
106
|
const rawSettings = (fetchedSettings?.data ?? fetchedSettings)?.settings ?? {}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
const secretResponse = await new Secrets(client).get('chat-url').execute<{ data: { value: string | { chat_url?: string, url?: string } } }>()
|
|
112
|
-
const secretValue = secretResponse?.data?.value
|
|
113
|
-
chatUrl = typeof secretValue === 'string'
|
|
114
|
-
? secretValue
|
|
115
|
-
: (secretValue?.chat_url ?? secretValue?.url ?? '')
|
|
116
|
-
} catch { }
|
|
117
|
-
setChatUrl(chatUrl)
|
|
118
|
-
|
|
119
|
-
const newSiteSettings: SiteSettings = {
|
|
120
|
-
shortcuts: [],
|
|
121
|
-
logo: rawSettings['navkit.logo'] ?? '',
|
|
107
|
+
siteSettings.current = {
|
|
108
|
+
...siteSettings.current,
|
|
122
109
|
'show-chat': rawSettings['navkit.show-chat'] ?? false,
|
|
123
|
-
'chat-url':
|
|
110
|
+
'chat-url': rawSettings['navkit.chat-url'] ?? '',
|
|
124
111
|
frontendUrl: rawSettings['navkit.frontend-url'] ?? '',
|
|
112
|
+
logo: rawSettings['navkit.logo'] ?? '',
|
|
125
113
|
enableDarkMode: rawSettings['navkit.enable-dark-mode'] ?? false,
|
|
126
114
|
}
|
|
127
|
-
setSiteSettings(newSiteSettings)
|
|
128
115
|
if (isUserAuthenticated) {
|
|
129
116
|
const userDataResponse = await auth.getCurrentUser()
|
|
130
117
|
setUserData(userDataResponse?.data || null)
|
|
131
118
|
|
|
132
119
|
// Fetch user apps using username from userData
|
|
133
120
|
if (userDataResponse?.data?.username) {
|
|
134
|
-
|
|
135
|
-
// Transform API response to match AppData interface
|
|
136
|
-
const transformedApps: AppData[] = (appsResponse?.data || []).map?.((app: any) => ({
|
|
137
|
-
id: app.slug,
|
|
138
|
-
appname: app.display_name || app.name,
|
|
139
|
-
icon: app.icon || "",
|
|
140
|
-
url: app.url
|
|
141
|
-
}))
|
|
142
|
-
startTransition(() => {
|
|
143
|
-
setAppsList(transformedApps)
|
|
144
|
-
})
|
|
121
|
+
fetchUserAppsList(userDataResponse.data.username)
|
|
145
122
|
}
|
|
146
123
|
}
|
|
147
124
|
|
|
148
125
|
}, [appName, client])
|
|
149
126
|
|
|
127
|
+
const fetchUserAppsList = async (name?: string) => {
|
|
128
|
+
// Prefer the explicitly passed username (used on first load, when userData
|
|
129
|
+
// state hasn't committed yet); fall back to committed userData for the
|
|
130
|
+
// argument-less onError refresh path.
|
|
131
|
+
const username = name ?? userData?.username
|
|
132
|
+
// Transform API response to match AppData interface
|
|
133
|
+
const appsResponse = await user.current?.getUserApps?.(username)
|
|
134
|
+
const transformedApps: AppData[] = (appsResponse?.data || []).map?.((app: any) => ({
|
|
135
|
+
id: app.slug,
|
|
136
|
+
appname: app.display_name || app.name,
|
|
137
|
+
icon: app.icon || "",
|
|
138
|
+
url: app.url
|
|
139
|
+
}))
|
|
140
|
+
startTransition(() => {
|
|
141
|
+
setAppsList(transformedApps)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
150
146
|
useLayoutEffect(() => {
|
|
151
147
|
console.log(`Taruvi Navkit v${version} initialized`)
|
|
152
148
|
const init = async () => {
|
|
@@ -189,7 +185,7 @@ export const NavkitProvider = ({ children, client, onThemeChange, appName, navba
|
|
|
189
185
|
}, [themeMode, onThemeChange])
|
|
190
186
|
|
|
191
187
|
return (
|
|
192
|
-
<NavigationContext.Provider value={{ navigateToUrl, isDesk, appsList, userData, siteSettings, appSettings, appSettingsLoaded, appName,
|
|
188
|
+
<NavigationContext.Provider value={{ navigateToUrl, isDesk, appsList, userData, siteSettings: siteSettings.current, appSettings, appSettingsLoaded, appName, jwtToken, isUserAuthenticated, client, auth, themeMode, toggleTheme, navbarColor, iconColor, fetchUserAppsList }}>
|
|
193
189
|
{children}
|
|
194
190
|
</NavigationContext.Provider>
|
|
195
191
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState,
|
|
1
|
+
import { useState, useRef, useMemo } from "react"
|
|
2
2
|
import type { AppData } from "../../types"
|
|
3
3
|
import Search from "../Search/Search"
|
|
4
4
|
import { Card, Box, Grid, Typography } from "@mui/material"
|
|
@@ -19,26 +19,49 @@ const isValidFaIcon = (icon: string): boolean => {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const AppLauncher = () => {
|
|
22
|
-
const { navigateToUrl, appsList
|
|
23
|
-
const appsList = allApps.filter(a => a.id !== 'chat')
|
|
22
|
+
const { navigateToUrl, appsList, themeMode, fetchUserAppsList } = useNavigation()
|
|
24
23
|
const styles = getAppLauncherStyles(themeMode)
|
|
25
|
-
const [
|
|
24
|
+
const [query, setQuery] = useState('')
|
|
25
|
+
|
|
26
|
+
// Derive during render — no second state, no effect, no loop
|
|
27
|
+
const filteredApps = useMemo(
|
|
28
|
+
() => appsList.filter(app =>
|
|
29
|
+
app.appname?.toLowerCase().includes(query.toLowerCase())
|
|
30
|
+
),
|
|
31
|
+
[appsList, query]
|
|
32
|
+
)
|
|
33
|
+
// Single-flight guard for fetchUserAppsList.
|
|
34
|
+
// This prevents multiple concurrent calls to fetchUserAppsList.
|
|
35
|
+
const refreshPromise = useRef<Promise<void> | null>(null)
|
|
26
36
|
|
|
27
|
-
const
|
|
28
|
-
|
|
37
|
+
const fetchIcons = async () => {
|
|
38
|
+
// A refresh is already running — share it instead of starting another.
|
|
39
|
+
if (refreshPromise.current) {
|
|
40
|
+
await refreshPromise.current
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
refreshPromise.current = (async () => {
|
|
45
|
+
try {
|
|
46
|
+
await fetchUserAppsList()
|
|
47
|
+
} finally {
|
|
48
|
+
// Release the guard so the next expiry cycle can refresh again.
|
|
49
|
+
refreshPromise.current = null
|
|
50
|
+
}
|
|
51
|
+
})()
|
|
52
|
+
|
|
53
|
+
await refreshPromise.current
|
|
29
54
|
}
|
|
30
55
|
|
|
31
|
-
const
|
|
32
|
-
|
|
56
|
+
const handleAppClick = (app: AppData) => {
|
|
57
|
+
navigateToUrl(app.url, false)
|
|
33
58
|
}
|
|
34
59
|
|
|
35
|
-
|
|
36
|
-
setFilteredApps(appsList || [])
|
|
37
|
-
}, [appsList])
|
|
60
|
+
const handleSearchChange = (q: string) => setQuery(q)
|
|
38
61
|
|
|
39
62
|
return (
|
|
40
63
|
<Card sx={styles.container}>
|
|
41
|
-
<Search
|
|
64
|
+
<Search onSearchChange={handleSearchChange} />
|
|
42
65
|
{filteredApps.filter(app => app.url).length === 0 ? (
|
|
43
66
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
|
44
67
|
<Typography sx={{ color: styles.appName.color, fontSize: typography.sizes.sm }}>No apps available</Typography>
|
|
@@ -60,6 +83,7 @@ const AppLauncher = () => {
|
|
|
60
83
|
<img
|
|
61
84
|
src={app.icon}
|
|
62
85
|
alt={app.appname}
|
|
86
|
+
onError={fetchIcons}
|
|
63
87
|
style={{
|
|
64
88
|
width: 40,
|
|
65
89
|
height: 40,
|
|
@@ -94,4 +118,4 @@ const AppLauncher = () => {
|
|
|
94
118
|
)
|
|
95
119
|
}
|
|
96
120
|
|
|
97
|
-
export default AppLauncher
|
|
121
|
+
export default AppLauncher
|
|
@@ -3,7 +3,6 @@ import { Box } from '@mui/material'
|
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
5
|
children: React.ReactNode
|
|
6
|
-
titleBarActions?: React.ReactNode
|
|
7
6
|
initialWidth?: number
|
|
8
7
|
initialHeight?: number
|
|
9
8
|
minWidth?: number
|
|
@@ -15,7 +14,6 @@ const HEADER_HEIGHT = 28
|
|
|
15
14
|
|
|
16
15
|
const DraggableResizable = ({
|
|
17
16
|
children,
|
|
18
|
-
titleBarActions,
|
|
19
17
|
initialWidth = 900,
|
|
20
18
|
initialHeight = 600,
|
|
21
19
|
minWidth = 320,
|
|
@@ -110,21 +108,13 @@ const DraggableResizable = ({
|
|
|
110
108
|
cursor: 'grab',
|
|
111
109
|
display: 'flex',
|
|
112
110
|
alignItems: 'center',
|
|
113
|
-
justifyContent: '
|
|
114
|
-
px: 1,
|
|
111
|
+
justifyContent: 'center',
|
|
115
112
|
flexShrink: 0,
|
|
116
113
|
userSelect: 'none',
|
|
117
114
|
'&:active': { cursor: 'grabbing' },
|
|
118
115
|
}}
|
|
119
116
|
>
|
|
120
|
-
<Box sx={{ width: 32 }} />
|
|
121
117
|
<Box sx={{ width: 40, height: 4, borderRadius: 2, backgroundColor: '#999' }} />
|
|
122
|
-
<Box
|
|
123
|
-
sx={{ display: 'flex', alignItems: 'center', width: 32, justifyContent: 'flex-end' }}
|
|
124
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
125
|
-
>
|
|
126
|
-
{titleBarActions}
|
|
127
|
-
</Box>
|
|
128
118
|
</Box>
|
|
129
119
|
{/* Content */}
|
|
130
120
|
<Box sx={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
|
@@ -1,46 +1,98 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {Box, Badge} from '@mui/material'
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Box, Badge, CircularProgress, Typography } from '@mui/material'
|
|
3
3
|
|
|
4
4
|
interface MattermostChatProps {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
jwtToken: string
|
|
6
|
+
mattermostUrl: string
|
|
7
|
+
loginId: string
|
|
8
8
|
onNotification?: (count: number) => void
|
|
9
9
|
onUrlClick?: (url: string) => void
|
|
10
10
|
width?: string | number
|
|
11
11
|
height?: string | number
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
interface NotifyMessage {
|
|
15
|
+
event: string
|
|
16
|
+
data?: {
|
|
17
|
+
title?: string
|
|
18
|
+
body?: string
|
|
19
|
+
channel?: string
|
|
20
|
+
teamId?: string
|
|
21
|
+
url?: string
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MattermostChat = ({
|
|
26
|
+
jwtToken,
|
|
27
|
+
mattermostUrl,
|
|
28
|
+
loginId,
|
|
29
|
+
onNotification,
|
|
30
|
+
onUrlClick,
|
|
31
|
+
width = '100%',
|
|
32
|
+
height = '100%'
|
|
33
|
+
}: MattermostChatProps) => {
|
|
15
34
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
16
|
-
const credentialsRef = useRef({ loginId, sessionToken })
|
|
17
|
-
credentialsRef.current = { loginId, sessionToken }
|
|
18
35
|
const [unreadCount, setUnreadCount] = useState(0)
|
|
19
36
|
const [isWindowFocused, setIsWindowFocused] = useState(true)
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const { loginId: id, sessionToken: token } = credentialsRef.current
|
|
24
|
-
if (!id || !token) return
|
|
25
|
-
iframeRef.current?.contentWindow?.postMessage(
|
|
26
|
-
{ type: 'mm-login', loginId: id, token },
|
|
27
|
-
mattermostOrigin
|
|
28
|
-
)
|
|
29
|
-
}
|
|
37
|
+
const [authToken, setAuthToken] = useState<string | null>(null)
|
|
38
|
+
const [authError, setAuthError] = useState<string | null>(null)
|
|
39
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
30
40
|
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
// Authenticate with Mattermost on mount
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const login = async () => {
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`${mattermostUrl.replace(/\/$/, '')}/api/v4/users/login`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
48
|
+
credentials: 'omit',
|
|
49
|
+
body: JSON.stringify({ login_id: loginId, password: jwtToken, token: '', deviceId: '' })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
setAuthError(`Login failed (${res.status})`)
|
|
54
|
+
setIsLoading(false)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
36
57
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
58
|
+
const token = res.headers.get('Token')
|
|
59
|
+
if (token) {
|
|
60
|
+
setAuthToken(token)
|
|
61
|
+
} else {
|
|
62
|
+
setAuthError('No token in response')
|
|
63
|
+
}
|
|
64
|
+
} catch (e: any) {
|
|
65
|
+
setAuthError(e.message || 'Login failed')
|
|
41
66
|
}
|
|
67
|
+
setIsLoading(false)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (loginId && jwtToken) login()
|
|
71
|
+
else { setAuthError('Missing credentials'); setIsLoading(false) }
|
|
72
|
+
}, [mattermostUrl, loginId, jwtToken])
|
|
73
|
+
|
|
74
|
+
// Inject auth token into iframe via postMessage once loaded
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!authToken || !iframeRef.current) return
|
|
77
|
+
|
|
78
|
+
const handleIframeLoad = () => {
|
|
79
|
+
// Set the auth cookie/token in the iframe context
|
|
80
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
81
|
+
{ type: 'token', data: authToken },
|
|
82
|
+
new URL(mattermostUrl).origin
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const iframe = iframeRef.current
|
|
87
|
+
iframe.addEventListener('load', handleIframeLoad)
|
|
88
|
+
return () => iframe.removeEventListener('load', handleIframeLoad)
|
|
89
|
+
}, [authToken, mattermostUrl])
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const handleMessage = (event: MessageEvent<NotifyMessage>) => {
|
|
93
|
+
const mattermostOrigin = new URL(mattermostUrl).origin
|
|
94
|
+
if (event.origin !== mattermostOrigin) return
|
|
42
95
|
|
|
43
|
-
// Mattermost Notify events (unread badge, URL clicks)
|
|
44
96
|
if (event.data?.event === 'Notify') {
|
|
45
97
|
if (!isWindowFocused) {
|
|
46
98
|
setUnreadCount(prev => {
|
|
@@ -49,74 +101,60 @@ const MattermostChat = ({ mattermostUrl, loginId, sessionToken, onNotification,
|
|
|
49
101
|
return newCount
|
|
50
102
|
})
|
|
51
103
|
}
|
|
52
|
-
if (event.data.data?.url && onUrlClick)
|
|
53
|
-
onUrlClick(event.data.data.url)
|
|
54
|
-
}
|
|
104
|
+
if (event.data.data?.url && onUrlClick) onUrlClick(event.data.data.url)
|
|
55
105
|
}
|
|
56
106
|
}
|
|
57
107
|
|
|
58
|
-
const handleFocus = () => {
|
|
59
|
-
setIsWindowFocused(true)
|
|
60
|
-
setUnreadCount(0)
|
|
61
|
-
onNotification?.(0)
|
|
62
|
-
}
|
|
63
|
-
|
|
108
|
+
const handleFocus = () => { setIsWindowFocused(true); setUnreadCount(0); onNotification?.(0) }
|
|
64
109
|
const handleBlur = () => setIsWindowFocused(false)
|
|
65
110
|
|
|
66
111
|
window.addEventListener('message', handleMessage)
|
|
67
112
|
window.addEventListener('focus', handleFocus)
|
|
68
113
|
window.addEventListener('blur', handleBlur)
|
|
69
|
-
|
|
70
114
|
return () => {
|
|
71
115
|
window.removeEventListener('message', handleMessage)
|
|
72
116
|
window.removeEventListener('focus', handleFocus)
|
|
73
117
|
window.removeEventListener('blur', handleBlur)
|
|
74
118
|
}
|
|
75
|
-
}, [
|
|
119
|
+
}, [mattermostUrl, isWindowFocused, onNotification, onUrlClick])
|
|
120
|
+
|
|
121
|
+
if (isLoading) {
|
|
122
|
+
return (
|
|
123
|
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width, height }}>
|
|
124
|
+
<CircularProgress size={32} />
|
|
125
|
+
</Box>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (authError) {
|
|
130
|
+
return (
|
|
131
|
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width, height }}>
|
|
132
|
+
<Typography color="error">{authError}</Typography>
|
|
133
|
+
</Box>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
76
136
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
const bridgeUrl = `${mattermostOrigin}/static/autologin.html`
|
|
137
|
+
// Load Mattermost with the auth token as a cookie header via the access_token param
|
|
138
|
+
const iframeUrl = `${mattermostUrl.replace(/\/$/, '')}?access_token=${authToken}`
|
|
80
139
|
|
|
81
140
|
return (
|
|
82
|
-
<Box
|
|
83
|
-
sx={{
|
|
84
|
-
position: 'relative',
|
|
85
|
-
width,
|
|
86
|
-
height,
|
|
87
|
-
overflow: 'hidden'
|
|
88
|
-
}}
|
|
89
|
-
>
|
|
141
|
+
<Box sx={{ position: 'relative', width, height, overflow: 'hidden' }}>
|
|
90
142
|
{unreadCount > 0 && (
|
|
91
143
|
<Badge
|
|
92
144
|
badgeContent={unreadCount}
|
|
93
145
|
color="error"
|
|
94
146
|
sx={{
|
|
95
|
-
position: 'absolute',
|
|
96
|
-
|
|
97
|
-
right: 16,
|
|
98
|
-
zIndex: 1000,
|
|
99
|
-
'& .MuiBadge-badge': {
|
|
100
|
-
fontSize: '1rem',
|
|
101
|
-
height: '28px',
|
|
102
|
-
minWidth: '28px',
|
|
103
|
-
borderRadius: '14px'
|
|
104
|
-
}
|
|
147
|
+
position: 'absolute', top: 16, right: 16, zIndex: 1000,
|
|
148
|
+
'& .MuiBadge-badge': { fontSize: '1rem', height: '28px', minWidth: '28px', borderRadius: '14px' }
|
|
105
149
|
}}
|
|
106
150
|
/>
|
|
107
151
|
)}
|
|
108
152
|
<iframe
|
|
109
|
-
key={`${loginId}:${sessionToken ? '1' : '0'}`}
|
|
110
153
|
ref={iframeRef}
|
|
111
|
-
src={
|
|
154
|
+
src={iframeUrl}
|
|
112
155
|
title="Mattermost Chat"
|
|
113
|
-
allow="clipboard-read; clipboard-write
|
|
114
|
-
style={{
|
|
115
|
-
width: '100%',
|
|
116
|
-
height: '100%',
|
|
117
|
-
border: 'none',
|
|
118
|
-
display: 'block'
|
|
119
|
-
}}
|
|
156
|
+
allow="clipboard-read; clipboard-write"
|
|
157
|
+
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
|
|
120
158
|
/>
|
|
121
159
|
</Box>
|
|
122
160
|
)
|
|
@@ -6,7 +6,7 @@ import { getProfileStyles } from './Profile.styles'
|
|
|
6
6
|
import { useNavigation } from '../../NavkitContext'
|
|
7
7
|
|
|
8
8
|
const ProfileMenu = () => {
|
|
9
|
-
const { auth,
|
|
9
|
+
const { auth, themeMode, toggleTheme, userData, siteSettings } = useNavigation()
|
|
10
10
|
const styles = getProfileStyles(themeMode)
|
|
11
11
|
|
|
12
12
|
// const handlePreferences = () => {
|
|
@@ -15,15 +15,6 @@ const ProfileMenu = () => {
|
|
|
15
15
|
|
|
16
16
|
const handleLogout = async () => {
|
|
17
17
|
localStorage.removeItem('navkit-theme-mode')
|
|
18
|
-
try {
|
|
19
|
-
await fetch(chatUrl + 'api/v4/users/logout', {
|
|
20
|
-
method: 'POST',
|
|
21
|
-
headers: {
|
|
22
|
-
'X-Session-Token': client.tokenClient.getSessionToken() || '',
|
|
23
|
-
'X-Requested-With': 'XMLHttpRequest',
|
|
24
|
-
},
|
|
25
|
-
})
|
|
26
|
-
} catch { /* ignore */ }
|
|
27
18
|
await auth.logout()
|
|
28
19
|
}
|
|
29
20
|
|
|
@@ -3,14 +3,12 @@ import { TextField, InputAdornment } from '@mui/material'
|
|
|
3
3
|
import { useState } from 'react'
|
|
4
4
|
import { getSearchStyles } from './Search.styles'
|
|
5
5
|
import { useNavigation } from '../../NavkitContext'
|
|
6
|
-
import type { AppData } from '../../types'
|
|
7
6
|
|
|
8
7
|
interface SearchProps {
|
|
9
|
-
|
|
10
|
-
onSearchChange?: (filteredApps: AppData[]) => void
|
|
8
|
+
onSearchChange?: (query: string) => void
|
|
11
9
|
}
|
|
12
10
|
|
|
13
|
-
const Search = ({
|
|
11
|
+
const Search = ({ onSearchChange }: SearchProps) => {
|
|
14
12
|
const [searchTerm, setSearchTerm] = useState('')
|
|
15
13
|
const { themeMode } = useNavigation()
|
|
16
14
|
const styles = getSearchStyles(themeMode)
|
|
@@ -19,17 +17,8 @@ const Search = ({ appsList, onSearchChange }: SearchProps) => {
|
|
|
19
17
|
const value = event.target.value
|
|
20
18
|
setSearchTerm(value)
|
|
21
19
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
const filtered = value.trim() === ''
|
|
25
|
-
? appsList
|
|
26
|
-
: appsList.filter(app =>
|
|
27
|
-
app.appname.toLowerCase().includes(value.toLowerCase())
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
// Call parent callback with filtered results
|
|
31
|
-
onSearchChange?.(filtered)
|
|
32
|
-
}
|
|
20
|
+
// Report the raw query upward; filtering happens in the parent
|
|
21
|
+
onSearchChange?.(value)
|
|
33
22
|
}
|
|
34
23
|
|
|
35
24
|
return (
|