@pmate/account-sdk 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +18 -20
  2. package/src/api/AccountService.ts +97 -0
  3. package/src/api/Api.ts +99 -0
  4. package/src/api/AppService.ts +71 -0
  5. package/src/api/EntityService.ts +41 -0
  6. package/src/api/ProfileService.ts +133 -0
  7. package/src/api/cacheInMem.ts +73 -0
  8. package/src/api/index.ts +5 -0
  9. package/src/atoms/accountAtom.ts +18 -0
  10. package/src/atoms/accountProfileAtom.ts +13 -0
  11. package/src/atoms/atomWithLoadable.ts +48 -0
  12. package/src/atoms/createProfileAtom.ts +27 -0
  13. package/src/atoms/index.ts +16 -0
  14. package/src/atoms/learningLangAtom.ts +8 -0
  15. package/src/atoms/localStorageAtom.ts +39 -0
  16. package/src/atoms/loginAtom.ts +8 -0
  17. package/src/atoms/motherTongueAtom.ts +8 -0
  18. package/src/atoms/profileAtom.ts +24 -0
  19. package/src/atoms/profileDraftAtom.ts +7 -0
  20. package/src/atoms/profilesAtom.ts +20 -0
  21. package/src/atoms/sessionCheckAtom.ts +10 -0
  22. package/src/atoms/switchProfileAtom.ts +9 -0
  23. package/src/atoms/updateProfileAtom.ts +35 -0
  24. package/src/atoms/uploadAvatarAtom.ts +49 -0
  25. package/src/atoms/userLogoutAtom.ts +8 -0
  26. package/src/atoms/userSettingsAtom.ts +58 -0
  27. package/src/components/AuthProviderV2.tsx +315 -0
  28. package/src/components/Button.tsx +39 -0
  29. package/src/components/Drawer.tsx +80 -0
  30. package/src/components/index.ts +1 -0
  31. package/src/constants.ts +1 -0
  32. package/src/hooks/index.ts +5 -0
  33. package/src/hooks/useAppBackgroundStyle.ts +25 -0
  34. package/src/hooks/useAppConfig.ts +44 -0
  35. package/src/hooks/useAuthApp.ts +165 -0
  36. package/src/hooks/useAuthSnapshot.ts +84 -0
  37. package/src/hooks/useIsAuthenticated.ts +19 -0
  38. package/src/hooks/useProfileStepFlow.ts +59 -0
  39. package/src/i18n/index.ts +59 -0
  40. package/src/index.ts +9 -0
  41. package/src/locales/ar-SA.json +183 -0
  42. package/src/locales/de-DE.json +183 -0
  43. package/src/locales/el-GR.json +183 -0
  44. package/src/locales/en.json +183 -0
  45. package/src/locales/es-ES.json +183 -0
  46. package/src/locales/fi-FI.json +183 -0
  47. package/src/locales/fil-PH.json +183 -0
  48. package/src/locales/fr-FR.json +183 -0
  49. package/src/locales/hi-IN.json +183 -0
  50. package/src/locales/ja-JP.json +183 -0
  51. package/src/locales/ko-KR.json +183 -0
  52. package/src/locales/pt-BR.json +183 -0
  53. package/src/locales/pt-PT.json +183 -0
  54. package/src/locales/ru-RU.json +183 -0
  55. package/src/locales/ta-IN.json +183 -0
  56. package/src/locales/uk-UA.json +183 -0
  57. package/src/locales/zh-CN.json +183 -0
  58. package/src/locales/zh-TW.json +183 -0
  59. package/src/types/account.types.ts +28 -0
  60. package/src/types/app.ts +22 -0
  61. package/src/types/profile.ts +6 -0
  62. package/src/utils/AccountManagerV2.ts +349 -0
  63. package/src/utils/Redirect.ts +17 -0
  64. package/src/utils/accountStorage.ts +46 -0
  65. package/src/utils/errors.ts +1 -0
  66. package/src/utils/index.ts +11 -0
  67. package/src/utils/location.ts +34 -0
  68. package/src/utils/profileStep.ts +26 -0
  69. package/src/utils/resolveAppId.ts +13 -0
  70. package/src/utils/selectedProfileStorage.ts +46 -0
  71. package/src/utils/tokenStorage.ts +47 -0
  72. package/dist/index.cjs.js +0 -22
  73. package/dist/index.cjs.js.map +0 -1
  74. package/dist/index.d.ts +0 -302
  75. package/dist/index.es.js +0 -8884
  76. package/dist/index.es.js.map +0 -1
@@ -0,0 +1,16 @@
1
+ export * from "./uploadAvatarAtom"
2
+ export * from "./accountAtom"
3
+ export * from "./accountProfileAtom"
4
+ export * from "./switchProfileAtom"
5
+ export * from "./createProfileAtom"
6
+ export * from "./profileDraftAtom"
7
+ export * from "./profileAtom"
8
+ export * from "./profilesAtom"
9
+ export * from "./updateProfileAtom"
10
+ export * from "./loginAtom"
11
+ export * from "./sessionCheckAtom"
12
+ export * from "./userLogoutAtom"
13
+ export * from "./motherTongueAtom"
14
+ export * from "./learningLangAtom"
15
+ export * from "./userSettingsAtom"
16
+ export * from "./localStorageAtom"
@@ -0,0 +1,8 @@
1
+ import { normalizeLang } from "@pmate/lang"
2
+ import { atom } from "jotai"
3
+ import { profileAtom } from "./accountProfileAtom"
4
+
5
+ export const learningLangAtom = atom((get) => {
6
+ const profile = get(profileAtom)
7
+ return normalizeLang(profile?.learningTargetLang)
8
+ })
@@ -0,0 +1,39 @@
1
+ import { atom } from "jotai"
2
+ import { atomFamily, type AtomFamily } from "jotai-family"
3
+ import type { WritableAtom } from "jotai"
4
+
5
+ export enum LSKEYS {
6
+ USER_SETTINGS = "user-settings",
7
+ }
8
+
9
+ const cacheAtom = atomFamily((_: LSKEYS) => atom<any>(undefined))
10
+
11
+ type LocalStorageJsonAtomFamily = AtomFamily<
12
+ LSKEYS,
13
+ WritableAtom<any, [any], void>
14
+ >
15
+
16
+ export const localStorageJsonAtom: LocalStorageJsonAtomFamily = atomFamily(
17
+ (key: LSKEYS) =>
18
+ atom(
19
+ (get) => {
20
+ const cache = get(cacheAtom(key))
21
+ if (cache) {
22
+ return cache
23
+ }
24
+ try {
25
+ const item = localStorage.getItem(key)
26
+ if (!item) {
27
+ return null
28
+ }
29
+ return JSON.parse(item)
30
+ } catch {
31
+ return null
32
+ }
33
+ },
34
+ (_, set, value: any) => {
35
+ localStorage.setItem(key, JSON.stringify(value))
36
+ set(cacheAtom(key), value)
37
+ }
38
+ )
39
+ )
@@ -0,0 +1,8 @@
1
+ import { atom } from "jotai"
2
+ import { AccountManagerV2 } from "../utils/AccountManagerV2"
3
+ import { resolveAppId } from "../utils/resolveAppId"
4
+
5
+ export const loginAtom = atom(null, async () => {
6
+ const manager = AccountManagerV2.get(resolveAppId())
7
+ return manager.login()
8
+ })
@@ -0,0 +1,8 @@
1
+ import { normalizeLang } from "@pmate/lang"
2
+ import { atom } from "jotai"
3
+ import { profileAtom } from "./accountProfileAtom"
4
+
5
+ export const motherTongueAtom = atom((get) => {
6
+ const profile = get(profileAtom)
7
+ return normalizeLang(profile?.motherTongue, "zh-CN")
8
+ })
@@ -0,0 +1,24 @@
1
+ import { Profile } from "@pmate/meta"
2
+ import { atomWithRefresh } from "jotai/utils"
3
+ import { atomFamily, type AtomFamily } from "jotai-family"
4
+ import type { WritableAtom } from "jotai"
5
+ import { EntityService } from "../api/EntityService"
6
+
7
+ type ProfileByIdAtomFamily = AtomFamily<
8
+ string,
9
+ WritableAtom<Promise<Profile | null>, [], void>
10
+ >
11
+
12
+ export const profileByIdAtom: ProfileByIdAtomFamily = atomFamily(
13
+ (profileId: string) =>
14
+ atomWithRefresh(async () => {
15
+ if (!profileId) {
16
+ return null
17
+ }
18
+ try {
19
+ return await EntityService.entity<Profile>(profileId)
20
+ } catch {
21
+ return null
22
+ }
23
+ })
24
+ )
@@ -0,0 +1,7 @@
1
+ import { atom } from "jotai"
2
+ import type { ProfileDraft } from "../types/profile"
3
+
4
+ /**
5
+ * Temporary profile info during registration flow
6
+ */
7
+ export const profileDraftAtom = atom<ProfileDraft>({})
@@ -0,0 +1,20 @@
1
+ import { Profile } from "@pmate/meta"
2
+ import { atomWithLoadable } from "./atomWithLoadable"
3
+ import { ProfileService } from "../api/ProfileService"
4
+ import { resolveAppId } from "../utils/resolveAppId"
5
+ import { AccountManagerV2 } from "../utils/AccountManagerV2"
6
+ import { accountAtom } from "./accountAtom"
7
+
8
+ export const profilesAtom = atomWithLoadable<Profile[]>(async (get) => {
9
+ get(accountAtom)
10
+ const manager = AccountManagerV2.get(resolveAppId())
11
+ const acc = await manager.getAccountState()
12
+ if (!acc) {
13
+ return [] as Profile[]
14
+ }
15
+ try {
16
+ return await ProfileService.getProfiles(acc)
17
+ } catch {
18
+ return [] as Profile[]
19
+ }
20
+ })
@@ -0,0 +1,10 @@
1
+ import { atom } from "jotai"
2
+ import { AccountService } from "../api/AccountService"
3
+
4
+ export const sessionCheckAtom = atom(null, async () => {
5
+ try {
6
+ return await AccountService.session()
7
+ } catch {
8
+ return null
9
+ }
10
+ })
@@ -0,0 +1,9 @@
1
+ import { Profile } from "@pmate/meta"
2
+ import { atom } from "jotai"
3
+ import { AccountManagerV2 } from "../utils/AccountManagerV2"
4
+ import { resolveAppId } from "../utils/resolveAppId"
5
+
6
+ export const switchProfileAtom = atom(null, (_get, _, profile: Profile) => {
7
+ const app = resolveAppId(profile.app)
8
+ AccountManagerV2.get(app).setSelectedProfile(profile)
9
+ })
@@ -0,0 +1,35 @@
1
+ import { atom } from "jotai"
2
+ import { ProfileService } from "../api/ProfileService"
3
+ import { AccountManagerV2 } from "../utils/AccountManagerV2"
4
+ import { resolveAppId } from "../utils/resolveAppId"
5
+ import { profileAtom } from "./accountProfileAtom"
6
+ import { profileByIdAtom } from "./profileAtom"
7
+
8
+ export const updateProfileAtom = atom(
9
+ null,
10
+ async (get, _set, profileId: string, updates: Record<string, any>) => {
11
+ const selected = get(profileAtom)
12
+ const target =
13
+ selected && selected.id === profileId
14
+ ? selected
15
+ : await get(profileByIdAtom(profileId))
16
+
17
+ if (!target) {
18
+ return
19
+ }
20
+
21
+ await ProfileService.updateProfile({
22
+ profileId: target.id,
23
+ ...updates,
24
+ })
25
+
26
+ const app = resolveAppId(target.app)
27
+ const manager = AccountManagerV2.get(app)
28
+ const updated = { ...target, ...updates }
29
+ if (selected && selected.id === profileId) {
30
+ manager.setSelectedProfile(updated)
31
+ }
32
+
33
+ await manager.getProfiles()
34
+ }
35
+ )
@@ -0,0 +1,49 @@
1
+ import imageCompression from "browser-image-compression"
2
+ import { atom } from "jotai"
3
+ import { ProfileService } from "../api/ProfileService"
4
+
5
+ type UploadAvatarParams = {
6
+ file: File
7
+ userId: string
8
+ }
9
+
10
+ const fileToBase64 = (file: File) =>
11
+ new Promise<string>((resolve, reject) => {
12
+ const reader = new FileReader()
13
+ reader.onloadend = () => {
14
+ const result = reader.result
15
+ if (typeof result !== "string") {
16
+ reject(new Error("Failed to read file"))
17
+ return
18
+ }
19
+ const [, base64] = result.split(",")
20
+ if (!base64) {
21
+ reject(new Error("Failed to parse base64 data"))
22
+ return
23
+ }
24
+ resolve(base64)
25
+ }
26
+ reader.onerror = () => {
27
+ reject(new Error("Failed to read file"))
28
+ }
29
+ reader.readAsDataURL(file)
30
+ })
31
+
32
+ export const uploadAvatarAtom = atom(
33
+ null,
34
+ async (_get, _set, { file, userId }: UploadAvatarParams) => {
35
+ const compressed = await imageCompression(file, {
36
+ maxSizeMB: 1,
37
+ maxWidthOrHeight: 128,
38
+ useWebWorker: true,
39
+ })
40
+
41
+ const base64 = await fileToBase64(compressed)
42
+
43
+ return ProfileService.updateAvatar({
44
+ user: userId,
45
+ base64,
46
+ filename: compressed.name,
47
+ })
48
+ }
49
+ )
@@ -0,0 +1,8 @@
1
+ import { atom } from "jotai"
2
+ import { AccountManagerV2 } from "../utils/AccountManagerV2"
3
+ import { resolveAppId } from "../utils/resolveAppId"
4
+
5
+ export const userLogoutAtom = atom(null, async () => {
6
+ const manager = AccountManagerV2.get(resolveAppId())
7
+ await manager.logout()
8
+ })
@@ -0,0 +1,58 @@
1
+ import { VoiceList } from "@pmate/meta"
2
+ import { Difficulty, UserSettings } from "@pmate/meta"
3
+ import { WritableAtom, atom } from "jotai"
4
+ import { LSKEYS, localStorageJsonAtom } from "./localStorageAtom"
5
+
6
+ const defaultSettings: UserSettings = {
7
+ intensive: false,
8
+ bilingual: true,
9
+ fontColor: "#000000",
10
+ backgroundColor: "white",
11
+ books: [],
12
+ advancedMode: false,
13
+ autoread: true,
14
+ playSpeed: 1,
15
+ scrollDirection: "vertical",
16
+ companion: "",
17
+ difficulty: Difficulty.Medium,
18
+ "chatVoice@v2": VoiceList.KOKORO_af_alloy,
19
+ uiLang: "zh-CN",
20
+ motherTongue: "zh-CN",
21
+ }
22
+
23
+ const settingsCache = new Map<keyof UserSettings, WritableAtom<any, any, any>>()
24
+
25
+ export const userSettingsAtom = <T extends keyof UserSettings>(key: T) => {
26
+ if (settingsCache.has(key)) {
27
+ return settingsCache.get(key)! as WritableAtom<
28
+ UserSettings[T],
29
+ [value: UserSettings[T]],
30
+ void
31
+ >
32
+ }
33
+
34
+ const settingAtom = atom(
35
+ (get) => {
36
+ const stored = get(localStorageJsonAtom(LSKEYS.USER_SETTINGS)) as
37
+ | UserSettings
38
+ | null
39
+ if (stored && key in stored) {
40
+ return stored[key] as UserSettings[T]
41
+ }
42
+ return defaultSettings[key]
43
+ },
44
+ (get, set, value: UserSettings[T]) => {
45
+ const stored = get(localStorageJsonAtom(LSKEYS.USER_SETTINGS)) as
46
+ | UserSettings
47
+ | null
48
+ const next = {
49
+ ...(stored ?? defaultSettings),
50
+ [key]: value,
51
+ } as UserSettings
52
+ set(localStorageJsonAtom(LSKEYS.USER_SETTINGS), next)
53
+ }
54
+ )
55
+
56
+ settingsCache.set(key, settingAtom)
57
+ return settingAtom as WritableAtom<UserSettings[T], [value: UserSettings[T]], void>
58
+ }
@@ -0,0 +1,315 @@
1
+ import { Button } from "./Button"
2
+ import { Drawer } from "./Drawer"
3
+ import { useSetAtom } from "jotai"
4
+ import {
5
+ Component,
6
+ PropsWithChildren,
7
+ useCallback,
8
+ useEffect,
9
+ useMemo,
10
+ useState,
11
+ } from "react"
12
+ import { useAuthSnapshot } from "../hooks/useAuthSnapshot"
13
+ import { accountAtom } from "../atoms/accountAtom"
14
+ import { AccountLifecycleState } from "../types/account.types"
15
+ import { userLogoutAtom } from "../atoms/userLogoutAtom"
16
+ import { Redirect } from "../utils/Redirect"
17
+ import { NotAuthenticatedError } from "../utils/errors"
18
+ import {
19
+ getWindowPathname,
20
+ subscribeToLocationChange,
21
+ } from "../utils/location"
22
+
23
+ export type AuthRoute =
24
+ | string
25
+ | {
26
+ path: string
27
+ behavior?: "redirect" | "prompt"
28
+ }
29
+
30
+ const escapeRegExp = (value: string) =>
31
+ value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
32
+
33
+ const matchRoutePath = (pattern: string, pathname: string) => {
34
+ if (pattern === "*") {
35
+ return true
36
+ }
37
+ if (pattern === "/") {
38
+ return pathname === "/"
39
+ }
40
+ const segments = pattern.split("/").filter(Boolean)
41
+ const parts = segments.map((segment) => {
42
+ if (segment === "*") {
43
+ return ".*"
44
+ }
45
+ if (segment.startsWith(":")) {
46
+ return "[^/]+"
47
+ }
48
+ return escapeRegExp(segment)
49
+ })
50
+ const regex = new RegExp(`^/${parts.join("/")}$`)
51
+ return regex.test(pathname)
52
+ }
53
+
54
+ const getAuthBehaviors = (
55
+ authRoutes: AuthRoute[] | undefined,
56
+ pathname: string
57
+ ) => {
58
+ const matchedAuthRoute = authRoutes?.find((route) => {
59
+ const path = typeof route === "string" ? route : route.path
60
+ return matchRoutePath(path, pathname)
61
+ })
62
+ const authBehavior =
63
+ matchedAuthRoute && typeof matchedAuthRoute !== "string"
64
+ ? (matchedAuthRoute.behavior ?? "prompt")
65
+ : "prompt"
66
+ const requiresAuth = Boolean(matchedAuthRoute)
67
+ return {
68
+ authBehavior,
69
+ requiresAuth,
70
+ }
71
+ }
72
+
73
+ type AuthProviderV2Props = PropsWithChildren<{
74
+ app: string
75
+ authRoutes?: AuthRoute[]
76
+ onLoginSuccess?: () => void | Promise<void>
77
+ rtcProvider?: React.ComponentType<{ children: React.ReactNode }>
78
+ pathname?: string
79
+ navigate?: (to: string, options?: { replace?: boolean }) => void
80
+ }>
81
+
82
+ const useWindowPathname = () => {
83
+ const [pathname, setPathname] = useState(getWindowPathname())
84
+ useEffect(() => {
85
+ const update = () => setPathname(getWindowPathname())
86
+ const unsubscribe = subscribeToLocationChange(update)
87
+ return () => unsubscribe()
88
+ }, [])
89
+ return pathname
90
+ }
91
+
92
+ export const AuthProviderV2 = ({
93
+ app,
94
+ authRoutes,
95
+ rtcProvider: RtcProvider,
96
+ pathname: pathnameProp,
97
+ navigate: navigateProp,
98
+ children,
99
+ }: AuthProviderV2Props) => {
100
+ 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
+ const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false)
114
+ const [isLoginErrorDismissed, setIsLoginErrorDismissed] = useState(false)
115
+ const setAccountSnapshot = useSetAtom(accountAtom)
116
+ const { authBehavior, requiresAuth } = getAuthBehaviors(
117
+ authRoutes,
118
+ pathname
119
+ )
120
+ const { loading, snapshot } = useAuthSnapshot({
121
+ app,
122
+ behaviors: {
123
+ authBehavior,
124
+ requiresAuth,
125
+ },
126
+ })
127
+ const loginError = snapshot.error
128
+ const hasAccount =
129
+ snapshot.state === AccountLifecycleState.Idle
130
+ ? null
131
+ : Boolean(snapshot.account)
132
+
133
+ useEffect(() => {
134
+ setAccountSnapshot(snapshot)
135
+ }, [setAccountSnapshot, snapshot])
136
+
137
+ useEffect(() => {
138
+ if (!requiresAuth || loading) {
139
+ return
140
+ }
141
+ if (
142
+ snapshot.account &&
143
+ snapshot.profiles.length === 0 &&
144
+ pathname !== "/create-profile"
145
+ ) {
146
+ Redirect.toCreateProfile(app)
147
+ return
148
+ }
149
+ if (!snapshot.account && authBehavior === "redirect") {
150
+ Redirect.toLogin(app)
151
+ }
152
+ }, [
153
+ app,
154
+ authBehavior,
155
+ loading,
156
+ pathname,
157
+ requiresAuth,
158
+ snapshot.account,
159
+ snapshot.profiles.length,
160
+ ])
161
+
162
+ useEffect(() => {
163
+ if (loading) {
164
+ return
165
+ }
166
+ setIsLoginPromptOpen(
167
+ requiresAuth && !snapshot.account && authBehavior === "prompt",
168
+ )
169
+ }, [authBehavior, loading, requiresAuth, snapshot.account])
170
+ useEffect(() => {
171
+ if (!loginError) {
172
+ setIsLoginErrorDismissed(false)
173
+ }
174
+ }, [loginError])
175
+
176
+ const handleBack = () => {
177
+ setIsLoginPromptOpen(false)
178
+ if (window.history.length > 1) {
179
+ window.history.back()
180
+ return
181
+ }
182
+ // window.location.href = "/"
183
+ }
184
+
185
+ if (loading && requiresAuth) {
186
+ return null
187
+ }
188
+ if (loginError && requiresAuth && !isLoginErrorDismissed) {
189
+ 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>
210
+ )
211
+ }
212
+ if (requiresAuth && hasAccount === null) {
213
+ return null
214
+ }
215
+ if (requiresAuth && hasAccount === false && authBehavior === "prompt") {
216
+ return (
217
+ <Drawer
218
+ 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>
247
+ )
248
+ }
249
+ if (!requiresAuth) {
250
+ return children
251
+ }
252
+ const RtcWrapper = RtcProvider ?? (({ children }) => <>{children}</>)
253
+ return (
254
+ <AuthErrorBoundary navigate={navigate}>
255
+ <RtcWrapper>{children}</RtcWrapper>
256
+ </AuthErrorBoundary>
257
+ )
258
+ }
259
+
260
+ interface AuthErrorBoundaryState {
261
+ hasError: boolean
262
+ }
263
+
264
+ interface AuthErrorBoundaryProps extends PropsWithChildren {
265
+ onAuthError: () => Promise<void>
266
+ }
267
+
268
+ class AuthErrorBoundaryInner extends Component<
269
+ AuthErrorBoundaryProps,
270
+ AuthErrorBoundaryState
271
+ > {
272
+ state: AuthErrorBoundaryState = { hasError: false }
273
+
274
+ static getDerivedStateFromError(): AuthErrorBoundaryState {
275
+ return { hasError: true }
276
+ }
277
+
278
+ componentDidCatch(error: unknown) {
279
+ if (error instanceof NotAuthenticatedError) {
280
+ this.props.onAuthError().then(() => {
281
+ this.setState({ hasError: false })
282
+ })
283
+ return
284
+ }
285
+
286
+ console.error(error)
287
+ }
288
+
289
+ render() {
290
+ if (this.state.hasError) {
291
+ return null
292
+ }
293
+
294
+ return this.props.children
295
+ }
296
+ }
297
+
298
+ const AuthErrorBoundary = ({
299
+ children,
300
+ navigate,
301
+ }: PropsWithChildren<{
302
+ navigate: (to: string, options?: { replace?: boolean }) => void
303
+ }>) => {
304
+ const logout = useSetAtom(userLogoutAtom)
305
+ const handleAuthError = useCallback(async () => {
306
+ await logout()
307
+ navigate("/login", { replace: true })
308
+ }, [logout, navigate])
309
+
310
+ return (
311
+ <AuthErrorBoundaryInner onAuthError={handleAuthError}>
312
+ {children}
313
+ </AuthErrorBoundaryInner>
314
+ )
315
+ }
@@ -0,0 +1,39 @@
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
+ }