@pmate/account-sdk 0.5.5 → 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.
- package/package.json +14 -21
- package/src/api/AccountService.ts +97 -0
- package/src/api/Api.ts +99 -0
- package/src/api/AppService.ts +71 -0
- package/src/api/EntityService.ts +41 -0
- package/src/api/ProfileService.ts +133 -0
- package/src/api/cacheInMem.ts +73 -0
- package/src/api/index.ts +5 -0
- package/src/atoms/accountAtom.ts +18 -0
- package/src/atoms/accountProfileAtom.ts +13 -0
- package/src/atoms/atomWithLoadable.ts +48 -0
- package/src/atoms/createProfileAtom.ts +27 -0
- package/src/atoms/index.ts +16 -0
- package/src/atoms/learningLangAtom.ts +8 -0
- package/src/atoms/localStorageAtom.ts +39 -0
- package/src/atoms/loginAtom.ts +8 -0
- package/src/atoms/motherTongueAtom.ts +8 -0
- package/src/atoms/profileAtom.ts +24 -0
- package/src/atoms/profileDraftAtom.ts +7 -0
- package/src/atoms/profilesAtom.ts +20 -0
- package/src/atoms/sessionCheckAtom.ts +10 -0
- package/src/atoms/switchProfileAtom.ts +9 -0
- package/src/atoms/updateProfileAtom.ts +35 -0
- package/src/atoms/uploadAvatarAtom.ts +49 -0
- package/src/atoms/userLogoutAtom.ts +8 -0
- package/src/atoms/userSettingsAtom.ts +58 -0
- package/src/components/AuthProviderV2.tsx +315 -0
- package/src/components/Button.tsx +39 -0
- package/src/components/Drawer.tsx +80 -0
- package/src/components/index.ts +1 -0
- package/src/constants.ts +1 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useAppBackgroundStyle.ts +25 -0
- package/src/hooks/useAppConfig.ts +44 -0
- package/src/hooks/useAuthApp.ts +165 -0
- package/src/hooks/useAuthSnapshot.ts +84 -0
- package/src/hooks/useIsAuthenticated.ts +19 -0
- package/src/hooks/useProfileStepFlow.ts +59 -0
- package/src/i18n/index.ts +59 -0
- package/src/index.ts +9 -0
- package/src/locales/ar-SA.json +183 -0
- package/src/locales/de-DE.json +183 -0
- package/src/locales/el-GR.json +183 -0
- package/src/locales/en.json +183 -0
- package/src/locales/es-ES.json +183 -0
- package/src/locales/fi-FI.json +183 -0
- package/src/locales/fil-PH.json +183 -0
- package/src/locales/fr-FR.json +183 -0
- package/src/locales/hi-IN.json +183 -0
- package/src/locales/ja-JP.json +183 -0
- package/src/locales/ko-KR.json +183 -0
- package/src/locales/pt-BR.json +183 -0
- package/src/locales/pt-PT.json +183 -0
- package/src/locales/ru-RU.json +183 -0
- package/src/locales/ta-IN.json +183 -0
- package/src/locales/uk-UA.json +183 -0
- package/src/locales/zh-CN.json +183 -0
- package/src/locales/zh-TW.json +183 -0
- package/src/types/account.types.ts +28 -0
- package/src/types/app.ts +22 -0
- package/src/types/profile.ts +6 -0
- package/src/utils/AccountManagerV2.ts +349 -0
- package/src/utils/Redirect.ts +17 -0
- package/src/utils/accountStorage.ts +46 -0
- package/src/utils/errors.ts +1 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/location.ts +34 -0
- package/src/utils/profileStep.ts +26 -0
- package/src/utils/resolveAppId.ts +13 -0
- package/src/utils/selectedProfileStorage.ts +46 -0
- package/src/utils/tokenStorage.ts +47 -0
- package/dist/index.cjs.js +0 -22
- package/dist/index.cjs.js.map +0 -1
- package/dist/index.d.ts +0 -305
- package/dist/index.es.js +0 -8897
- 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,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,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
|
+
}
|