@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/account-sdk",
3
- "version": "0.6.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": "^7.3.1",
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
+ }
@@ -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 = "https://apps-api.pmate.chat"
5
- const DEFAULT_BACKGROUND = "linear-gradient(180deg, #9ca3af 0%, #6b7280 100%)"
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
- background?: string
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
- background: record.theme?.background || DEFAULT_BACKGROUND,
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
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./uploadAvatarAtom"
2
+ export * from "./appConfigAtom"
2
3
  export * from "./accountAtom"
3
4
  export * from "./accountProfileAtom"
4
5
  export * from "./switchProfileAtom"
@@ -1,24 +1,25 @@
1
- import { Button } from "./Button"
2
- import { Drawer } from "./Drawer"
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 { Redirect } from "../utils/Redirect"
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 ?? "prompt")
65
- : "prompt"
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 AuthProviderV2Props = PropsWithChildren<{
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
- navigate?: (to: string, options?: { replace?: boolean }) => void
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 AuthProviderV2 = ({
92
+ export const AuthProvider = ({
93
93
  app,
94
94
  authRoutes,
95
- rtcProvider: RtcProvider,
96
- pathname: pathnameProp,
97
- navigate: navigateProp,
98
95
  children,
99
- }: AuthProviderV2Props) => {
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
- <div className="flex min-h-screen items-center justify-center bg-slate-50 p-6">
191
- <div className="w-full max-w-md rounded-xl border border-rose-200 bg-white p-6 shadow-sm">
192
- <div className="text-sm font-semibold text-rose-600">
193
- Login failed
194
- </div>
195
- <p className="mt-2 text-sm text-slate-600">
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
- <Drawer
189
+ <AuthLoginPromptSheet
218
190
  open={isLoginPromptOpen}
219
- onClose={handleBack}
220
- anchor="bottom"
221
- overlayClassName="bg-black/40"
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 navigate={navigate}>
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
- navigate: (to: string, options?: { replace?: boolean }) => void
252
+ app: string
303
253
  }>) => {
304
254
  const logout = useSetAtom(userLogoutAtom)
305
255
  const handleAuthError = useCallback(async () => {
306
256
  await logout()
307
- navigate("/login", { replace: true })
308
- }, [logout, navigate])
257
+ Redirect.toLogin(app)
258
+ }, [app, logout])
309
259
 
310
260
  return (
311
261
  <AuthErrorBoundaryInner onAuthError={handleAuthError}>
@@ -1 +1 @@
1
- export * from "./AuthProviderV2"
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 = resolveAppId(app ?? DEFAULT_APP_ID)
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) return
26
+ if (!active) {
27
+ return
28
+ }
27
29
  setState({ appConfig, isLoading: false, error: null })
28
30
  })
29
31
  .catch((error: unknown) => {
30
- if (!active) return
32
+ if (!active) {
33
+ return
34
+ }
31
35
  setState({
32
36
  appConfig: null,
33
37
  isLoading: false,
34
- error: error instanceof Error ? error : new Error("Failed to load app config"),
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
- const defaultStep: ProfileStepType = appProfileSteps[0] ?? DEFAULT_CREATE_STEP
28
- const activeStep: ProfileStepType = normalizedStep ?? defaultStep
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 appProfileSteps.length > 0 ? appProfileSteps : [DEFAULT_CREATE_STEP]
31
- }, [appProfileSteps])
32
- const currentStepIndex = createSteps.indexOf(activeStep)
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 = createSteps.includes(activeStep)
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
- background: string
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
+ }
@@ -9,3 +9,4 @@ export * from "./resolveAppId"
9
9
  export * from "./errors"
10
10
  export * from "./profileStep"
11
11
  export * from "./Redirect"
12
+ export * from "./appTheme"
@@ -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
- }