@pmate/account-sdk 0.6.0 → 0.7.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.
- package/package.json +30 -15
- package/src/api/AppService.ts +7 -7
- package/src/atoms/appConfigAtom.ts +24 -0
- package/src/atoms/index.ts +1 -0
- package/src/components/{AuthProviderV2.tsx → AuthProvider.tsx} +34 -84
- package/src/components/index.ts +1 -1
- package/src/hooks/useAppBackgroundStyle.ts +2 -2
- package/src/hooks/useAppConfig.ts +13 -6
- package/src/hooks/useProfileStepFlow.ts +28 -9
- package/src/index.ts +1 -1
- package/src/node/index.ts +271 -0
- package/src/types/app.ts +8 -2
- package/src/utils/AccountManagerV2.ts +3 -0
- package/src/utils/appTheme.ts +16 -0
- package/src/utils/index.ts +1 -0
- package/src/components/Button.tsx +0 -39
- package/src/components/Drawer.tsx +0 -80
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmate/account-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -8,38 +8,53 @@
|
|
|
8
8
|
"!src/tests/**"
|
|
9
9
|
],
|
|
10
10
|
"exports": {
|
|
11
|
-
".": "./src/index.ts"
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./node": "./src/node/index.ts"
|
|
12
13
|
},
|
|
13
14
|
"publishConfig": {
|
|
14
15
|
"access": "public"
|
|
15
16
|
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"npm:publish": "npm publish --registry https://registry.npmjs.org/",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest"
|
|
21
|
+
},
|
|
16
22
|
"peerDependencies": {
|
|
17
23
|
"jotai": "*",
|
|
18
24
|
"jotai-family": "*",
|
|
19
25
|
"react": "*"
|
|
20
26
|
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"jotai": {
|
|
29
|
+
"optional": true
|
|
30
|
+
},
|
|
31
|
+
"jotai-family": {
|
|
32
|
+
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"react": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
21
38
|
"dependencies": {
|
|
39
|
+
"@pmate/auth-widgets": "^0.1.0",
|
|
40
|
+
"@pmate/lang": "^1.0.2",
|
|
41
|
+
"@pmate/meta": "^1.1.3",
|
|
42
|
+
"@pmate/service-core": "^1.0.0",
|
|
43
|
+
"@pmate/utils": "^1.0.3",
|
|
22
44
|
"browser-image-compression": "^2.0.2",
|
|
45
|
+
"elysia": "^1.4.19",
|
|
23
46
|
"i18next": "^23.0.0",
|
|
24
47
|
"react-i18next": "^13.0.0",
|
|
25
|
-
"react-use": "^17.6.0"
|
|
26
|
-
"@pmate/lang": "1.0.2",
|
|
27
|
-
"@pmate/utils": "1.0.3",
|
|
28
|
-
"@pmate/meta": "1.1.3"
|
|
48
|
+
"react-use": "^17.6.0"
|
|
29
49
|
},
|
|
30
50
|
"devDependencies": {
|
|
31
|
-
"@types/react": "
|
|
32
|
-
"@types/react-dom": "
|
|
51
|
+
"@types/react": "catalog:",
|
|
52
|
+
"@types/react-dom": "catalog:",
|
|
33
53
|
"@vitejs/plugin-react": "^4.2.1",
|
|
34
54
|
"jsdom": "^26.1.0",
|
|
35
55
|
"react-dom": "*",
|
|
36
56
|
"typescript": "^5.2.2",
|
|
37
|
-
"vite": "
|
|
57
|
+
"vite": "catalog:",
|
|
38
58
|
"vitest": "^4.0.17"
|
|
39
|
-
},
|
|
40
|
-
"scripts": {
|
|
41
|
-
"npm:publish": "npm publish --registry https://registry.npmjs.org/",
|
|
42
|
-
"test": "vitest run",
|
|
43
|
-
"test:watch": "vitest"
|
|
44
59
|
}
|
|
45
|
-
}
|
|
60
|
+
}
|
package/src/api/AppService.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { lru } from "@pmate/utils"
|
|
2
|
-
import type { AppConfig, ProfileStep } from "../types/app"
|
|
2
|
+
import type { AppConfig, AppThemePreset, ProfileStep } from "../types/app"
|
|
3
3
|
|
|
4
|
-
const APPS_ENDPOINT =
|
|
5
|
-
|
|
4
|
+
const APPS_ENDPOINT =
|
|
5
|
+
process.env.VITE_PUBLIC_APPS_SERVER_ENDPOINT || "https://apps-api.pmate.chat"
|
|
6
6
|
const DEFAULT_ICON = "https://parrot-static.pmate.chat/parrot-logo.png"
|
|
7
|
+
const DEFAULT_THEME_PRESET: AppThemePreset = "default"
|
|
7
8
|
|
|
8
9
|
type AppRegistryRecord = {
|
|
9
10
|
id: string
|
|
10
11
|
name: string
|
|
11
12
|
icon: string
|
|
12
13
|
theme?: {
|
|
13
|
-
|
|
14
|
-
themeColor?: string
|
|
14
|
+
preset?: AppThemePreset
|
|
15
15
|
welcomeText?: string
|
|
16
16
|
}
|
|
17
17
|
profileSchema?: ProfileStep[]
|
|
@@ -26,12 +26,12 @@ const buildDisplayNameFromAppId = (appId: string): string => {
|
|
|
26
26
|
|
|
27
27
|
const toAppConfig = (record: AppRegistryRecord): AppConfig => {
|
|
28
28
|
const fallbackName = buildDisplayNameFromAppId(record.id)
|
|
29
|
+
const themePreset = record.theme?.preset || DEFAULT_THEME_PRESET
|
|
29
30
|
return {
|
|
30
31
|
id: record.id,
|
|
31
32
|
name: record.name || fallbackName,
|
|
32
33
|
icon: record.icon || DEFAULT_ICON,
|
|
33
|
-
|
|
34
|
-
themeColor: record.theme?.themeColor,
|
|
34
|
+
themePreset,
|
|
35
35
|
welcomeText: record.theme?.welcomeText || `Welcome to ${fallbackName}`,
|
|
36
36
|
profiles: record.profileSchema ?? [],
|
|
37
37
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { atom, type Atom } from "jotai"
|
|
2
|
+
import { atomFamily, type AtomFamily } from "jotai-family"
|
|
3
|
+
import { DEFAULT_APP_ID } from "../constants"
|
|
4
|
+
import { AppService } from "../api/AppService"
|
|
5
|
+
import type { AppConfig } from "../types/app"
|
|
6
|
+
import { resolveAppId } from "../utils/resolveAppId"
|
|
7
|
+
|
|
8
|
+
export const resolveAppConfigId = (app?: string | null) => {
|
|
9
|
+
return resolveAppId(app ?? DEFAULT_APP_ID)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type AppConfigAtomFamily = AtomFamily<string, Atom<Promise<AppConfig>>>
|
|
13
|
+
|
|
14
|
+
export const appConfigAtom: AppConfigAtomFamily = atomFamily((appId: string) =>
|
|
15
|
+
atom(async () => {
|
|
16
|
+
return AppService.getAppConfig(appId)
|
|
17
|
+
})
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
export const clearAppConfigAtoms = () => {
|
|
21
|
+
for (const appId of appConfigAtom.getParams()) {
|
|
22
|
+
appConfigAtom.remove(appId)
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/atoms/index.ts
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
AuthLoginPromptSheet,
|
|
3
|
+
AuthSessionErrorCard,
|
|
4
|
+
} from "@pmate/auth-widgets"
|
|
3
5
|
import { useSetAtom } from "jotai"
|
|
4
6
|
import {
|
|
5
7
|
Component,
|
|
6
8
|
PropsWithChildren,
|
|
7
9
|
useCallback,
|
|
8
10
|
useEffect,
|
|
9
|
-
useMemo,
|
|
10
11
|
useState,
|
|
11
12
|
} from "react"
|
|
12
|
-
import { useAuthSnapshot } from "../hooks/useAuthSnapshot"
|
|
13
13
|
import { accountAtom } from "../atoms/accountAtom"
|
|
14
|
-
import { AccountLifecycleState } from "../types/account.types"
|
|
15
14
|
import { userLogoutAtom } from "../atoms/userLogoutAtom"
|
|
16
|
-
import {
|
|
15
|
+
import { useAuthSnapshot } from "../hooks/useAuthSnapshot"
|
|
16
|
+
import { AccountLifecycleState } from "../types/account.types"
|
|
17
17
|
import { NotAuthenticatedError } from "../utils/errors"
|
|
18
18
|
import {
|
|
19
19
|
getWindowPathname,
|
|
20
20
|
subscribeToLocationChange,
|
|
21
21
|
} from "../utils/location"
|
|
22
|
+
import { Redirect } from "../utils/Redirect"
|
|
22
23
|
|
|
23
24
|
export type AuthRoute =
|
|
24
25
|
| string
|
|
@@ -61,8 +62,8 @@ const getAuthBehaviors = (
|
|
|
61
62
|
})
|
|
62
63
|
const authBehavior =
|
|
63
64
|
matchedAuthRoute && typeof matchedAuthRoute !== "string"
|
|
64
|
-
? (matchedAuthRoute.behavior ?? "
|
|
65
|
-
: "
|
|
65
|
+
? (matchedAuthRoute.behavior ?? "redirect")
|
|
66
|
+
: "redirect"
|
|
66
67
|
const requiresAuth = Boolean(matchedAuthRoute)
|
|
67
68
|
return {
|
|
68
69
|
authBehavior,
|
|
@@ -70,13 +71,12 @@ const getAuthBehaviors = (
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
type
|
|
74
|
+
type AuthProviderProps = PropsWithChildren<{
|
|
74
75
|
app: string
|
|
75
76
|
authRoutes?: AuthRoute[]
|
|
76
77
|
onLoginSuccess?: () => void | Promise<void>
|
|
77
|
-
rtcProvider?: React.ComponentType<{ children: React.ReactNode }>
|
|
78
78
|
pathname?: string
|
|
79
|
-
|
|
79
|
+
rtcProvider?: React.ComponentType<{ children: React.ReactNode }>
|
|
80
80
|
}>
|
|
81
81
|
|
|
82
82
|
const useWindowPathname = () => {
|
|
@@ -89,27 +89,14 @@ const useWindowPathname = () => {
|
|
|
89
89
|
return pathname
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export const
|
|
92
|
+
export const AuthProvider = ({
|
|
93
93
|
app,
|
|
94
94
|
authRoutes,
|
|
95
|
-
rtcProvider: RtcProvider,
|
|
96
|
-
pathname: pathnameProp,
|
|
97
|
-
navigate: navigateProp,
|
|
98
95
|
children,
|
|
99
|
-
|
|
96
|
+
pathname: pathnameProp,
|
|
97
|
+
rtcProvider: RtcProvider,
|
|
98
|
+
}: AuthProviderProps) => {
|
|
100
99
|
const pathname = pathnameProp ?? useWindowPathname()
|
|
101
|
-
const navigate = useMemo(() => {
|
|
102
|
-
if (navigateProp) {
|
|
103
|
-
return navigateProp
|
|
104
|
-
}
|
|
105
|
-
return (to: string, options?: { replace?: boolean }) => {
|
|
106
|
-
if (options?.replace) {
|
|
107
|
-
window.location.replace(to)
|
|
108
|
-
return
|
|
109
|
-
}
|
|
110
|
-
window.location.assign(to)
|
|
111
|
-
}
|
|
112
|
-
}, [navigateProp])
|
|
113
100
|
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false)
|
|
114
101
|
const [isLoginErrorDismissed, setIsLoginErrorDismissed] = useState(false)
|
|
115
102
|
const setAccountSnapshot = useSetAtom(accountAtom)
|
|
@@ -167,6 +154,7 @@ export const AuthProviderV2 = ({
|
|
|
167
154
|
requiresAuth && !snapshot.account && authBehavior === "prompt",
|
|
168
155
|
)
|
|
169
156
|
}, [authBehavior, loading, requiresAuth, snapshot.account])
|
|
157
|
+
|
|
170
158
|
useEffect(() => {
|
|
171
159
|
if (!loginError) {
|
|
172
160
|
setIsLoginErrorDismissed(false)
|
|
@@ -177,9 +165,7 @@ export const AuthProviderV2 = ({
|
|
|
177
165
|
setIsLoginPromptOpen(false)
|
|
178
166
|
if (window.history.length > 1) {
|
|
179
167
|
window.history.back()
|
|
180
|
-
return
|
|
181
168
|
}
|
|
182
|
-
// window.location.href = "/"
|
|
183
169
|
}
|
|
184
170
|
|
|
185
171
|
if (loading && requiresAuth) {
|
|
@@ -187,26 +173,12 @@ export const AuthProviderV2 = ({
|
|
|
187
173
|
}
|
|
188
174
|
if (loginError && requiresAuth && !isLoginErrorDismissed) {
|
|
189
175
|
return (
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
We could not restore your session. Please try again.
|
|
197
|
-
</p>
|
|
198
|
-
<div className="mt-3 text-xs text-rose-500">{loginError.message}</div>
|
|
199
|
-
<div className="mt-4 flex flex-wrap gap-3">
|
|
200
|
-
<button
|
|
201
|
-
className="rounded-md bg-rose-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-rose-700"
|
|
202
|
-
type="button"
|
|
203
|
-
onClick={() => window.location.reload()}
|
|
204
|
-
>
|
|
205
|
-
Reload
|
|
206
|
-
</button>
|
|
207
|
-
</div>
|
|
208
|
-
</div>
|
|
209
|
-
</div>
|
|
176
|
+
<AuthSessionErrorCard
|
|
177
|
+
title="Login failed"
|
|
178
|
+
description="We could not restore your session. Please try again."
|
|
179
|
+
message={loginError.message}
|
|
180
|
+
onAction={() => window.location.reload()}
|
|
181
|
+
/>
|
|
210
182
|
)
|
|
211
183
|
}
|
|
212
184
|
if (requiresAuth && hasAccount === null) {
|
|
@@ -214,36 +186,12 @@ export const AuthProviderV2 = ({
|
|
|
214
186
|
}
|
|
215
187
|
if (requiresAuth && hasAccount === false && authBehavior === "prompt") {
|
|
216
188
|
return (
|
|
217
|
-
<
|
|
189
|
+
<AuthLoginPromptSheet
|
|
218
190
|
open={isLoginPromptOpen}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
<div className="rounded-t-2xl px-6 pb-6 pt-4">
|
|
224
|
-
<div className="mx-auto mb-3 h-1.5 w-12 rounded-full bg-slate-200" />
|
|
225
|
-
<div className="text-lg font-semibold text-slate-900">
|
|
226
|
-
You need login to continue ?
|
|
227
|
-
</div>
|
|
228
|
-
<div className="mt-5 flex items-center justify-end gap-3">
|
|
229
|
-
<Button
|
|
230
|
-
type="button"
|
|
231
|
-
variant="plain"
|
|
232
|
-
className="min-w-[96px] justify-center"
|
|
233
|
-
onClick={handleBack}
|
|
234
|
-
>
|
|
235
|
-
Back
|
|
236
|
-
</Button>
|
|
237
|
-
<Button
|
|
238
|
-
type="button"
|
|
239
|
-
className="min-w-[96px] justify-center"
|
|
240
|
-
onClick={() => Redirect.toLogin(app)}
|
|
241
|
-
>
|
|
242
|
-
Login
|
|
243
|
-
</Button>
|
|
244
|
-
</div>
|
|
245
|
-
</div>
|
|
246
|
-
</Drawer>
|
|
191
|
+
onBack={handleBack}
|
|
192
|
+
onLogin={() => Redirect.toLogin(app)}
|
|
193
|
+
title="You need login to continue ?"
|
|
194
|
+
/>
|
|
247
195
|
)
|
|
248
196
|
}
|
|
249
197
|
if (!requiresAuth) {
|
|
@@ -251,12 +199,14 @@ export const AuthProviderV2 = ({
|
|
|
251
199
|
}
|
|
252
200
|
const RtcWrapper = RtcProvider ?? (({ children }) => <>{children}</>)
|
|
253
201
|
return (
|
|
254
|
-
<AuthErrorBoundary
|
|
202
|
+
<AuthErrorBoundary app={app}>
|
|
255
203
|
<RtcWrapper>{children}</RtcWrapper>
|
|
256
204
|
</AuthErrorBoundary>
|
|
257
205
|
)
|
|
258
206
|
}
|
|
259
207
|
|
|
208
|
+
export const AuthProviderV2 = AuthProvider
|
|
209
|
+
|
|
260
210
|
interface AuthErrorBoundaryState {
|
|
261
211
|
hasError: boolean
|
|
262
212
|
}
|
|
@@ -296,16 +246,16 @@ class AuthErrorBoundaryInner extends Component<
|
|
|
296
246
|
}
|
|
297
247
|
|
|
298
248
|
const AuthErrorBoundary = ({
|
|
249
|
+
app,
|
|
299
250
|
children,
|
|
300
|
-
navigate,
|
|
301
251
|
}: PropsWithChildren<{
|
|
302
|
-
|
|
252
|
+
app: string
|
|
303
253
|
}>) => {
|
|
304
254
|
const logout = useSetAtom(userLogoutAtom)
|
|
305
255
|
const handleAuthError = useCallback(async () => {
|
|
306
256
|
await logout()
|
|
307
|
-
|
|
308
|
-
}, [
|
|
257
|
+
Redirect.toLogin(app)
|
|
258
|
+
}, [app, logout])
|
|
309
259
|
|
|
310
260
|
return (
|
|
311
261
|
<AuthErrorBoundaryInner onAuthError={handleAuthError}>
|
package/src/components/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "./
|
|
1
|
+
export * from "./AuthProvider"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from "react"
|
|
2
2
|
import { getWindowSearch, subscribeToLocationChange } from "../utils/location"
|
|
3
|
+
import { getAppThemeBackground } from "../utils/appTheme"
|
|
3
4
|
import { useAppConfig } from "./useAppConfig"
|
|
4
5
|
|
|
5
6
|
export const useAppBackgroundStyle = () => {
|
|
@@ -17,8 +18,7 @@ export const useAppBackgroundStyle = () => {
|
|
|
17
18
|
|
|
18
19
|
return useMemo(
|
|
19
20
|
() => ({
|
|
20
|
-
background:
|
|
21
|
-
appConfig?.background || "linear-gradient(180deg, #9ca3af 0%, #6b7280 100%)",
|
|
21
|
+
background: getAppThemeBackground(appConfig?.themePreset),
|
|
22
22
|
}),
|
|
23
23
|
[appConfig]
|
|
24
24
|
)
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from "react"
|
|
2
|
+
import { resolveAppConfigId } from "../atoms/appConfigAtom"
|
|
2
3
|
import { AppService } from "../api/AppService"
|
|
3
|
-
import { DEFAULT_APP_ID } from "../constants"
|
|
4
4
|
import type { AppConfig } from "../types/app"
|
|
5
|
-
import { resolveAppId } from "../utils/resolveAppId"
|
|
6
5
|
|
|
7
6
|
type AppConfigState = {
|
|
8
7
|
appConfig: AppConfig | null
|
|
@@ -11,7 +10,7 @@ type AppConfigState = {
|
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
export const useAppConfig = (app?: string | null): AppConfigState => {
|
|
14
|
-
const resolvedApp =
|
|
13
|
+
const resolvedApp = resolveAppConfigId(app)
|
|
15
14
|
const [state, setState] = useState<AppConfigState>({
|
|
16
15
|
appConfig: null,
|
|
17
16
|
isLoading: true,
|
|
@@ -21,17 +20,25 @@ export const useAppConfig = (app?: string | null): AppConfigState => {
|
|
|
21
20
|
useEffect(() => {
|
|
22
21
|
let active = true
|
|
23
22
|
setState({ appConfig: null, isLoading: true, error: null })
|
|
23
|
+
|
|
24
24
|
AppService.getAppConfig(resolvedApp)
|
|
25
25
|
.then((appConfig) => {
|
|
26
|
-
if (!active)
|
|
26
|
+
if (!active) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
27
29
|
setState({ appConfig, isLoading: false, error: null })
|
|
28
30
|
})
|
|
29
31
|
.catch((error: unknown) => {
|
|
30
|
-
if (!active)
|
|
32
|
+
if (!active) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
31
35
|
setState({
|
|
32
36
|
appConfig: null,
|
|
33
37
|
isLoading: false,
|
|
34
|
-
error:
|
|
38
|
+
error:
|
|
39
|
+
error instanceof Error
|
|
40
|
+
? error
|
|
41
|
+
: new Error("Failed to load app config"),
|
|
35
42
|
})
|
|
36
43
|
})
|
|
37
44
|
|
|
@@ -2,8 +2,6 @@ import { useCallback, useMemo } from "react"
|
|
|
2
2
|
import { isProfileStepType, ProfileStepType } from "../utils/profileStep"
|
|
3
3
|
import { useAppConfig } from "./useAppConfig"
|
|
4
4
|
|
|
5
|
-
const DEFAULT_CREATE_STEP: ProfileStepType = "learning-language"
|
|
6
|
-
|
|
7
5
|
type UseProfileStepFlowParams = {
|
|
8
6
|
params: URLSearchParams
|
|
9
7
|
}
|
|
@@ -13,7 +11,13 @@ export const useProfileStepFlow = ({
|
|
|
13
11
|
}: UseProfileStepFlowParams) => {
|
|
14
12
|
const appParam = params.get("app")
|
|
15
13
|
const redirectParam = params.get("redirect")
|
|
16
|
-
const { appConfig } = useAppConfig(appParam)
|
|
14
|
+
const { appConfig, error, isLoading } = useAppConfig(appParam)
|
|
15
|
+
const shouldBlockStep = Boolean(appParam) && isLoading && !appConfig && !error
|
|
16
|
+
|
|
17
|
+
// The app registry decides the canonical order of profile steps.
|
|
18
|
+
// When the config is still loading for an explicit app, we intentionally
|
|
19
|
+
// block the create flow instead of briefly falling back to a synthetic
|
|
20
|
+
// default step that does not exist in the app schema.
|
|
17
21
|
const appProfileSteps = useMemo<ProfileStepType[]>(
|
|
18
22
|
() =>
|
|
19
23
|
(appConfig?.profiles ?? [])
|
|
@@ -24,15 +28,27 @@ export const useProfileStepFlow = ({
|
|
|
24
28
|
const stepParam = params.get("step")
|
|
25
29
|
const normalizedStep: ProfileStepType | null =
|
|
26
30
|
appProfileSteps.find((item) => item === stepParam) ?? null
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
|
|
32
|
+
// step=... wins when it is valid for the current app.
|
|
33
|
+
// Otherwise we use the first configured step. If the app does not declare
|
|
34
|
+
// any profile schema, create-profile should stay unavailable instead of
|
|
35
|
+
// inventing a fallback step.
|
|
36
|
+
const defaultStep: ProfileStepType | null = shouldBlockStep
|
|
37
|
+
? null
|
|
38
|
+
: (appProfileSteps[0] ?? null)
|
|
39
|
+
const activeStep: ProfileStepType | null = normalizedStep ?? defaultStep
|
|
40
|
+
|
|
41
|
+
// createSteps stays empty while we intentionally block rendering.
|
|
42
|
+
// Once app config is ready, it mirrors the registry schema order exactly.
|
|
29
43
|
const createSteps = useMemo<ProfileStepType[]>(() => {
|
|
30
|
-
return
|
|
31
|
-
}, [appProfileSteps])
|
|
32
|
-
const currentStepIndex =
|
|
44
|
+
return shouldBlockStep ? [] : appProfileSteps
|
|
45
|
+
}, [appProfileSteps, shouldBlockStep])
|
|
46
|
+
const currentStepIndex =
|
|
47
|
+
activeStep === null ? -1 : createSteps.indexOf(activeStep)
|
|
33
48
|
const nextStep =
|
|
34
49
|
currentStepIndex >= 0 ? createSteps[currentStepIndex + 1] : undefined
|
|
35
|
-
const isCreateFlowStep =
|
|
50
|
+
const isCreateFlowStep =
|
|
51
|
+
activeStep === null ? false : createSteps.includes(activeStep)
|
|
36
52
|
const buildStepUrl = useCallback(
|
|
37
53
|
(next: ProfileStepType) => {
|
|
38
54
|
const search = new URLSearchParams()
|
|
@@ -53,7 +69,10 @@ export const useProfileStepFlow = ({
|
|
|
53
69
|
appProfileSteps,
|
|
54
70
|
buildStepUrl,
|
|
55
71
|
createSteps,
|
|
72
|
+
error,
|
|
73
|
+
isLoading,
|
|
56
74
|
isCreateFlowStep,
|
|
75
|
+
isReady: activeStep !== null,
|
|
57
76
|
nextStep,
|
|
58
77
|
}
|
|
59
78
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ export * from "./atoms"
|
|
|
2
2
|
export * from "./components"
|
|
3
3
|
export * from "./i18n"
|
|
4
4
|
export { DEFAULT_APP_ID } from "./constants"
|
|
5
|
-
export type { AppConfig, ProfileStep } from "./types/app"
|
|
5
|
+
export type { AppConfig, AppThemePreset, ProfileStep } from "./types/app"
|
|
6
6
|
export * from "./hooks"
|
|
7
7
|
export * from "./utils"
|
|
8
8
|
export * from "./api"
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccountViewResult,
|
|
3
|
+
AuthSession,
|
|
4
|
+
AuthzCheckResult,
|
|
5
|
+
RoleBinding,
|
|
6
|
+
ServerResponse,
|
|
7
|
+
} from "@pmate/meta"
|
|
8
|
+
import { BizErrorCode } from "@pmate/meta"
|
|
9
|
+
import { ServiceError } from "@pmate/service-core"
|
|
10
|
+
import { Elysia } from "elysia"
|
|
11
|
+
|
|
12
|
+
export type AuthenticatedAccount = {
|
|
13
|
+
token: string
|
|
14
|
+
accountId: string
|
|
15
|
+
session: AuthSession
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type AuthClientLike = {
|
|
19
|
+
authenticateRequest(request: Request): Promise<AuthenticatedAccount>
|
|
20
|
+
checkAuthorization(
|
|
21
|
+
request: Request,
|
|
22
|
+
input: { account?: string; namespace: string; roles: string[] }
|
|
23
|
+
): Promise<AuthzCheckResult>
|
|
24
|
+
getAccountView(
|
|
25
|
+
request: Request,
|
|
26
|
+
query: { account?: string; mobile?: string; nickName?: string }
|
|
27
|
+
): Promise<AccountViewResult>
|
|
28
|
+
listRoleBindings(
|
|
29
|
+
request: Request,
|
|
30
|
+
query: { account?: string; namespace?: string }
|
|
31
|
+
): Promise<RoleBinding[]>
|
|
32
|
+
createRoleBinding(
|
|
33
|
+
request: Request,
|
|
34
|
+
input: { account: string; namespace: string; role: string; source?: string }
|
|
35
|
+
): Promise<RoleBinding>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class AuthClient implements AuthClientLike {
|
|
39
|
+
private readonly authApiBaseUrl: string
|
|
40
|
+
private readonly fetchImpl: typeof fetch
|
|
41
|
+
|
|
42
|
+
constructor(options: { authApiBaseUrl?: string; fetchImpl?: typeof fetch } = {}) {
|
|
43
|
+
this.authApiBaseUrl = (
|
|
44
|
+
options.authApiBaseUrl ||
|
|
45
|
+
process.env.AUTH_API_BASE_URL ||
|
|
46
|
+
"https://auth-api-v2.pmate.chat"
|
|
47
|
+
).replace(/\/+$/, "")
|
|
48
|
+
this.fetchImpl = options.fetchImpl ?? fetch
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async authenticateRequest(request: Request): Promise<AuthenticatedAccount> {
|
|
52
|
+
const token = extractBearerToken(request.headers.get("authorization"))
|
|
53
|
+
const session = await authRequest<AuthSession | null>(
|
|
54
|
+
this.fetchImpl,
|
|
55
|
+
this.authApiBaseUrl,
|
|
56
|
+
"/session",
|
|
57
|
+
{
|
|
58
|
+
method: "GET",
|
|
59
|
+
token,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
if (!session?.identity?.accountId) {
|
|
63
|
+
throw new ServiceError("Unauthorized", 401, BizErrorCode.AUTH_ERROR)
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
token,
|
|
67
|
+
accountId: session.identity.accountId,
|
|
68
|
+
session,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async checkAuthorization(
|
|
73
|
+
request: Request,
|
|
74
|
+
input: { account?: string; namespace: string; roles: string[] }
|
|
75
|
+
) {
|
|
76
|
+
const auth = await this.authenticateRequest(request)
|
|
77
|
+
return authRequest<AuthzCheckResult>(
|
|
78
|
+
this.fetchImpl,
|
|
79
|
+
this.authApiBaseUrl,
|
|
80
|
+
"/authz/check",
|
|
81
|
+
{
|
|
82
|
+
method: "POST",
|
|
83
|
+
token: auth.token,
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
account: input.account ?? auth.accountId,
|
|
86
|
+
namespace: input.namespace,
|
|
87
|
+
roles: input.roles,
|
|
88
|
+
}),
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getAccountView(
|
|
94
|
+
request: Request,
|
|
95
|
+
query: { account?: string; mobile?: string; nickName?: string }
|
|
96
|
+
) {
|
|
97
|
+
const auth = await this.authenticateRequest(request)
|
|
98
|
+
const url = new URL(`${this.authApiBaseUrl}/accounts/view`)
|
|
99
|
+
if (query.account) url.searchParams.set("account", query.account)
|
|
100
|
+
if (query.mobile) url.searchParams.set("mobile", query.mobile)
|
|
101
|
+
if (query.nickName) url.searchParams.set("nickName", query.nickName)
|
|
102
|
+
return authRequest<AccountViewResult>(this.fetchImpl, this.authApiBaseUrl, url, {
|
|
103
|
+
method: "GET",
|
|
104
|
+
token: auth.token,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async listRoleBindings(
|
|
109
|
+
request: Request,
|
|
110
|
+
query: { account?: string; namespace?: string }
|
|
111
|
+
) {
|
|
112
|
+
const auth = await this.authenticateRequest(request)
|
|
113
|
+
const url = new URL(`${this.authApiBaseUrl}/role/bindings`)
|
|
114
|
+
if (query.account) url.searchParams.set("account", query.account)
|
|
115
|
+
if (query.namespace) url.searchParams.set("namespace", query.namespace)
|
|
116
|
+
return authRequest<RoleBinding[]>(this.fetchImpl, this.authApiBaseUrl, url, {
|
|
117
|
+
method: "GET",
|
|
118
|
+
token: auth.token,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async createRoleBinding(
|
|
123
|
+
request: Request,
|
|
124
|
+
input: { account: string; namespace: string; role: string; source?: string }
|
|
125
|
+
) {
|
|
126
|
+
const auth = await this.authenticateRequest(request)
|
|
127
|
+
return authRequest<RoleBinding>(
|
|
128
|
+
this.fetchImpl,
|
|
129
|
+
this.authApiBaseUrl,
|
|
130
|
+
"/role/bindings",
|
|
131
|
+
{
|
|
132
|
+
method: "POST",
|
|
133
|
+
token: auth.token,
|
|
134
|
+
body: JSON.stringify(input),
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createAccountAuthPlugin(options: {
|
|
141
|
+
authApiBaseUrl?: string
|
|
142
|
+
fetchImpl?: typeof fetch
|
|
143
|
+
} = {}) {
|
|
144
|
+
const client = new AuthClient(options)
|
|
145
|
+
return new Elysia({ name: "pmate-account-auth" }).decorate(
|
|
146
|
+
"accountAuthClient",
|
|
147
|
+
client
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function requireAuth(
|
|
152
|
+
client: AuthClientLike,
|
|
153
|
+
options: {
|
|
154
|
+
namespace?:
|
|
155
|
+
| string
|
|
156
|
+
| ((context: {
|
|
157
|
+
request: Request
|
|
158
|
+
params?: Record<string, string | undefined>
|
|
159
|
+
query?: Record<string, unknown>
|
|
160
|
+
body?: unknown
|
|
161
|
+
}) => string | Promise<string>)
|
|
162
|
+
roles?: string[]
|
|
163
|
+
} = {}
|
|
164
|
+
) {
|
|
165
|
+
return {
|
|
166
|
+
beforeHandle: async (context: {
|
|
167
|
+
request: Request
|
|
168
|
+
params?: Record<string, string | undefined>
|
|
169
|
+
query?: Record<string, unknown>
|
|
170
|
+
body?: unknown
|
|
171
|
+
}) => {
|
|
172
|
+
if (context.request.method.toUpperCase() === "OPTIONS") {
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const auth = await client.authenticateRequest(context.request)
|
|
177
|
+
let namespace: string | undefined
|
|
178
|
+
let matchedRole: string | undefined
|
|
179
|
+
if (options.roles?.length) {
|
|
180
|
+
namespace =
|
|
181
|
+
typeof options.namespace === "function"
|
|
182
|
+
? await options.namespace(context)
|
|
183
|
+
: options.namespace
|
|
184
|
+
if (!namespace) {
|
|
185
|
+
throw new ServiceError(
|
|
186
|
+
"Namespace is required for role authorization",
|
|
187
|
+
500,
|
|
188
|
+
BizErrorCode.AUTH_ERROR
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
const result = await client.checkAuthorization(context.request, {
|
|
192
|
+
account: auth.accountId,
|
|
193
|
+
namespace,
|
|
194
|
+
roles: options.roles,
|
|
195
|
+
})
|
|
196
|
+
if (!result.allowed) {
|
|
197
|
+
throw new ServiceError("Forbidden", 403, BizErrorCode.AUTH_ERROR)
|
|
198
|
+
}
|
|
199
|
+
matchedRole = result.matchedRole
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const authContext = {
|
|
203
|
+
...auth,
|
|
204
|
+
...(namespace ? { namespace } : {}),
|
|
205
|
+
...(matchedRole ? { matchedRole } : {}),
|
|
206
|
+
}
|
|
207
|
+
;(context as Record<string, unknown>).auth = authContext
|
|
208
|
+
;(context.request as Request & { auth?: typeof authContext }).auth =
|
|
209
|
+
authContext
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
type AuthRequestInit = RequestInit & { token?: string }
|
|
215
|
+
|
|
216
|
+
async function authRequest<T>(
|
|
217
|
+
fetchImpl: typeof fetch,
|
|
218
|
+
authApiBaseUrl: string,
|
|
219
|
+
path: string | URL,
|
|
220
|
+
init: AuthRequestInit
|
|
221
|
+
): Promise<T> {
|
|
222
|
+
const { token, headers, ...rest } = init
|
|
223
|
+
const url =
|
|
224
|
+
path instanceof URL
|
|
225
|
+
? path.toString()
|
|
226
|
+
: `${authApiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`
|
|
227
|
+
const response = await fetchImpl(url, {
|
|
228
|
+
...rest,
|
|
229
|
+
headers: {
|
|
230
|
+
"Content-Type": "application/json",
|
|
231
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
232
|
+
...(headers ?? {}),
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
let json: ServerResponse<T> | null = null
|
|
237
|
+
try {
|
|
238
|
+
json = (await response.json()) as ServerResponse<T>
|
|
239
|
+
} catch {
|
|
240
|
+
json = null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!response.ok || !json?.success) {
|
|
244
|
+
throw new ServiceError(
|
|
245
|
+
json?.message || response.statusText || "Request failed",
|
|
246
|
+
response.status || 500,
|
|
247
|
+
BizErrorCode.AUTH_ERROR
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return json.data
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function extractBearerToken(header: string | null) {
|
|
255
|
+
if (!header) {
|
|
256
|
+
throw new ServiceError(
|
|
257
|
+
"Missing authorization header",
|
|
258
|
+
401,
|
|
259
|
+
BizErrorCode.AUTH_ERROR
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
const match = header.match(/^Bearer\s+(.+)$/i)
|
|
263
|
+
if (!match) {
|
|
264
|
+
throw new ServiceError(
|
|
265
|
+
"Invalid authorization header",
|
|
266
|
+
401,
|
|
267
|
+
BizErrorCode.AUTH_ERROR
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
return match[1].trim()
|
|
271
|
+
}
|
package/src/types/app.ts
CHANGED
|
@@ -11,12 +11,18 @@ export type ProfileStep = {
|
|
|
11
11
|
required: boolean
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export type AppThemePreset =
|
|
15
|
+
| "default"
|
|
16
|
+
| "chat"
|
|
17
|
+
| "parrotmate"
|
|
18
|
+
| "sunrise"
|
|
19
|
+
| "graphite"
|
|
20
|
+
|
|
14
21
|
export interface AppConfig {
|
|
15
22
|
id: string
|
|
16
23
|
name: string
|
|
17
24
|
icon: string
|
|
18
|
-
|
|
19
|
-
themeColor?: string
|
|
25
|
+
themePreset: AppThemePreset
|
|
20
26
|
welcomeText: string
|
|
21
27
|
profiles: ProfileStep[]
|
|
22
28
|
}
|
|
@@ -216,6 +216,9 @@ export class AccountManagerV2 extends EmitterV2<AccountManagerEventMap> {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
public clearSelectedProfile(): void {
|
|
219
|
+
if (!getSelectedProfileId(this.app)) {
|
|
220
|
+
return
|
|
221
|
+
}
|
|
219
222
|
clearSelectedProfileId(this.app)
|
|
220
223
|
this.emit(AccountManagerEvent.StateChange)
|
|
221
224
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AppThemePreset } from "../types/app"
|
|
2
|
+
|
|
3
|
+
const DEFAULT_THEME_PRESET: AppThemePreset = "default"
|
|
4
|
+
|
|
5
|
+
const THEME_BACKGROUNDS: Record<AppThemePreset, string> = {
|
|
6
|
+
default: "linear-gradient(180deg, #9ca3af 0%, #6b7280 100%)",
|
|
7
|
+
chat: "linear-gradient(180deg, #0f766e 0%, #115e59 100%)",
|
|
8
|
+
parrotmate: "linear-gradient(180deg, #5b4cf0 0%, #4537d2 100%)",
|
|
9
|
+
sunrise: "linear-gradient(180deg, #f59e0b 0%, #ea580c 100%)",
|
|
10
|
+
graphite: "linear-gradient(180deg, #111827 0%, #1f2937 100%)",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const getAppThemeBackground = (themePreset?: AppThemePreset) => {
|
|
14
|
+
const preset = themePreset ?? DEFAULT_THEME_PRESET
|
|
15
|
+
return THEME_BACKGROUNDS[preset] ?? THEME_BACKGROUNDS[DEFAULT_THEME_PRESET]
|
|
16
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import React from "react"
|
|
2
|
-
|
|
3
|
-
type ButtonVariant = "primary" | "plain"
|
|
4
|
-
|
|
5
|
-
export interface ButtonProps
|
|
6
|
-
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
7
|
-
variant?: ButtonVariant
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const joinClassNames = (...parts: Array<string | false | null | undefined>) =>
|
|
11
|
-
parts.filter(Boolean).join(" ")
|
|
12
|
-
|
|
13
|
-
const baseClassName =
|
|
14
|
-
"inline-flex items-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors"
|
|
15
|
-
|
|
16
|
-
const variantClassName: Record<ButtonVariant, string> = {
|
|
17
|
-
primary: "bg-slate-900 text-white hover:bg-slate-800",
|
|
18
|
-
plain: "border border-slate-200 bg-white text-slate-700 hover:bg-slate-50",
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export const Button = ({
|
|
22
|
-
variant = "primary",
|
|
23
|
-
className,
|
|
24
|
-
disabled,
|
|
25
|
-
...props
|
|
26
|
-
}: ButtonProps) => {
|
|
27
|
-
return (
|
|
28
|
-
<button
|
|
29
|
-
{...props}
|
|
30
|
-
disabled={disabled}
|
|
31
|
-
className={joinClassNames(
|
|
32
|
-
baseClassName,
|
|
33
|
-
variantClassName[variant],
|
|
34
|
-
disabled && "cursor-not-allowed opacity-60",
|
|
35
|
-
className,
|
|
36
|
-
)}
|
|
37
|
-
/>
|
|
38
|
-
)
|
|
39
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from "react"
|
|
2
|
-
|
|
3
|
-
type DrawerAnchor = "left" | "right" | "top" | "bottom"
|
|
4
|
-
|
|
5
|
-
export interface DrawerProps {
|
|
6
|
-
open: boolean
|
|
7
|
-
onClose: () => void
|
|
8
|
-
children: React.ReactNode
|
|
9
|
-
anchor?: DrawerAnchor
|
|
10
|
-
className?: string
|
|
11
|
-
overlayClassName?: string
|
|
12
|
-
id?: string
|
|
13
|
-
style?: React.CSSProperties
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const joinClassNames = (...parts: Array<string | false | null | undefined>) =>
|
|
17
|
-
parts.filter(Boolean).join(" ")
|
|
18
|
-
|
|
19
|
-
const positionClassMap: Record<DrawerAnchor, string> = {
|
|
20
|
-
left: "left-0 top-0 h-full",
|
|
21
|
-
right: "right-0 top-0 h-full",
|
|
22
|
-
top: "left-0 top-0 w-full",
|
|
23
|
-
bottom: "bottom-0 left-0 w-full",
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const hiddenTransformMap: Record<DrawerAnchor, string> = {
|
|
27
|
-
left: "-translate-x-full",
|
|
28
|
-
right: "translate-x-full",
|
|
29
|
-
top: "-translate-y-full",
|
|
30
|
-
bottom: "translate-y-full",
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export const Drawer = ({
|
|
34
|
-
open,
|
|
35
|
-
onClose,
|
|
36
|
-
children,
|
|
37
|
-
anchor = "right",
|
|
38
|
-
className,
|
|
39
|
-
overlayClassName,
|
|
40
|
-
id,
|
|
41
|
-
style,
|
|
42
|
-
}: DrawerProps) => {
|
|
43
|
-
const [mounted, setMounted] = useState(open)
|
|
44
|
-
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
if (open) {
|
|
47
|
-
setMounted(true)
|
|
48
|
-
}
|
|
49
|
-
}, [open])
|
|
50
|
-
|
|
51
|
-
const handleTransitionEnd = () => {
|
|
52
|
-
if (!open) {
|
|
53
|
-
setMounted(false)
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (!mounted) return null
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<div
|
|
61
|
-
id={id}
|
|
62
|
-
className={joinClassNames("fixed inset-0 z-[1002]", overlayClassName)}
|
|
63
|
-
onClick={onClose}
|
|
64
|
-
>
|
|
65
|
-
<div
|
|
66
|
-
className={joinClassNames(
|
|
67
|
-
"absolute bg-white transition-transform duration-300",
|
|
68
|
-
positionClassMap[anchor],
|
|
69
|
-
open ? "translate-x-0 translate-y-0" : hiddenTransformMap[anchor],
|
|
70
|
-
className,
|
|
71
|
-
)}
|
|
72
|
-
style={style}
|
|
73
|
-
onClick={(event) => event.stopPropagation()}
|
|
74
|
-
onTransitionEnd={handleTransitionEnd}
|
|
75
|
-
>
|
|
76
|
-
{children}
|
|
77
|
-
</div>
|
|
78
|
-
</div>
|
|
79
|
-
)
|
|
80
|
-
}
|