@shipfox/client-auth 0.2.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 (225) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +7 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +111 -0
  8. package/LICENSE +21 -0
  9. package/dist/components/auth-guard.d.ts +5 -0
  10. package/dist/components/auth-guard.d.ts.map +1 -0
  11. package/dist/components/auth-guard.js +61 -0
  12. package/dist/components/auth-guard.js.map +1 -0
  13. package/dist/components/auth-provider.d.ts +11 -0
  14. package/dist/components/auth-provider.d.ts.map +1 -0
  15. package/dist/components/auth-provider.js +21 -0
  16. package/dist/components/auth-provider.js.map +1 -0
  17. package/dist/components/auth-shell.d.ts +11 -0
  18. package/dist/components/auth-shell.d.ts.map +1 -0
  19. package/dist/components/auth-shell.js +63 -0
  20. package/dist/components/auth-shell.js.map +1 -0
  21. package/dist/components/redirect-target.d.ts +2 -0
  22. package/dist/components/redirect-target.d.ts.map +1 -0
  23. package/dist/components/redirect-target.js +22 -0
  24. package/dist/components/redirect-target.js.map +1 -0
  25. package/dist/components/workspace-crumb.d.ts +3 -0
  26. package/dist/components/workspace-crumb.d.ts.map +1 -0
  27. package/dist/components/workspace-crumb.js +3 -0
  28. package/dist/components/workspace-crumb.js.map +1 -0
  29. package/dist/components/workspace-switcher.d.ts +6 -0
  30. package/dist/components/workspace-switcher.d.ts.map +1 -0
  31. package/dist/components/workspace-switcher.js +91 -0
  32. package/dist/components/workspace-switcher.js.map +1 -0
  33. package/dist/components/workspace-switcher.stories.d.ts +26 -0
  34. package/dist/components/workspace-switcher.stories.d.ts.map +1 -0
  35. package/dist/components/workspace-switcher.stories.js +168 -0
  36. package/dist/components/workspace-switcher.stories.js.map +1 -0
  37. package/dist/feature.d.ts +33 -0
  38. package/dist/feature.d.ts.map +1 -0
  39. package/dist/feature.js +43 -0
  40. package/dist/feature.js.map +1 -0
  41. package/dist/hooks/api/login-auth.d.ts +29 -0
  42. package/dist/hooks/api/login-auth.d.ts.map +1 -0
  43. package/dist/hooks/api/login-auth.js +42 -0
  44. package/dist/hooks/api/login-auth.js.map +1 -0
  45. package/dist/hooks/api/logout-auth.d.ts +2 -0
  46. package/dist/hooks/api/logout-auth.d.ts.map +1 -0
  47. package/dist/hooks/api/logout-auth.js +32 -0
  48. package/dist/hooks/api/logout-auth.js.map +1 -0
  49. package/dist/hooks/api/password-reset-auth.d.ts +33 -0
  50. package/dist/hooks/api/password-reset-auth.d.ts.map +1 -0
  51. package/dist/hooks/api/password-reset-auth.js +48 -0
  52. package/dist/hooks/api/password-reset-auth.js.map +1 -0
  53. package/dist/hooks/api/refresh-auth.d.ts +2 -0
  54. package/dist/hooks/api/refresh-auth.d.ts.map +1 -0
  55. package/dist/hooks/api/refresh-auth.js +3 -0
  56. package/dist/hooks/api/refresh-auth.js.map +1 -0
  57. package/dist/hooks/api/signup-auth.d.ts +27 -0
  58. package/dist/hooks/api/signup-auth.d.ts.map +1 -0
  59. package/dist/hooks/api/signup-auth.js +15 -0
  60. package/dist/hooks/api/signup-auth.js.map +1 -0
  61. package/dist/hooks/api/verify-email-auth.d.ts +20 -0
  62. package/dist/hooks/api/verify-email-auth.d.ts.map +1 -0
  63. package/dist/hooks/api/verify-email-auth.js +48 -0
  64. package/dist/hooks/api/verify-email-auth.js.map +1 -0
  65. package/dist/hooks/api/workspace-auth.d.ts +22 -0
  66. package/dist/hooks/api/workspace-auth.d.ts.map +1 -0
  67. package/dist/hooks/api/workspace-auth.js +25 -0
  68. package/dist/hooks/api/workspace-auth.js.map +1 -0
  69. package/dist/hooks/index.d.ts +11 -0
  70. package/dist/hooks/index.d.ts.map +1 -0
  71. package/dist/hooks/index.js +11 -0
  72. package/dist/hooks/index.js.map +1 -0
  73. package/dist/hooks/use-active-workspace.d.ts +2 -0
  74. package/dist/hooks/use-active-workspace.d.ts.map +1 -0
  75. package/dist/hooks/use-active-workspace.js +3 -0
  76. package/dist/hooks/use-active-workspace.js.map +1 -0
  77. package/dist/hooks/use-auth-state.d.ts +2 -0
  78. package/dist/hooks/use-auth-state.d.ts.map +1 -0
  79. package/dist/hooks/use-auth-state.js +3 -0
  80. package/dist/hooks/use-auth-state.js.map +1 -0
  81. package/dist/index.d.ts +15 -0
  82. package/dist/index.d.ts.map +1 -0
  83. package/dist/index.js +16 -0
  84. package/dist/index.js.map +1 -0
  85. package/dist/pages/email-verification-resend-model.d.ts +7 -0
  86. package/dist/pages/email-verification-resend-model.d.ts.map +1 -0
  87. package/dist/pages/email-verification-resend-model.js +15 -0
  88. package/dist/pages/email-verification-resend-model.js.map +1 -0
  89. package/dist/pages/form-errors.d.ts +19 -0
  90. package/dist/pages/form-errors.d.ts.map +1 -0
  91. package/dist/pages/form-errors.js +51 -0
  92. package/dist/pages/form-errors.js.map +1 -0
  93. package/dist/pages/form-utils.d.ts +2 -0
  94. package/dist/pages/form-utils.d.ts.map +1 -0
  95. package/dist/pages/form-utils.js +28 -0
  96. package/dist/pages/form-utils.js.map +1 -0
  97. package/dist/pages/invitation-context.d.ts +31 -0
  98. package/dist/pages/invitation-context.d.ts.map +1 -0
  99. package/dist/pages/invitation-context.js +50 -0
  100. package/dist/pages/invitation-context.js.map +1 -0
  101. package/dist/pages/login-page.d.ts +2 -0
  102. package/dist/pages/login-page.d.ts.map +1 -0
  103. package/dist/pages/login-page.js +214 -0
  104. package/dist/pages/login-page.js.map +1 -0
  105. package/dist/pages/logout-page.d.ts +2 -0
  106. package/dist/pages/logout-page.d.ts.map +1 -0
  107. package/dist/pages/logout-page.js +45 -0
  108. package/dist/pages/logout-page.js.map +1 -0
  109. package/dist/pages/password-reset-page.d.ts +2 -0
  110. package/dist/pages/password-reset-page.d.ts.map +1 -0
  111. package/dist/pages/password-reset-page.js +265 -0
  112. package/dist/pages/password-reset-page.js.map +1 -0
  113. package/dist/pages/signup-page.d.ts +2 -0
  114. package/dist/pages/signup-page.d.ts.map +1 -0
  115. package/dist/pages/signup-page.js +384 -0
  116. package/dist/pages/signup-page.js.map +1 -0
  117. package/dist/pages/verify-email-page.d.ts +2 -0
  118. package/dist/pages/verify-email-page.d.ts.map +1 -0
  119. package/dist/pages/verify-email-page.js +57 -0
  120. package/dist/pages/verify-email-page.js.map +1 -0
  121. package/dist/pages/workspace-onboarding-page.d.ts +2 -0
  122. package/dist/pages/workspace-onboarding-page.d.ts.map +1 -0
  123. package/dist/pages/workspace-onboarding-page.js +294 -0
  124. package/dist/pages/workspace-onboarding-page.js.map +1 -0
  125. package/dist/routes/index.d.ts +10 -0
  126. package/dist/routes/index.d.ts.map +1 -0
  127. package/dist/routes/index.js +29 -0
  128. package/dist/routes/index.js.map +1 -0
  129. package/dist/routes/login.d.ts +5 -0
  130. package/dist/routes/login.d.ts.map +1 -0
  131. package/dist/routes/login.js +11 -0
  132. package/dist/routes/login.js.map +1 -0
  133. package/dist/routes/logout.d.ts +6 -0
  134. package/dist/routes/logout.d.ts.map +1 -0
  135. package/dist/routes/logout.js +7 -0
  136. package/dist/routes/logout.js.map +1 -0
  137. package/dist/routes/reset.d.ts +5 -0
  138. package/dist/routes/reset.d.ts.map +1 -0
  139. package/dist/routes/reset.js +11 -0
  140. package/dist/routes/reset.js.map +1 -0
  141. package/dist/routes/signup.d.ts +5 -0
  142. package/dist/routes/signup.d.ts.map +1 -0
  143. package/dist/routes/signup.js +11 -0
  144. package/dist/routes/signup.js.map +1 -0
  145. package/dist/routes/verify-email.d.ts +5 -0
  146. package/dist/routes/verify-email.d.ts.map +1 -0
  147. package/dist/routes/verify-email.js +11 -0
  148. package/dist/routes/verify-email.js.map +1 -0
  149. package/dist/routes/workspace-onboarding.d.ts +13 -0
  150. package/dist/routes/workspace-onboarding.d.ts.map +1 -0
  151. package/dist/routes/workspace-onboarding.js +18 -0
  152. package/dist/routes/workspace-onboarding.js.map +1 -0
  153. package/dist/state/auth.d.ts +10 -0
  154. package/dist/state/auth.d.ts.map +1 -0
  155. package/dist/state/auth.js +9 -0
  156. package/dist/state/auth.js.map +1 -0
  157. package/dist/state/last-workspace.d.ts +2 -0
  158. package/dist/state/last-workspace.d.ts.map +1 -0
  159. package/dist/state/last-workspace.js +3 -0
  160. package/dist/state/last-workspace.js.map +1 -0
  161. package/dist/tsconfig.test.tsbuildinfo +1 -0
  162. package/package.json +116 -0
  163. package/src/components/auth-guard.tsx +58 -0
  164. package/src/components/auth-provider.test.tsx +164 -0
  165. package/src/components/auth-provider.tsx +24 -0
  166. package/src/components/auth-shell.tsx +53 -0
  167. package/src/components/redirect-target.test.ts +60 -0
  168. package/src/components/redirect-target.ts +21 -0
  169. package/src/components/workspace-crumb.tsx +2 -0
  170. package/src/components/workspace-switcher.stories.tsx +169 -0
  171. package/src/components/workspace-switcher.tsx +76 -0
  172. package/src/feature.ts +18 -0
  173. package/src/hooks/api/login-auth.test.ts +45 -0
  174. package/src/hooks/api/login-auth.ts +41 -0
  175. package/src/hooks/api/logout-auth.ts +26 -0
  176. package/src/hooks/api/password-reset-auth.test.ts +68 -0
  177. package/src/hooks/api/password-reset-auth.ts +51 -0
  178. package/src/hooks/api/refresh-auth.test.ts +37 -0
  179. package/src/hooks/api/refresh-auth.ts +5 -0
  180. package/src/hooks/api/signup-auth.ts +11 -0
  181. package/src/hooks/api/verify-email-auth.ts +55 -0
  182. package/src/hooks/api/workspace-auth.test.ts +40 -0
  183. package/src/hooks/api/workspace-auth.ts +25 -0
  184. package/src/hooks/index.ts +13 -0
  185. package/src/hooks/use-active-workspace.ts +1 -0
  186. package/src/hooks/use-auth-state.ts +1 -0
  187. package/src/index.ts +18 -0
  188. package/src/pages/email-verification-resend-model.test.ts +52 -0
  189. package/src/pages/email-verification-resend-model.ts +21 -0
  190. package/src/pages/form-errors.test.ts +114 -0
  191. package/src/pages/form-errors.ts +45 -0
  192. package/src/pages/form-utils.test.ts +37 -0
  193. package/src/pages/form-utils.ts +29 -0
  194. package/src/pages/invitation-context.ts +57 -0
  195. package/src/pages/login-page.test.tsx +128 -0
  196. package/src/pages/login-page.tsx +184 -0
  197. package/src/pages/logout-page.tsx +33 -0
  198. package/src/pages/password-reset-page.test.tsx +109 -0
  199. package/src/pages/password-reset-page.tsx +222 -0
  200. package/src/pages/signup-page.test.tsx +289 -0
  201. package/src/pages/signup-page.tsx +354 -0
  202. package/src/pages/verify-email-page.test.tsx +82 -0
  203. package/src/pages/verify-email-page.tsx +48 -0
  204. package/src/pages/workspace-onboarding-page.test.tsx +161 -0
  205. package/src/pages/workspace-onboarding-page.tsx +213 -0
  206. package/src/routes/index.tsx +18 -0
  207. package/src/routes/login.tsx +11 -0
  208. package/src/routes/logout.tsx +4 -0
  209. package/src/routes/reset.tsx +11 -0
  210. package/src/routes/signup.tsx +11 -0
  211. package/src/routes/verify-email.tsx +11 -0
  212. package/src/routes/workspace-onboarding.tsx +14 -0
  213. package/src/state/auth.ts +18 -0
  214. package/src/state/last-workspace.test.ts +63 -0
  215. package/src/state/last-workspace.ts +5 -0
  216. package/test/factories/user.ts +17 -0
  217. package/test/pages.tsx +83 -0
  218. package/test/setup.ts +3 -0
  219. package/test/utils.ts +11 -0
  220. package/tsconfig.build.json +10 -0
  221. package/tsconfig.build.tsbuildinfo +1 -0
  222. package/tsconfig.json +3 -0
  223. package/tsconfig.test.json +8 -0
  224. package/vercel.json +8 -0
  225. package/vitest.config.ts +71 -0
package/package.json ADDED
@@ -0,0 +1,116 @@
1
+ {
2
+ "name": "@shipfox/client-auth",
3
+ "license": "MIT",
4
+ "version": "0.2.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
8
+ "directory": "libs/client/auth"
9
+ },
10
+ "type": "module",
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "imports": {
14
+ "#*": {
15
+ "types": "./src/*",
16
+ "workspace-source": "./src/*",
17
+ "development": "./src/*",
18
+ "default": "./dist/*"
19
+ },
20
+ "#test/*": "./test/*"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "development": {
25
+ "types": "./src/index.ts",
26
+ "default": "./src/index.ts"
27
+ },
28
+ "default": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ },
33
+ "./feature": {
34
+ "development": {
35
+ "types": "./src/feature.ts",
36
+ "default": "./src/feature.ts"
37
+ },
38
+ "default": {
39
+ "types": "./dist/feature.d.ts",
40
+ "default": "./dist/feature.js"
41
+ }
42
+ },
43
+ "./routes/*": {
44
+ "development": {
45
+ "types": "./src/routes/*.tsx",
46
+ "default": "./src/routes/*.tsx"
47
+ },
48
+ "default": {
49
+ "types": "./dist/routes/*.d.ts",
50
+ "default": "./dist/routes/*.js"
51
+ }
52
+ }
53
+ },
54
+ "dependencies": {
55
+ "@swc/helpers": "^0.5.17",
56
+ "@tanstack/react-form": "^1.32.0",
57
+ "@shipfox/api-auth-dto": "2.0.0",
58
+ "@shipfox/api-workspaces-dto": "2.0.0",
59
+ "@shipfox/client-shell": "0.2.0",
60
+ "@shipfox/client-api": "0.2.0",
61
+ "@shipfox/client-ui": "0.2.0",
62
+ "@shipfox/react-ui": "0.3.1"
63
+ },
64
+ "peerDependencies": {
65
+ "@tanstack/react-query": "^5.101.0",
66
+ "@tanstack/react-router": "^1.170.16",
67
+ "jotai": "^2.19.1",
68
+ "react": "^19.0.0",
69
+ "react-dom": "^19.0.0"
70
+ },
71
+ "devDependencies": {
72
+ "@argos-ci/cli": "^5.0.0",
73
+ "@argos-ci/storybook": "^6.0.0",
74
+ "@faker-js/faker": "^10.4.0",
75
+ "@storybook/addon-vitest": "^10.3.6",
76
+ "@storybook/react": "^10.0.0",
77
+ "@storybook/react-vite": "^10.0.0",
78
+ "@tailwindcss/vite": "^4.1.13",
79
+ "@tanstack/react-query": "^5.101.0",
80
+ "@tanstack/react-router": "^1.170.16",
81
+ "@testing-library/jest-dom": "^6.6.3",
82
+ "@testing-library/react": "^16.3.2",
83
+ "@testing-library/user-event": "^14.6.1",
84
+ "@types/react": "^19.1.11",
85
+ "@types/react-dom": "^19.1.7",
86
+ "@vitejs/plugin-react": "^6.0.0",
87
+ "@vitest/browser-playwright": "^4.1.5",
88
+ "fishery": "^2.4.0",
89
+ "jotai": "^2.19.1",
90
+ "jsdom": "^29.0.0",
91
+ "react": "^19.1.1",
92
+ "react-dom": "^19.1.1",
93
+ "storybook": "^10.0.0",
94
+ "storybook-addon-pseudo-states": "^10.0.0",
95
+ "tailwindcss": "^4.1.13",
96
+ "vite": "^8.0.3",
97
+ "vitest": "^4.1.5",
98
+ "@shipfox/biome": "1.8.1",
99
+ "@shipfox/client-test-setup": "0.0.2",
100
+ "@shipfox/ts-config": "1.3.8",
101
+ "@shipfox/typescript": "1.1.6",
102
+ "@shipfox/swc": "1.2.5",
103
+ "@shipfox/vitest": "1.2.2"
104
+ },
105
+ "scripts": {
106
+ "build": "shipfox-swc",
107
+ "check": "shipfox-biome-check",
108
+ "check:fix": "shipfox-biome-check --write",
109
+ "storybook": "storybook dev -p 6009",
110
+ "storybook:build": "storybook build -o storybook-static",
111
+ "test": "shipfox-vitest-run",
112
+ "test:watch": "shipfox-vitest-watch",
113
+ "type": "shipfox-tsc-check",
114
+ "type:emit": "shipfox-tsc-emit"
115
+ }
116
+ }
@@ -0,0 +1,58 @@
1
+ import {FullPageLoader} from '@shipfox/react-ui/loader';
2
+ import {Navigate, useRouter, useSearch} from '@tanstack/react-router';
3
+ import {type PropsWithChildren, useEffect} from 'react';
4
+ import {useAuthState} from '#hooks/use-auth-state.js';
5
+ import {WorkspaceOnboardingPage} from '#pages/workspace-onboarding-page.js';
6
+ import {sanitizeRedirectPath} from './redirect-target.js';
7
+
8
+ export function AuthGuard({children}: PropsWithChildren) {
9
+ const auth = useAuthState();
10
+
11
+ if (auth.isLoading) {
12
+ return <FullPageLoader />;
13
+ }
14
+
15
+ if (!auth.isAuthenticated) {
16
+ return <Navigate to="/auth/login" replace />;
17
+ }
18
+
19
+ return children;
20
+ }
21
+
22
+ export function GuestGuard({children}: PropsWithChildren) {
23
+ const auth = useAuthState();
24
+ const search = useSearch({strict: false}) as {redirect?: unknown};
25
+ const router = useRouter();
26
+ const target = sanitizeRedirectPath(search.redirect);
27
+
28
+ useEffect(() => {
29
+ if (auth.isAuthenticated && target !== undefined) {
30
+ // Bypass the typed route matcher — `target` is an arbitrary same-origin
31
+ // path resolved at runtime, so we let the URL change drive route resolution.
32
+ router.history.replace(target);
33
+ }
34
+ }, [auth.isAuthenticated, target, router]);
35
+
36
+ if (auth.isLoading) {
37
+ return <FullPageLoader />;
38
+ }
39
+
40
+ if (auth.isAuthenticated) {
41
+ if (target !== undefined) {
42
+ return <FullPageLoader />;
43
+ }
44
+ return <Navigate to="/" replace />;
45
+ }
46
+
47
+ return children;
48
+ }
49
+
50
+ export function WorkspaceGuard({children}: PropsWithChildren) {
51
+ const auth = useAuthState();
52
+
53
+ if (auth.isAuthenticated && !auth.hasWorkspace) {
54
+ return <WorkspaceOnboardingPage />;
55
+ }
56
+
57
+ return children;
58
+ }
@@ -0,0 +1,164 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {render, screen, waitFor} from '@testing-library/react';
3
+ import {useEffect, useRef} from 'react';
4
+ import {useLogoutAuth} from '#hooks/api/logout-auth.js';
5
+ import {useRefreshAuth} from '#hooks/api/refresh-auth.js';
6
+ import {useAuthState} from '#hooks/use-auth-state.js';
7
+ import {AuthProvider} from './auth-provider.js';
8
+
9
+ const user = {
10
+ id: '11111111-1111-4111-8111-111111111111',
11
+ email: 'noe@example.com',
12
+ name: 'Noe',
13
+ email_verified_at: '2026-04-27T00:00:00.000Z',
14
+ status: 'active' as const,
15
+ created_at: '2026-04-27T00:00:00.000Z',
16
+ updated_at: '2026-04-27T00:00:00.000Z',
17
+ };
18
+
19
+ function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
20
+ return new Response(JSON.stringify(body), {
21
+ headers: {'content-type': 'application/json'},
22
+ status: 200,
23
+ ...init,
24
+ });
25
+ }
26
+
27
+ function StatusProbe() {
28
+ const auth = useAuthState();
29
+ const logout = useLogoutAuth();
30
+
31
+ return (
32
+ <div>
33
+ <span data-testid="status">{auth.status}</span>
34
+ <span data-testid="email">{auth.user?.email ?? 'none'}</span>
35
+ <button type="button" onClick={() => void logout.mutateAsync()}>
36
+ Logout
37
+ </button>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ describe('AuthProvider', () => {
43
+ beforeEach(() => {
44
+ configureApiClient({
45
+ baseUrl: 'https://api.example.test',
46
+ fetchImpl: fetch,
47
+ getAccessToken: undefined,
48
+ refreshAccessToken: undefined,
49
+ });
50
+ });
51
+
52
+ test('boots from a successful refresh', async () => {
53
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({token: 'access-token', user}));
54
+ configureApiClient({fetchImpl});
55
+
56
+ render(
57
+ <AuthProvider>
58
+ <StatusProbe />
59
+ </AuthProvider>,
60
+ );
61
+
62
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'));
63
+ expect(screen.getByTestId('email')).toHaveTextContent('noe@example.com');
64
+ });
65
+
66
+ test('treats refresh 401 as a guest session', async () => {
67
+ const fetchImpl = vi
68
+ .fn()
69
+ .mockResolvedValue(
70
+ jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
71
+ );
72
+ configureApiClient({fetchImpl});
73
+
74
+ render(
75
+ <AuthProvider>
76
+ <StatusProbe />
77
+ </AuthProvider>,
78
+ );
79
+
80
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('guest'));
81
+ expect(screen.getByTestId('email')).toHaveTextContent('none');
82
+ });
83
+
84
+ test('keeps the current session when refresh fails with a server error', async () => {
85
+ const fetchImpl = vi
86
+ .fn()
87
+ .mockResolvedValueOnce(jsonResponse({token: 'access-token', user}))
88
+ .mockResolvedValueOnce(
89
+ jsonResponse({code: 'internal-error', message: 'Internal Server Error'}, {status: 500}),
90
+ );
91
+ configureApiClient({fetchImpl});
92
+ const onDone = vi.fn();
93
+
94
+ function RefreshAfterBootProbe() {
95
+ const auth = useAuthState();
96
+ const refresh = useRefreshAuth();
97
+ const didRefresh = useRef(false);
98
+
99
+ useEffect(() => {
100
+ if (auth.status !== 'authenticated' || didRefresh.current) return;
101
+ didRefresh.current = true;
102
+ void refresh().catch(onDone);
103
+ }, [auth.status, refresh]);
104
+
105
+ return <StatusProbe />;
106
+ }
107
+
108
+ render(
109
+ <AuthProvider>
110
+ <RefreshAfterBootProbe />
111
+ </AuthProvider>,
112
+ );
113
+
114
+ await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
115
+ expect(screen.getByTestId('status')).toHaveTextContent('authenticated');
116
+ expect(screen.getByTestId('email')).toHaveTextContent('noe@example.com');
117
+ });
118
+
119
+ test('deduplicates concurrent refresh calls', async () => {
120
+ const fetchImpl = vi
121
+ .fn()
122
+ .mockImplementation(() => Promise.resolve(jsonResponse({token: 'access-token', user})));
123
+ configureApiClient({fetchImpl});
124
+ const onDone = vi.fn();
125
+
126
+ function RefreshTwiceProbe() {
127
+ const refresh = useRefreshAuth();
128
+
129
+ useEffect(() => {
130
+ void Promise.all([refresh(), refresh()]).then(onDone);
131
+ }, [refresh]);
132
+
133
+ return <StatusProbe />;
134
+ }
135
+
136
+ render(
137
+ <AuthProvider>
138
+ <RefreshTwiceProbe />
139
+ </AuthProvider>,
140
+ );
141
+
142
+ await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
143
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
144
+ });
145
+
146
+ test('logout clears local state even when the API call fails', async () => {
147
+ const fetchImpl = vi
148
+ .fn()
149
+ .mockResolvedValueOnce(jsonResponse({token: 'access-token', user}))
150
+ .mockRejectedValueOnce(new Error('offline'));
151
+ configureApiClient({fetchImpl});
152
+
153
+ render(
154
+ <AuthProvider>
155
+ <StatusProbe />
156
+ </AuthProvider>,
157
+ );
158
+
159
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'));
160
+ screen.getByRole('button', {name: 'Logout'}).click();
161
+
162
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('guest'));
163
+ });
164
+ });
@@ -0,0 +1,24 @@
1
+ import {AuthRuntime} from '@shipfox/client-shell/runtime';
2
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
3
+ import {Provider as JotaiProvider} from 'jotai';
4
+ import {type PropsWithChildren, useState} from 'react';
5
+
6
+ export interface AuthProviderProps extends PropsWithChildren {
7
+ queryClient?: QueryClient;
8
+ }
9
+
10
+ /**
11
+ * Backwards-compatible provider for applications that have not adopted
12
+ * `composeClientApp` yet. The shell owns the runtime behavior in both paths.
13
+ */
14
+ export function AuthProvider({children, queryClient}: AuthProviderProps) {
15
+ const [fallbackQueryClient] = useState(() => new QueryClient());
16
+
17
+ return (
18
+ <QueryClientProvider client={queryClient ?? fallbackQueryClient}>
19
+ <JotaiProvider>
20
+ <AuthRuntime>{children}</AuthRuntime>
21
+ </JotaiProvider>
22
+ </QueryClientProvider>
23
+ );
24
+ }
@@ -0,0 +1,53 @@
1
+ import {Icon} from '@shipfox/react-ui/icon';
2
+ import {Header, Text} from '@shipfox/react-ui/typography';
3
+ import type {PropsWithChildren, ReactNode} from 'react';
4
+
5
+ interface AuthShellProps {
6
+ title: string;
7
+ description: string;
8
+ children: ReactNode;
9
+ className?: string;
10
+ }
11
+
12
+ export function AuthShell({title, description, children, className}: AuthShellProps) {
13
+ return (
14
+ <main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background-subtle-base px-24 py-32 max-[520px]:items-start max-[520px]:px-20 max-[520px]:pb-32 max-[520px]:pt-56">
15
+ <div
16
+ className="pointer-events-none absolute left-1/2 top-0 h-[380px] w-full max-w-[880px] -translate-x-1/2 -translate-y-[120px] opacity-55 max-[520px]:-translate-y-[170px] max-[520px]:opacity-35"
17
+ aria-hidden="true"
18
+ style={{
19
+ maskImage:
20
+ 'radial-gradient(ellipse 85% 100% at 50% 0%, #000 0%, #000 24%, transparent 78%)',
21
+ WebkitMaskImage:
22
+ 'radial-gradient(ellipse 85% 100% at 50% 0%, #000 0%, #000 24%, transparent 78%)',
23
+ }}
24
+ >
25
+ <div className="h-full w-full bg-[radial-gradient(circle,rgba(230,62,0,0.48)_1.6px,transparent_1.8px)] bg-[length:44px_44px]" />
26
+ </div>
27
+
28
+ <section
29
+ className={className ?? 'relative flex w-full max-w-[384px] flex-col items-stretch gap-28'}
30
+ aria-labelledby="auth-title"
31
+ >
32
+ <div className="flex flex-col items-center gap-16">
33
+ <div className="flex size-64 items-center justify-center rounded-12 border border-border-neutral-base bg-background-neutral-base p-10 shadow-button-neutral">
34
+ <Icon name="shipfox" className="size-42 text-background-highlight-interactive" />
35
+ </div>
36
+ <div className="flex min-w-[128px] flex-col items-center gap-4 text-center">
37
+ <Header id="auth-title" variant="h1">
38
+ {title}
39
+ </Header>
40
+ <Text size="sm" className="text-foreground-neutral-subtle">
41
+ {description}
42
+ </Text>
43
+ </div>
44
+ </div>
45
+ {children}
46
+ </section>
47
+ </main>
48
+ );
49
+ }
50
+
51
+ export function AuthActions({children}: PropsWithChildren) {
52
+ return <div className="flex flex-col gap-20">{children}</div>;
53
+ }
@@ -0,0 +1,60 @@
1
+ import {sanitizeRedirectPath} from './redirect-target.js';
2
+
3
+ describe('sanitizeRedirectPath', () => {
4
+ describe.each([
5
+ ['simple absolute path', '/foo'],
6
+ ['nested workspace path', '/workspaces/abc/projects/xyz'],
7
+ ['path with search', '/foo?bar=1'],
8
+ ['path with hash', '/foo#hash'],
9
+ ['path with search and hash', '/workspaces/abc?tab=runs#header'],
10
+ ])('accepts %s', (_label, input) => {
11
+ test('returns the original string', () => {
12
+ const result = sanitizeRedirectPath(input);
13
+
14
+ expect(result).toBe(input);
15
+ });
16
+ });
17
+
18
+ describe.each([
19
+ ['undefined', undefined],
20
+ ['null', null],
21
+ ['number', 42],
22
+ ['empty string', ''],
23
+ ['no leading slash', 'foo'],
24
+ ['protocol-relative URL', '//evil.com'],
25
+ ['triple-slash URL', '///evil.com'],
26
+ ['absolute https URL', 'https://evil.com'],
27
+ ['javascript scheme', 'javascript:alert(1)'],
28
+ ['plain /auth/login', '/auth/login'],
29
+ ['/auth bare', '/auth'],
30
+ ['/auth/reset with token', '/auth/reset?token=x'],
31
+ ['/auth with query bypass', '/auth?token=x'],
32
+ ['/auth with fragment bypass', '/auth#foo'],
33
+ ])('rejects %s', (_label, input) => {
34
+ test('returns undefined', () => {
35
+ const result = sanitizeRedirectPath(input);
36
+
37
+ expect(result).toBeUndefined();
38
+ });
39
+ });
40
+
41
+ describe('decode-then-check defenses', () => {
42
+ test('rejects percent-encoded /auth/* path', () => {
43
+ const result = sanitizeRedirectPath('/%61uth/login');
44
+
45
+ expect(result).toBeUndefined();
46
+ });
47
+
48
+ test('rejects percent-encoded protocol-relative URL', () => {
49
+ const result = sanitizeRedirectPath('/%2fevil.com');
50
+
51
+ expect(result).toBeUndefined();
52
+ });
53
+
54
+ test('rejects malformed percent-encoded input', () => {
55
+ const result = sanitizeRedirectPath('/%E0%80%80');
56
+
57
+ expect(result).toBeUndefined();
58
+ });
59
+ });
60
+ });
@@ -0,0 +1,21 @@
1
+ const SEARCH_OR_HASH_RE = /[?#]/u;
2
+
3
+ // Returns the input string only if it is a same-origin internal path safe to
4
+ // navigate to after authentication. We decode before checking so percent-encoded
5
+ // variants like `/%61uth/login` cannot bypass the `/auth/*` rejection.
6
+ export function sanitizeRedirectPath(value: unknown): string | undefined {
7
+ if (typeof value !== 'string') return undefined;
8
+ let decoded: string;
9
+ try {
10
+ decoded = decodeURIComponent(value);
11
+ } catch {
12
+ return undefined;
13
+ }
14
+ if (!decoded.startsWith('/')) return undefined;
15
+ if (decoded.startsWith('//')) return undefined;
16
+ // Strip search/hash before the /auth check so /auth?token=x and /auth#x
17
+ // cannot bypass the prefix match.
18
+ const pathOnly = decoded.split(SEARCH_OR_HASH_RE, 1)[0] ?? decoded;
19
+ if (pathOnly === '/auth' || pathOnly.startsWith('/auth/')) return undefined;
20
+ return value;
21
+ }
@@ -0,0 +1,2 @@
1
+ export type {WorkspaceCrumbProps} from '@shipfox/client-shell/runtime';
2
+ export {WorkspaceCrumb} from '@shipfox/client-shell/runtime';
@@ -0,0 +1,169 @@
1
+ import {argosScreenshot} from '@argos-ci/storybook/vitest';
2
+ import type {Meta, StoryObj} from '@storybook/react';
3
+ import {
4
+ createMemoryHistory,
5
+ createRootRoute,
6
+ createRoute,
7
+ createRouter,
8
+ Outlet,
9
+ RouterProvider,
10
+ } from '@tanstack/react-router';
11
+ import {createStore, Provider as JotaiProvider} from 'jotai';
12
+ import {useMemo} from 'react';
13
+ import {expect, userEvent, waitFor, within} from 'storybook/test';
14
+ import {authStateAtom, type Workspace} from '#state/auth.js';
15
+ import {WorkspaceSwitcher} from './workspace-switcher.js';
16
+
17
+ const ACTIVE_WORKSPACE_ID = '00000000-0000-4000-8000-000000000001';
18
+ const WORKSPACES = [
19
+ workspace(ACTIVE_WORKSPACE_ID, 'Alpha Workspace', 1),
20
+ workspace('00000000-0000-4000-8000-000000000002', 'Beta Workspace', 2),
21
+ workspace('00000000-0000-4000-8000-000000000003', 'Gamma Workspace', 3),
22
+ workspace('00000000-0000-4000-8000-000000000004', 'Delta Workspace', 4),
23
+ ];
24
+ const SINGLE_WORKSPACE = [workspace(ACTIVE_WORKSPACE_ID, 'Alpha Workspace', 1)];
25
+ const MANY_WORKSPACES = Array.from({length: 20}, (_, index) =>
26
+ workspace(
27
+ `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
28
+ `Workspace ${String(index + 1).padStart(2, '0')}`,
29
+ index + 1,
30
+ ),
31
+ );
32
+
33
+ interface WorkspaceSwitcherStoryProps {
34
+ activeWorkspaceId: string;
35
+ workspaces: Workspace[];
36
+ }
37
+
38
+ function workspace(id: string, name: string, index: number): Workspace {
39
+ return {
40
+ id,
41
+ name,
42
+ membershipId: `10000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
43
+ };
44
+ }
45
+
46
+ function WorkspaceSwitcherStory({activeWorkspaceId, workspaces}: WorkspaceSwitcherStoryProps) {
47
+ const store = useMemo(() => {
48
+ const nextStore = createStore();
49
+ nextStore.set(authStateAtom, {status: 'authenticated', workspaces});
50
+ return nextStore;
51
+ }, [workspaces]);
52
+ const router = useMemo(() => {
53
+ const rootRoute = createRootRoute({component: Outlet});
54
+ const workspaceRoute = createRoute({
55
+ getParentRoute: () => rootRoute,
56
+ path: '/workspaces/$wid',
57
+ component: () => (
58
+ <JotaiProvider store={store}>
59
+ <div className="min-h-screen bg-background-neutral-base p-24">
60
+ <div className="w-[280px] rounded-10 bg-background-neutral-overlay shadow-tooltip">
61
+ <WorkspaceSwitcher activeWorkspaceId={activeWorkspaceId} />
62
+ </div>
63
+ </div>
64
+ </JotaiProvider>
65
+ ),
66
+ });
67
+ const setupRoute = createRoute({
68
+ getParentRoute: () => rootRoute,
69
+ path: '/setup/workspaces/new',
70
+ component: () => <div />,
71
+ });
72
+
73
+ return createRouter({
74
+ routeTree: rootRoute.addChildren([workspaceRoute, setupRoute]),
75
+ history: createMemoryHistory({initialEntries: [`/workspaces/${activeWorkspaceId}`]}),
76
+ });
77
+ }, [activeWorkspaceId, store]);
78
+
79
+ return <RouterProvider router={router} />;
80
+ }
81
+
82
+ const meta = {
83
+ title: 'Auth/WorkspaceSwitcher',
84
+ component: WorkspaceSwitcherStory,
85
+ parameters: {
86
+ layout: 'fullscreen',
87
+ },
88
+ args: {
89
+ activeWorkspaceId: ACTIVE_WORKSPACE_ID,
90
+ workspaces: WORKSPACES,
91
+ },
92
+ } satisfies Meta<typeof WorkspaceSwitcherStory>;
93
+
94
+ export default meta;
95
+ type Story = StoryObj<typeof meta>;
96
+ type WorkspaceSwitcherStoryContext = Parameters<NonNullable<Story['play']>>[0];
97
+
98
+ async function captureSwitcher(ctx: WorkspaceSwitcherStoryContext, screenshotName: string) {
99
+ const canvas = within(ctx.canvasElement);
100
+ await canvas.findByRole('option', {name: 'Create workspace'});
101
+ await argosScreenshot(ctx, screenshotName);
102
+ }
103
+
104
+ export const Playground: Story = {
105
+ play: async (ctx) => {
106
+ await captureSwitcher(ctx, 'Workspace Switcher Open');
107
+ },
108
+ };
109
+
110
+ export const SingleWithCreate: Story = {
111
+ args: {
112
+ workspaces: SINGLE_WORKSPACE,
113
+ },
114
+ play: async (ctx) => {
115
+ await captureSwitcher(ctx, 'Workspace Switcher Single With Create');
116
+ },
117
+ };
118
+
119
+ export const EmptySearch: Story = {
120
+ play: async (ctx) => {
121
+ const canvas = within(ctx.canvasElement);
122
+
123
+ await userEvent.type(
124
+ await canvas.findByPlaceholderText('Search workspaces...'),
125
+ 'zzz-no-match',
126
+ );
127
+
128
+ await canvas.findByText('No workspaces found.');
129
+ await canvas.findByRole('option', {name: 'Create workspace'});
130
+ await argosScreenshot(ctx, 'Workspace Switcher Empty Search');
131
+ },
132
+ };
133
+
134
+ export const ManyOverflow: Story = {
135
+ args: {
136
+ activeWorkspaceId: MANY_WORKSPACES[0]?.id ?? ACTIVE_WORKSPACE_ID,
137
+ workspaces: MANY_WORKSPACES,
138
+ },
139
+ play: async (ctx) => {
140
+ await captureSwitcher(ctx, 'Workspace Switcher Many Overflow');
141
+ },
142
+ };
143
+
144
+ export const ManyOverflowScrolled: Story = {
145
+ args: {
146
+ activeWorkspaceId: MANY_WORKSPACES[0]?.id ?? ACTIVE_WORKSPACE_ID,
147
+ workspaces: MANY_WORKSPACES,
148
+ },
149
+ play: async (ctx) => {
150
+ const canvas = within(ctx.canvasElement);
151
+
152
+ await canvas.findByRole('option', {name: 'Workspace 01'});
153
+ await canvas.findByRole('option', {name: 'Create workspace'});
154
+
155
+ await canvas.findByRole('option', {name: 'Workspace 20'});
156
+ const firstOption = canvas.getAllByRole('option')[0];
157
+ let scrollContainer = firstOption?.parentElement ?? null;
158
+ while (scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
159
+ scrollContainer = scrollContainer.parentElement;
160
+ }
161
+ if (!scrollContainer) {
162
+ throw new Error('Workspace switcher scroll container was not found');
163
+ }
164
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
165
+
166
+ await waitFor(() => expect(canvas.getByRole('option', {name: 'Workspace 20'})).toBeVisible());
167
+ await argosScreenshot(ctx, 'Workspace Switcher Many Overflow Scrolled');
168
+ },
169
+ };