@exegia/corpora-ui 0.20.0 → 0.22.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/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
- package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
- package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
- package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
- package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
- package/dist-lib/components/blocks/layout.d.ts +4 -1
- package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
- package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
- package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
- package/dist-lib/components/blocks/nav/sidebar/type.d.ts +109 -29
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
- package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +6 -1
- package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +1 -1
- package/dist-lib/components/blocks/profile/index.d.ts +7 -0
- package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
- package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
- package/dist-lib/components/blocks/profile/type.d.ts +80 -0
- package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
- package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
- package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
- package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
- package/dist-lib/components/blocks/shell/index.d.ts +4 -12
- package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
- package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
- package/dist-lib/components/blocks/shell/type.d.ts +115 -3
- package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
- package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
- package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
- package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
- package/dist-lib/components/composed/__tests__/logo.test.d.ts +1 -0
- package/dist-lib/components/composed/logo.d.ts +34 -0
- package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
- package/dist-lib/components/composed/tree/constants.d.ts +4 -4
- package/dist-lib/components/composed/tree/index.d.ts +3 -1
- package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
- package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
- package/dist-lib/components/composed/tree/type.d.ts +116 -18
- package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
- package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
- package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
- package/dist-lib/components/composed/user-avatar.d.ts +5 -28
- package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
- package/dist-lib/components/user-avatar/component.d.ts +1 -8
- package/dist-lib/components/user-avatar/index.d.ts +5 -0
- package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
- package/dist-lib/components/user-avatar/type.d.ts +53 -0
- package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
- package/dist-lib/components/user-avatar/use-user-avatar.d.ts +37 -0
- package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +64 -0
- package/dist-lib/components/user-avatar/utils.d.ts +22 -0
- package/dist-lib/index.d.ts +6 -3
- package/dist-lib/index.js +3732 -2670
- package/dist-lib/index.js.map +1 -1
- package/dist-lib/state/exegia-provider.d.ts +51 -0
- package/dist-lib/state/index.d.ts +4 -0
- package/dist-lib/state/store.d.ts +17 -0
- package/package.json +15 -12
- package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
- package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
- package/src/components/blocks/auth/auth-session-atom.ts +82 -0
- package/src/components/blocks/auth/auth-state-type.ts +97 -0
- package/src/components/blocks/auth/auth-state.ts +52 -0
- package/src/components/blocks/auth/use-auth-state.ts +127 -0
- package/src/components/blocks/layout.ts +30 -2
- package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
- package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
- package/src/components/blocks/nav/sidebar/index.ts +53 -1
- package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
- package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
- package/src/components/blocks/nav/sidebar/type.ts +125 -30
- package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
- package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
- package/src/components/blocks/nav/sidebar/utils.ts +1 -1
- package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
- package/src/components/blocks/profile/index.ts +28 -0
- package/src/components/blocks/profile/profile-card-atom.ts +247 -0
- package/src/components/blocks/profile/profile-card-block.tsx +153 -74
- package/src/components/blocks/profile/type.ts +95 -0
- package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
- package/src/components/blocks/profile/use-profile-card.ts +126 -0
- package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
- package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
- package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
- package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
- package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
- package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
- package/src/components/blocks/shell/animated-panel.tsx +126 -29
- package/src/components/blocks/shell/index.ts +18 -13
- package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
- package/src/components/blocks/shell/shell-layout.tsx +55 -53
- package/src/components/blocks/shell/shell-metrics.ts +79 -0
- package/src/components/blocks/shell/type.ts +130 -3
- package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
- package/src/components/blocks/shell/use-shell-fit.ts +135 -0
- package/src/components/blocks/shell/use-shell-panels.ts +57 -4
- package/src/components/blocks/shell/utils.ts +44 -4
- package/src/components/composed/__tests__/logo.test.tsx +60 -0
- package/src/components/composed/logo.tsx +159 -0
- package/src/components/composed/tree/CLAUDE.md +132 -0
- package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
- package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
- package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
- package/src/components/composed/tree/constants.ts +4 -4
- package/src/components/composed/tree/index.ts +38 -0
- package/src/components/composed/tree/tree-atom.ts +590 -0
- package/src/components/composed/tree/tree-node.tsx +117 -78
- package/src/components/composed/tree/tree.tsx +24 -16
- package/src/components/composed/tree/type.ts +125 -15
- package/src/components/composed/tree/use-tree-dnd.ts +82 -51
- package/src/components/composed/tree/use-tree-state.ts +105 -0
- package/src/components/composed/tree/use-tree.ts +185 -184
- package/src/components/composed/user-avatar.tsx +11 -99
- package/src/components/user-avatar/__tests__/user-avatar.test.tsx +286 -0
- package/src/components/user-avatar/component.tsx +151 -22
- package/src/components/user-avatar/fallback.tsx +3 -1
- package/src/components/user-avatar/index.ts +19 -0
- package/src/components/user-avatar/presence-badge.tsx +56 -0
- package/src/components/user-avatar/type.ts +58 -0
- package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
- package/src/components/user-avatar/use-user-avatar.ts +180 -0
- package/src/components/user-avatar/user-avatar-atom.ts +218 -0
- package/src/components/user-avatar/utils.ts +94 -0
- package/src/index.ts +35 -3
- package/src/state/exegia-provider.tsx +79 -0
- package/src/state/index.ts +4 -0
- package/src/state/store.ts +19 -0
- package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
- package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
- package/dist-lib/components/blocks/nav/types.d.ts +0 -60
- package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
- package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
- package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
- package/src/components/blocks/nav/sidebar-block.tsx +0 -142
- package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
- package/src/components/blocks/nav/types.ts +0 -65
- package/src/components/blocks/nav/utils.ts +0 -4
- package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
- package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
- package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
- package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
- package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
- package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
- /package/dist-lib/components/blocks/{nav/__tests__/sidebar-block.test.d.ts → auth/__tests__/auth-state-atom.test.d.ts} +0 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useStore } from 'jotai';
|
|
2
|
+
import { ExegiaStore } from './store';
|
|
3
|
+
/**
|
|
4
|
+
* The library's one and only state provider.
|
|
5
|
+
*
|
|
6
|
+
* Mount it once at the app root. Every library state hook then works
|
|
7
|
+
* anywhere below it — there are no per-component providers to nest.
|
|
8
|
+
*
|
|
9
|
+
* One provider suffices because library state lives in module-level Jotai
|
|
10
|
+
* atom families keyed by an instance id, not in a context created per
|
|
11
|
+
* component. A new stateful component adds atoms to that module scope; it
|
|
12
|
+
* never adds a provider. Consumer code therefore stays exactly as it is as
|
|
13
|
+
* the library grows.
|
|
14
|
+
*
|
|
15
|
+
* Without this provider the hooks still run, which is the trap: Jotai falls
|
|
16
|
+
* back to its implicit default store, and that store is NOT `exegiaStore`,
|
|
17
|
+
* so imperative access would read a different store than the one the
|
|
18
|
+
* components render from. Mount the provider.
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* export function App() {
|
|
22
|
+
* return (
|
|
23
|
+
* <ExegiaProvider sound>
|
|
24
|
+
* <Routes />
|
|
25
|
+
* </ExegiaProvider>
|
|
26
|
+
* )
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
import * as React from "react";
|
|
31
|
+
/** Options forwarded to the library's `ThemeProvider`. */
|
|
32
|
+
export interface ExegiaThemeOptions {
|
|
33
|
+
defaultTheme?: "dark" | "light" | "system";
|
|
34
|
+
storageKey?: string;
|
|
35
|
+
disableTransitionOnChange?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface ExegiaProviderProps {
|
|
38
|
+
children: React.ReactNode;
|
|
39
|
+
/** Store to mount. Defaults to the library's `exegiaStore`. */
|
|
40
|
+
store?: ExegiaStore;
|
|
41
|
+
/** Opt into interaction sound. Off unless the app asks for it. */
|
|
42
|
+
sound?: boolean;
|
|
43
|
+
/** Mount `ThemeProvider` with these options. Omit it and none is mounted —
|
|
44
|
+
* it reads `localStorage` in its state initializer, so an unconditional
|
|
45
|
+
* mount would break SSR consumers, and `useTheme` already throws without
|
|
46
|
+
* one. */
|
|
47
|
+
theme?: ExegiaThemeOptions;
|
|
48
|
+
}
|
|
49
|
+
export declare function ExegiaProvider({ children, store, sound, theme, }: ExegiaProviderProps): React.ReactElement;
|
|
50
|
+
/** The store mounted by the nearest `ExegiaProvider` above the caller. */
|
|
51
|
+
export declare const useExegiaStore: typeof useStore;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createStore } from 'jotai';
|
|
2
|
+
/** A Jotai store as created by `createStore()`. */
|
|
3
|
+
export type ExegiaStore = ReturnType<typeof createStore>;
|
|
4
|
+
/**
|
|
5
|
+
* The library's default store — the one `ExegiaProvider` mounts unless it is
|
|
6
|
+
* handed another. Exported so non-React code (a router guard, a command
|
|
7
|
+
* palette, a test) can read and write library state imperatively:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* exegiaStore.get(treeExpandedIdsAtom("app-nav"))
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* A single module-level store is wrong for SSR — state would leak between
|
|
14
|
+
* requests — so render-per-request apps pass a fresh `createStore()` to
|
|
15
|
+
* `ExegiaProvider` instead.
|
|
16
|
+
*/
|
|
17
|
+
export declare const exegiaStore: ExegiaStore;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exegia/corpora-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"dist-lib",
|
|
24
24
|
"src/components",
|
|
25
25
|
"src/lib",
|
|
26
|
+
"src/state",
|
|
26
27
|
"src/index.css",
|
|
27
28
|
"src/index.ts"
|
|
28
29
|
],
|
|
@@ -37,18 +38,19 @@
|
|
|
37
38
|
"preview": "vite preview"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
|
-
"
|
|
41
|
-
"react
|
|
41
|
+
"jotai": "^2.20.2",
|
|
42
|
+
"react": "^19.2.8",
|
|
43
|
+
"react-dom": "^19.2.8"
|
|
42
44
|
},
|
|
43
45
|
"dependencies": {
|
|
44
|
-
"@base-ui/react": "^1.
|
|
46
|
+
"@base-ui/react": "^1.7.0",
|
|
45
47
|
"@remixicon/react": "^4.9.0",
|
|
46
48
|
"class-variance-authority": "^0.7.1",
|
|
47
49
|
"clsx": "^2.1.1",
|
|
48
|
-
"cuelume": "^0.2.
|
|
50
|
+
"cuelume": "^0.2.2",
|
|
49
51
|
"iconsax-reactjs": "^0.0.8",
|
|
50
52
|
"lucide-react": "^1.31.0",
|
|
51
|
-
"motion": "^
|
|
53
|
+
"motion": "^13.1.0",
|
|
52
54
|
"motion-icons-react": "^1.1.0",
|
|
53
55
|
"tailwind-animations": "^1.0.2",
|
|
54
56
|
"tailwind-merge": "^3.6.0"
|
|
@@ -57,11 +59,11 @@
|
|
|
57
59
|
"@eslint/js": "^10",
|
|
58
60
|
"@fontsource-variable/figtree": "^5.3.0",
|
|
59
61
|
"@fontsource-variable/inter": "^5.3.0",
|
|
60
|
-
"@happy-dom/global-registrator": "^20.11.
|
|
62
|
+
"@happy-dom/global-registrator": "^20.11.2",
|
|
61
63
|
"@tailwindcss/vite": "^4",
|
|
62
64
|
"@testing-library/dom": "^10.4.1",
|
|
63
65
|
"@testing-library/react": "^16.3.2",
|
|
64
|
-
"@testing-library/user-event": "^14.6.
|
|
66
|
+
"@testing-library/user-event": "^14.6.5",
|
|
65
67
|
"@types/bun": "^1.3.14",
|
|
66
68
|
"@types/node": "^24",
|
|
67
69
|
"@types/react": "^19",
|
|
@@ -69,14 +71,15 @@
|
|
|
69
71
|
"@vitejs/plugin-react": "^6",
|
|
70
72
|
"eslint": "^10",
|
|
71
73
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
72
|
-
"eslint-plugin-react-refresh": "^0.5.
|
|
74
|
+
"eslint-plugin-react-refresh": "^0.5.4",
|
|
73
75
|
"globals": "^17",
|
|
74
|
-
"
|
|
75
|
-
"prettier
|
|
76
|
+
"jotai": "^2.20.2",
|
|
77
|
+
"prettier": "^3.9.6",
|
|
78
|
+
"prettier-plugin-tailwindcss": "^0.8.1",
|
|
76
79
|
"react": "^19.2.6",
|
|
77
80
|
"react-dom": "^19.2.6",
|
|
78
81
|
"react-router": "^8.3.0",
|
|
79
|
-
"shadcn": "^4.
|
|
82
|
+
"shadcn": "^4.18.0",
|
|
80
83
|
"tailwindcss": "^4",
|
|
81
84
|
"tw-animate-css": "^1.4.0",
|
|
82
85
|
"typescript": "~6",
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { render, screen } from "@testing-library/react"
|
|
3
|
+
import userEvent from "@testing-library/user-event"
|
|
4
|
+
import { createStore } from "jotai"
|
|
5
|
+
|
|
6
|
+
import { ExegiaProvider } from "@/state"
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_AUTH_FLOW_ID,
|
|
9
|
+
authFlowErrorAtom,
|
|
10
|
+
authFlowIdentifierAtom,
|
|
11
|
+
authFlowMaskedIdentifierAtom,
|
|
12
|
+
authFlowStateAtom,
|
|
13
|
+
authFlowStatusAtom,
|
|
14
|
+
authFlowStepAtom,
|
|
15
|
+
authSessionStatusAtom,
|
|
16
|
+
authUserAtom,
|
|
17
|
+
beginAuthVerificationAtom,
|
|
18
|
+
completeAuthFlowAtom,
|
|
19
|
+
endAuthSessionAtom,
|
|
20
|
+
failAuthFlowAtom,
|
|
21
|
+
goToAuthStepAtom,
|
|
22
|
+
isAuthenticatedAtom,
|
|
23
|
+
maskAuthIdentifier,
|
|
24
|
+
removeAuthFlowInstance,
|
|
25
|
+
resetAuthFlowAtom,
|
|
26
|
+
signInAtom,
|
|
27
|
+
updateAuthUserAtom,
|
|
28
|
+
useAuthFlow,
|
|
29
|
+
useAuthFlowActions,
|
|
30
|
+
} from "../auth-state"
|
|
31
|
+
import type { AuthUser } from "../auth-state"
|
|
32
|
+
|
|
33
|
+
const USER: AuthUser = {
|
|
34
|
+
id: "u-1",
|
|
35
|
+
name: "Yona Appletree",
|
|
36
|
+
email: "yo@example.com",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("auth flow atoms · instance isolation", () => {
|
|
40
|
+
test("two flow ids never share state", () => {
|
|
41
|
+
const store = createStore()
|
|
42
|
+
store.set(beginAuthVerificationAtom("modal"), {
|
|
43
|
+
identifier: "yo@example.com",
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(store.get(authFlowStepAtom("modal"))).toBe("verify-code")
|
|
47
|
+
expect(store.get(authFlowStepAtom("page"))).toBe("login")
|
|
48
|
+
expect(store.get(authFlowIdentifierAtom("page"))).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("two stores never share state under one id", () => {
|
|
52
|
+
const one = createStore()
|
|
53
|
+
const two = createStore()
|
|
54
|
+
one.set(beginAuthVerificationAtom("shared"), {
|
|
55
|
+
identifier: "yo@example.com",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(one.get(authFlowStepAtom("shared"))).toBe("verify-code")
|
|
59
|
+
expect(two.get(authFlowStepAtom("shared"))).toBe("login")
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe("auth flow atoms · flow drives the session", () => {
|
|
64
|
+
test("beginVerification → complete(user) drives flow and session together", () => {
|
|
65
|
+
const store = createStore()
|
|
66
|
+
const other = createStore()
|
|
67
|
+
store.set(beginAuthVerificationAtom(DEFAULT_AUTH_FLOW_ID), {
|
|
68
|
+
identifier: "+15551234567",
|
|
69
|
+
channel: "sms",
|
|
70
|
+
})
|
|
71
|
+
store.set(completeAuthFlowAtom(DEFAULT_AUTH_FLOW_ID), USER)
|
|
72
|
+
|
|
73
|
+
const flow = store.get(authFlowStateAtom(DEFAULT_AUTH_FLOW_ID))
|
|
74
|
+
expect(flow.step).toBe("success")
|
|
75
|
+
expect(flow.status).toBe("success")
|
|
76
|
+
expect(flow.error).toBeNull()
|
|
77
|
+
expect(flow.channel).toBe("sms")
|
|
78
|
+
expect(store.get(authUserAtom)).toEqual(USER)
|
|
79
|
+
expect(store.get(isAuthenticatedAtom)).toBe(true)
|
|
80
|
+
|
|
81
|
+
// The other store's session never moved.
|
|
82
|
+
expect(other.get(authSessionStatusAtom)).toBe("unknown")
|
|
83
|
+
expect(other.get(authUserAtom)).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test("complete() without a user leaves the session alone", () => {
|
|
87
|
+
const store = createStore()
|
|
88
|
+
store.set(completeAuthFlowAtom(DEFAULT_AUTH_FLOW_ID))
|
|
89
|
+
|
|
90
|
+
expect(store.get(authFlowStatusAtom(DEFAULT_AUTH_FLOW_ID))).toBe("success")
|
|
91
|
+
expect(store.get(authSessionStatusAtom)).toBe("unknown")
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("auth flow atoms · status transitions", () => {
|
|
96
|
+
test("fail sets status and error; goToStep clears them", () => {
|
|
97
|
+
const store = createStore()
|
|
98
|
+
store.set(failAuthFlowAtom(DEFAULT_AUTH_FLOW_ID), "Wrong code")
|
|
99
|
+
|
|
100
|
+
expect(store.get(authFlowStatusAtom(DEFAULT_AUTH_FLOW_ID))).toBe("error")
|
|
101
|
+
expect(store.get(authFlowErrorAtom(DEFAULT_AUTH_FLOW_ID))).toBe(
|
|
102
|
+
"Wrong code"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
store.set(goToAuthStepAtom(DEFAULT_AUTH_FLOW_ID), "forgot-password")
|
|
106
|
+
expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe(
|
|
107
|
+
"forgot-password"
|
|
108
|
+
)
|
|
109
|
+
expect(store.get(authFlowStatusAtom(DEFAULT_AUTH_FLOW_ID))).toBe("idle")
|
|
110
|
+
expect(store.get(authFlowErrorAtom(DEFAULT_AUTH_FLOW_ID))).toBeNull()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe("maskAuthIdentifier", () => {
|
|
115
|
+
test("masks emails, keeping the first character and the domain", () => {
|
|
116
|
+
expect(maskAuthIdentifier("yo@example.com")).toBe("y•••@example.com")
|
|
117
|
+
expect(maskAuthIdentifier("y@ex.co")).toBe("y•••@ex.co")
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test("masks phone numbers down to the last 4 characters", () => {
|
|
121
|
+
expect(maskAuthIdentifier("+15551234567")).toBe("•••4567")
|
|
122
|
+
expect(maskAuthIdentifier("123")).toBe("•••")
|
|
123
|
+
// Exactly 4 chars: last-4 would reveal everything — mask it all.
|
|
124
|
+
expect(maskAuthIdentifier("1234")).toBe("•••")
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("the derived atom masks the identifier in flight", () => {
|
|
128
|
+
const store = createStore()
|
|
129
|
+
expect(store.get(authFlowMaskedIdentifierAtom("m"))).toBeNull()
|
|
130
|
+
|
|
131
|
+
store.set(beginAuthVerificationAtom("m"), {
|
|
132
|
+
identifier: "yo@example.com",
|
|
133
|
+
})
|
|
134
|
+
expect(store.get(authFlowMaskedIdentifierAtom("m"))).toBe(
|
|
135
|
+
"y•••@example.com"
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe("auth session atoms · sign out", () => {
|
|
141
|
+
test("endAuthSession clears the session and resets the default flow", () => {
|
|
142
|
+
const store = createStore()
|
|
143
|
+
store.set(beginAuthVerificationAtom(DEFAULT_AUTH_FLOW_ID), {
|
|
144
|
+
identifier: "yo@example.com",
|
|
145
|
+
})
|
|
146
|
+
store.set(completeAuthFlowAtom(DEFAULT_AUTH_FLOW_ID), USER)
|
|
147
|
+
|
|
148
|
+
store.set(endAuthSessionAtom)
|
|
149
|
+
expect(store.get(authSessionStatusAtom)).toBe("unauthenticated")
|
|
150
|
+
expect(store.get(authUserAtom)).toBeNull()
|
|
151
|
+
expect(store.get(isAuthenticatedAtom)).toBe(false)
|
|
152
|
+
|
|
153
|
+
const flow = store.get(authFlowStateAtom(DEFAULT_AUTH_FLOW_ID))
|
|
154
|
+
expect(flow.step).toBe("login")
|
|
155
|
+
expect(flow.identifier).toBeNull()
|
|
156
|
+
expect(flow.maskedIdentifier).toBeNull()
|
|
157
|
+
expect(flow.status).toBe("idle")
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe("auth session atoms · updateAuthUser", () => {
|
|
162
|
+
test("shallow-merges a patch into the signed-in user", () => {
|
|
163
|
+
const store = createStore()
|
|
164
|
+
store.set(signInAtom, USER)
|
|
165
|
+
store.set(updateAuthUserAtom, { name: "Yona A.", avatar: "/a.png" })
|
|
166
|
+
|
|
167
|
+
expect(store.get(authUserAtom)).toEqual({
|
|
168
|
+
...USER,
|
|
169
|
+
name: "Yona A.",
|
|
170
|
+
avatar: "/a.png",
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test("is a no-op when signed out", () => {
|
|
175
|
+
const store = createStore()
|
|
176
|
+
store.set(updateAuthUserAtom, { name: "Nobody" })
|
|
177
|
+
expect(store.get(authUserAtom)).toBeNull()
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe("auth flow atoms · reset and teardown", () => {
|
|
182
|
+
test("resetAuthFlow restores the initial values", () => {
|
|
183
|
+
const store = createStore()
|
|
184
|
+
store.set(beginAuthVerificationAtom("r"), {
|
|
185
|
+
identifier: "+15551234567",
|
|
186
|
+
channel: "sms",
|
|
187
|
+
step: "signup",
|
|
188
|
+
})
|
|
189
|
+
store.set(failAuthFlowAtom("r"), "nope")
|
|
190
|
+
|
|
191
|
+
store.set(resetAuthFlowAtom("r"))
|
|
192
|
+
expect(store.get(authFlowStateAtom("r"))).toEqual({
|
|
193
|
+
step: "login",
|
|
194
|
+
identifier: null,
|
|
195
|
+
maskedIdentifier: null,
|
|
196
|
+
channel: "email",
|
|
197
|
+
status: "idle",
|
|
198
|
+
error: null,
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test("removeAuthFlowInstance drops the slice back to its defaults", () => {
|
|
203
|
+
const store = createStore()
|
|
204
|
+
store.set(beginAuthVerificationAtom("gone"), {
|
|
205
|
+
identifier: "yo@example.com",
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
removeAuthFlowInstance("gone")
|
|
209
|
+
expect(store.get(authFlowStepAtom("gone"))).toBe("login")
|
|
210
|
+
expect(store.get(authFlowIdentifierAtom("gone"))).toBeNull()
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe("ExegiaProvider · siblings share one flow", () => {
|
|
215
|
+
/** Drives the flow; holds no state of its own. */
|
|
216
|
+
function Begin() {
|
|
217
|
+
const { beginVerification } = useAuthFlowActions()
|
|
218
|
+
return (
|
|
219
|
+
<button
|
|
220
|
+
onClick={() => beginVerification({ identifier: "yo@example.com" })}
|
|
221
|
+
type="button"
|
|
222
|
+
>
|
|
223
|
+
begin
|
|
224
|
+
</button>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Reads the flow; no relationship to `Begin` beyond the default id. */
|
|
229
|
+
function Destination() {
|
|
230
|
+
const flow = useAuthFlow()
|
|
231
|
+
return <p>{flow.maskedIdentifier ?? "no destination"}</p>
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
test("one sibling's beginVerification shows in the other's read", async () => {
|
|
235
|
+
const user = userEvent.setup()
|
|
236
|
+
render(
|
|
237
|
+
<ExegiaProvider store={createStore()}>
|
|
238
|
+
<Begin />
|
|
239
|
+
<Destination />
|
|
240
|
+
</ExegiaProvider>
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
expect(screen.getByText("no destination")).toBeDefined()
|
|
244
|
+
await user.click(screen.getByRole("button", { name: "begin" }))
|
|
245
|
+
expect(await screen.findByText("y•••@example.com")).toBeDefined()
|
|
246
|
+
})
|
|
247
|
+
})
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-instance Jotai state for the auth flow — which auth block the app is
|
|
3
|
+
* showing, the identifier in flight, and the attempt status.
|
|
4
|
+
*
|
|
5
|
+
* Every atom is a module-level family keyed by a flow id, so one
|
|
6
|
+
* `ExegiaProvider` at the app root is enough: a modal login and a full-page
|
|
7
|
+
* login can coexist in one store, and any component can drive a flow by id
|
|
8
|
+
* without holding a controller. Most apps have one flow and use
|
|
9
|
+
* `DEFAULT_AUTH_FLOW_ID`.
|
|
10
|
+
*
|
|
11
|
+
* This is a coordination layer only. The auth blocks keep passwords, codes
|
|
12
|
+
* and field values in local `useState` on purpose — never store those here.
|
|
13
|
+
*
|
|
14
|
+
* Import direction: `auth-session-atom.ts` is the LEAF. This module imports
|
|
15
|
+
* `signInAtom` from it (`completeAuthFlowAtom` signs the session in) and
|
|
16
|
+
* defines the composite `endAuthSessionAtom`; the session module never
|
|
17
|
+
* imports from here.
|
|
18
|
+
*/
|
|
19
|
+
import { atom } from "jotai"
|
|
20
|
+
import type { Getter, Setter } from "jotai"
|
|
21
|
+
|
|
22
|
+
import { signInAtom, signOutAtom } from "./auth-session-atom"
|
|
23
|
+
import type { AuthStatus } from "./auth-shell"
|
|
24
|
+
import type {
|
|
25
|
+
AuthFlowChannel,
|
|
26
|
+
AuthFlowId,
|
|
27
|
+
AuthFlowState,
|
|
28
|
+
AuthFlowStep,
|
|
29
|
+
AuthUser,
|
|
30
|
+
BeginAuthVerificationOptions,
|
|
31
|
+
} from "./auth-state-type"
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_AUTH_FLOW_ID: AuthFlowId = "default"
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A string-keyed atom family (same in-house shape as `tree-atom.ts`:
|
|
37
|
+
* `jotai/utils`' `atomFamily` is deprecated for Jotai v3, and we need only
|
|
38
|
+
* the string-keyed case with a `remove`). Dropping a key lets the store's
|
|
39
|
+
* WeakMap release that instance's state.
|
|
40
|
+
*/
|
|
41
|
+
type Family<AtomType> = ((id: AuthFlowId) => AtomType) & {
|
|
42
|
+
remove: (id: AuthFlowId) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every family, so `removeAuthFlowInstance` can drop an id from all. */
|
|
46
|
+
const families: { remove: (id: AuthFlowId) => void }[] = []
|
|
47
|
+
|
|
48
|
+
function keyed<AtomType>(create: (id: AuthFlowId) => AtomType) {
|
|
49
|
+
const cache = new Map<AuthFlowId, AtomType>()
|
|
50
|
+
const family = ((id: AuthFlowId) => {
|
|
51
|
+
let instance = cache.get(id)
|
|
52
|
+
if (instance === undefined) {
|
|
53
|
+
instance = create(id)
|
|
54
|
+
cache.set(id, instance)
|
|
55
|
+
}
|
|
56
|
+
return instance
|
|
57
|
+
}) as Family<AtomType>
|
|
58
|
+
family.remove = (id: AuthFlowId) => {
|
|
59
|
+
cache.delete(id)
|
|
60
|
+
}
|
|
61
|
+
families.push(family)
|
|
62
|
+
return family
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stateFamily<Value>(name: string, initialValue: Value) {
|
|
66
|
+
return keyed((id) => {
|
|
67
|
+
const instance = atom(initialValue)
|
|
68
|
+
instance.debugLabel = `auth-flow/${id}/${name}`
|
|
69
|
+
return instance
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readFamily<Value>(
|
|
74
|
+
name: string,
|
|
75
|
+
read: (get: Getter, id: AuthFlowId) => Value
|
|
76
|
+
) {
|
|
77
|
+
return keyed((id) => {
|
|
78
|
+
const instance = atom((get) => read(get, id))
|
|
79
|
+
instance.debugLabel = `auth-flow/${id}/${name}`
|
|
80
|
+
return instance
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function actionFamily<Args extends unknown[]>(
|
|
85
|
+
name: string,
|
|
86
|
+
write: (get: Getter, set: Setter, id: AuthFlowId, ...args: Args) => void
|
|
87
|
+
) {
|
|
88
|
+
return keyed((id) => {
|
|
89
|
+
const instance = atom(null, (get, set, ...args: Args) =>
|
|
90
|
+
write(get, set, id, ...args)
|
|
91
|
+
)
|
|
92
|
+
instance.debugLabel = `auth-flow/${id}/${name}`
|
|
93
|
+
return instance
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── masking ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mask an identifier for display. Email → first character of the local part
|
|
101
|
+
* plus `"•••"`, domain intact (`"y•••@example.com"`; a 1-char local part
|
|
102
|
+
* still gets `"y•••@…"`). Non-email (sms) → `"•••"` + last 4 characters;
|
|
103
|
+
* fewer than 4 characters masks everything as `"•••"`.
|
|
104
|
+
*/
|
|
105
|
+
export function maskAuthIdentifier(identifier: string): string {
|
|
106
|
+
const at = identifier.indexOf("@")
|
|
107
|
+
if (at > 0) return `${identifier[0]}•••${identifier.slice(at)}`
|
|
108
|
+
// At 4 characters or fewer, "keep the last 4" would reveal the whole
|
|
109
|
+
// identifier while implying redaction — mask everything instead.
|
|
110
|
+
if (identifier.length <= 4) return "•••"
|
|
111
|
+
return `•••${identifier.slice(-4)}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── primitives ───────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
export const authFlowStepAtom = stateFamily<AuthFlowStep>("step", "login")
|
|
117
|
+
|
|
118
|
+
/** An email or phone number in flight — never a password or a code. */
|
|
119
|
+
export const authFlowIdentifierAtom = stateFamily<string | null>(
|
|
120
|
+
"identifier",
|
|
121
|
+
null
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
export const authFlowChannelAtom = stateFamily<AuthFlowChannel>(
|
|
125
|
+
"channel",
|
|
126
|
+
"email"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
export const authFlowStatusAtom = stateFamily<AuthStatus>("status", "idle")
|
|
130
|
+
|
|
131
|
+
export const authFlowErrorAtom = stateFamily<string | null>("error", null)
|
|
132
|
+
|
|
133
|
+
// ── derived ──────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/** `identifier` masked for display, `null` while nothing is in flight. */
|
|
136
|
+
export const authFlowMaskedIdentifierAtom = readFamily<string | null>(
|
|
137
|
+
"maskedIdentifier",
|
|
138
|
+
(get, id) => {
|
|
139
|
+
const identifier = get(authFlowIdentifierAtom(id))
|
|
140
|
+
return identifier === null ? null : maskAuthIdentifier(identifier)
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
export const authFlowStateAtom = readFamily<AuthFlowState>(
|
|
145
|
+
"state",
|
|
146
|
+
(get, id) => ({
|
|
147
|
+
step: get(authFlowStepAtom(id)),
|
|
148
|
+
identifier: get(authFlowIdentifierAtom(id)),
|
|
149
|
+
maskedIdentifier: get(authFlowMaskedIdentifierAtom(id)),
|
|
150
|
+
channel: get(authFlowChannelAtom(id)),
|
|
151
|
+
status: get(authFlowStatusAtom(id)),
|
|
152
|
+
error: get(authFlowErrorAtom(id)),
|
|
153
|
+
})
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
// ── actions ──────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/** Show a step with a clean slate (`"idle"`, no error). Keeps the
|
|
159
|
+
* identifier so "back to verify" flows don't lose the destination. */
|
|
160
|
+
export const goToAuthStepAtom = actionFamily<[step: AuthFlowStep]>(
|
|
161
|
+
"goToStep",
|
|
162
|
+
(_get, set, id, step) => {
|
|
163
|
+
set(authFlowStepAtom(id), step)
|
|
164
|
+
set(authFlowStatusAtom(id), "idle")
|
|
165
|
+
set(authFlowErrorAtom(id), null)
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
/** Record the identifier in flight and move to the verification step. */
|
|
170
|
+
export const beginAuthVerificationAtom = actionFamily<
|
|
171
|
+
[options: BeginAuthVerificationOptions]
|
|
172
|
+
>(
|
|
173
|
+
"beginVerification",
|
|
174
|
+
(_get, set, id, { identifier, channel = "email", step = "verify-code" }) => {
|
|
175
|
+
set(authFlowIdentifierAtom(id), identifier)
|
|
176
|
+
set(authFlowChannelAtom(id), channel)
|
|
177
|
+
set(authFlowStepAtom(id), step)
|
|
178
|
+
set(authFlowStatusAtom(id), "idle")
|
|
179
|
+
set(authFlowErrorAtom(id), null)
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
/** Mark the flow `"loading"` while an async attempt is in flight. */
|
|
184
|
+
export const beginAuthAttemptAtom = actionFamily<[]>(
|
|
185
|
+
"beginAttempt",
|
|
186
|
+
(_get, set, id) => {
|
|
187
|
+
set(authFlowStatusAtom(id), "loading")
|
|
188
|
+
set(authFlowErrorAtom(id), null)
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
export const failAuthFlowAtom = actionFamily<[message: string]>(
|
|
193
|
+
"fail",
|
|
194
|
+
(_get, set, id, message) => {
|
|
195
|
+
set(authFlowStatusAtom(id), "error")
|
|
196
|
+
set(authFlowErrorAtom(id), message)
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
/** Mark the flow `"success"` and land on the success step. When `user` is
|
|
201
|
+
* given, also sign the session in — one atomic write for the whole
|
|
202
|
+
* "verification passed" transition. */
|
|
203
|
+
export const completeAuthFlowAtom = actionFamily<[user?: AuthUser]>(
|
|
204
|
+
"complete",
|
|
205
|
+
(_get, set, id, user) => {
|
|
206
|
+
set(authFlowStatusAtom(id), "success")
|
|
207
|
+
set(authFlowErrorAtom(id), null)
|
|
208
|
+
set(authFlowStepAtom(id), "success")
|
|
209
|
+
if (user) set(signInAtom, user)
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
/** Back to the initial values (login step, no identifier, `"email"`,
|
|
214
|
+
* `"idle"`, no error). */
|
|
215
|
+
export const resetAuthFlowAtom = actionFamily<[]>("reset", (_get, set, id) => {
|
|
216
|
+
set(authFlowStepAtom(id), "login")
|
|
217
|
+
set(authFlowIdentifierAtom(id), null)
|
|
218
|
+
set(authFlowChannelAtom(id), "email")
|
|
219
|
+
set(authFlowStatusAtom(id), "idle")
|
|
220
|
+
set(authFlowErrorAtom(id), null)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Sign out and return the default flow to the login step — the one to wire
|
|
225
|
+
* to a logout button. `useAuthSessionActions().signOut` is this atom;
|
|
226
|
+
* `signOutAtom` in `auth-session-atom.ts` clears the session only.
|
|
227
|
+
*/
|
|
228
|
+
export const endAuthSessionAtom = atom(null, (_get, set) => {
|
|
229
|
+
set(signOutAtom)
|
|
230
|
+
set(resetAuthFlowAtom(DEFAULT_AUTH_FLOW_ID))
|
|
231
|
+
})
|
|
232
|
+
endAuthSessionAtom.debugLabel = "auth-flow/endAuthSession"
|
|
233
|
+
|
|
234
|
+
/** Drop every atom for `id`. Call on teardown of an explicit flow id; the
|
|
235
|
+
* default flow usually just gets `resetAuthFlowAtom`. */
|
|
236
|
+
export function removeAuthFlowInstance(id: AuthFlowId): void {
|
|
237
|
+
for (const family of families) family.remove(id)
|
|
238
|
+
}
|