create-top-secret-starter 0.0.1 → 0.1.1

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 (57) hide show
  1. package/package.json +1 -1
  2. package/templates/template-router/src/api/auth/guards.ts +7 -0
  3. package/templates/template-router/src/api/auth/session-store.ts +17 -7
  4. package/templates/template-router/src/providers/theme.tsx +2 -0
  5. package/templates/template-start/.env.example +1 -0
  6. package/templates/template-start/.oxfmtrc.json +13 -0
  7. package/templates/template-start/.oxlintrc.json +8 -0
  8. package/templates/template-start/_gitignore +39 -0
  9. package/templates/template-start/components.json +25 -0
  10. package/templates/template-start/package.json +58 -0
  11. package/templates/template-start/public/favicon.svg +1 -0
  12. package/templates/template-start/src/api/auth/auth.test.ts +46 -0
  13. package/templates/template-start/src/api/auth/guards.ts +25 -0
  14. package/templates/template-start/src/api/auth/index.ts +46 -0
  15. package/templates/template-start/src/api/auth/refresh.test.ts +89 -0
  16. package/templates/template-start/src/api/auth/router-bridge.test.ts +74 -0
  17. package/templates/template-start/src/api/auth/router-bridge.ts +26 -0
  18. package/templates/template-start/src/api/auth/schema.ts +20 -0
  19. package/templates/template-start/src/api/auth/session-store.test.ts +101 -0
  20. package/templates/template-start/src/api/auth/session-store.ts +66 -0
  21. package/templates/template-start/src/api/index.ts +51 -0
  22. package/templates/template-start/src/components/status-page.tsx +68 -0
  23. package/templates/template-start/src/components/ui/button.tsx +58 -0
  24. package/templates/template-start/src/components/ui/dialog.tsx +136 -0
  25. package/templates/template-start/src/components/ui/empty.tsx +94 -0
  26. package/templates/template-start/src/components/ui/field.tsx +222 -0
  27. package/templates/template-start/src/components/ui/input.tsx +20 -0
  28. package/templates/template-start/src/components/ui/label.tsx +18 -0
  29. package/templates/template-start/src/components/ui/select.tsx +188 -0
  30. package/templates/template-start/src/components/ui/separator.tsx +21 -0
  31. package/templates/template-start/src/components/ui/skeleton.tsx +13 -0
  32. package/templates/template-start/src/components/ui/sonner.tsx +43 -0
  33. package/templates/template-start/src/components/ui/tooltip.tsx +52 -0
  34. package/templates/template-start/src/env.ts +20 -0
  35. package/templates/template-start/src/index.css +134 -0
  36. package/templates/template-start/src/lib/query-client.test.ts +82 -0
  37. package/templates/template-start/src/lib/query-client.ts +30 -0
  38. package/templates/template-start/src/lib/single-flight.test.ts +48 -0
  39. package/templates/template-start/src/lib/single-flight.ts +9 -0
  40. package/templates/template-start/src/lib/utils.ts +1 -0
  41. package/templates/template-start/src/mocks/mock-server.ts +149 -0
  42. package/templates/template-start/src/providers/index.tsx +27 -0
  43. package/templates/template-start/src/providers/theme.test.tsx +68 -0
  44. package/templates/template-start/src/providers/theme.tsx +78 -0
  45. package/templates/template-start/src/routeTree.gen.ts +102 -0
  46. package/templates/template-start/src/router.tsx +42 -0
  47. package/templates/template-start/src/routes/-error.tsx +34 -0
  48. package/templates/template-start/src/routes/-not-found.tsx +27 -0
  49. package/templates/template-start/src/routes/__root.tsx +55 -0
  50. package/templates/template-start/src/routes/_authenticated/index.tsx +9 -0
  51. package/templates/template-start/src/routes/_authenticated.tsx +38 -0
  52. package/templates/template-start/src/routes/sign-in.tsx +100 -0
  53. package/templates/template-start/src/test/setup.ts +5 -0
  54. package/templates/template-start/tsconfig.app.json +29 -0
  55. package/templates/template-start/tsconfig.json +7 -0
  56. package/templates/template-start/tsconfig.node.json +23 -0
  57. package/templates/template-start/vite.config.ts +31 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-top-secret-starter",
3
- "version": "0.0.1",
3
+ "version": "0.1.1",
4
4
  "description": "Scaffold a Vite + React 19 + TypeScript + shadcn starter with TanStack Router",
5
5
  "keywords": [
6
6
  "create",
@@ -7,12 +7,19 @@ import { sessionStore } from '@/api/auth/session-store'
7
7
  // never mirrors the session. Guards get these helpers so the redirect contract
8
8
  // (`search.redirect` carries where to return after sign-in) lives in one module.
9
9
 
10
+ // Under SSR the session is unknowable (it lives in localStorage), so a server-side
11
+ // redirect would be wrong for signed-in users. Guards defer to the client: they
12
+ // no-op on the server and run again during hydration, where the session is real.
13
+ const isServer = typeof document === 'undefined'
14
+
10
15
  export const requireSession = (location: { href: string }) => {
16
+ if (isServer) return
11
17
  if (!sessionStore.get()) {
12
18
  throw redirect({ to: '/sign-in', search: { redirect: location.href } })
13
19
  }
14
20
  }
15
21
 
16
22
  export const redirectIfAuthenticated = (search: { redirect?: string }) => {
23
+ if (isServer) return
17
24
  if (sessionStore.get()) throw redirect({ to: search.redirect ?? '/' })
18
25
  }
@@ -17,23 +17,31 @@ const parseRaw = (raw: string | null): Session | null => {
17
17
  }
18
18
  }
19
19
 
20
- let current = parseRaw(localStorage.getItem(KEY))
20
+ // The session lives in localStorage, so it exists only in the browser. During SSR
21
+ // (TanStack Start) this module still gets imported by route guards: there the
22
+ // session is always null and writes are impossible — auth resolves after hydration.
23
+ const isBrowser = typeof window !== 'undefined'
24
+
25
+ let current = isBrowser ? parseRaw(localStorage.getItem(KEY)) : null
21
26
  const listeners = new Set<Listener>()
22
27
 
23
28
  const notify = () => listeners.forEach(l => l(current))
24
29
 
25
30
  const commit = (next: Session | null) => {
31
+ if (!isBrowser) throw new Error('Session can only change in the browser')
26
32
  current = next
27
33
  if (next) localStorage.setItem(KEY, JSON.stringify(next))
28
34
  else localStorage.removeItem(KEY)
29
35
  notify()
30
36
  }
31
37
 
32
- window.addEventListener('storage', e => {
33
- if (e.key !== KEY) return
34
- current = parseRaw(e.newValue)
35
- notify()
36
- })
38
+ if (isBrowser) {
39
+ window.addEventListener('storage', e => {
40
+ if (e.key !== KEY) return
41
+ current = parseRaw(e.newValue)
42
+ notify()
43
+ })
44
+ }
37
45
 
38
46
  export const sessionStore = {
39
47
  get: () => current,
@@ -53,4 +61,6 @@ export const sessionStore = {
53
61
 
54
62
  // The React binding of the store's subscribe contract lives with the store: one
55
63
  // concept, one file. Components use this; route guards use @/api/auth/guards.
56
- export const useSession = () => useSyncExternalStore(sessionStore.subscribe, sessionStore.get)
64
+ // The server snapshot is always null: the session lives in localStorage (see above).
65
+ export const useSession = () =>
66
+ useSyncExternalStore(sessionStore.subscribe, sessionStore.get, () => null)
@@ -36,6 +36,8 @@ const applyTheme = (theme: Theme) => {
36
36
 
37
37
  export const ThemeProvider = ({ children }: PropsWithChildren) => {
38
38
  const [theme, setThemeState] = useState<Theme>(() => {
39
+ // the initializer also runs during SSR, where localStorage doesn't exist
40
+ if (typeof window === 'undefined') return DEFAULT_THEME
39
41
  const stored = localStorage.getItem(STORAGE_KEY)
40
42
  return isTheme(stored) ? stored : DEFAULT_THEME
41
43
  })
@@ -0,0 +1 @@
1
+ VITE_API_URL=/api/
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
3
+ "printWidth": 100,
4
+ "tabWidth": 2,
5
+ "useTabs": false,
6
+ "singleQuote": true,
7
+ "jsxSingleQuote": true,
8
+ "semi": false,
9
+ "trailingComma": "none",
10
+ "arrowParens": "avoid",
11
+ "sortTailwindcss": { "functions": ["clsx", "cva", "cn", "twMerge"] },
12
+ "ignorePatterns": ["**/dist", "src/routeTree.gen.ts"]
13
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
3
+ "plugins": ["react", "typescript", "oxc"],
4
+ "ignorePatterns": ["**/dist", "src/routeTree.gen.ts"],
5
+ "rules": {
6
+ "react/rules-of-hooks": "error"
7
+ }
8
+ }
@@ -0,0 +1,39 @@
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Env
16
+ .env
17
+ .env.*
18
+ !.env.example
19
+
20
+ # Skill artifacts
21
+ .agents/
22
+
23
+ # Playwright
24
+ /test-results
25
+ /playwright-report
26
+ /blob-report
27
+ /playwright/.cache
28
+
29
+ # Editor directories and files
30
+ .vscode/*
31
+ !.vscode/extensions.json
32
+ !.vscode/settings.json
33
+ .idea
34
+ .DS_Store
35
+ *.suo
36
+ *.ntvs*
37
+ *.njsproj
38
+ *.sln
39
+ *.sw?
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "base-nova",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/index.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "rtl": false,
15
+ "aliases": {
16
+ "components": "@/components",
17
+ "utils": "@/lib/utils",
18
+ "ui": "@/components/ui",
19
+ "lib": "@/lib",
20
+ "hooks": "@/hooks"
21
+ },
22
+ "menuColor": "default",
23
+ "menuAccent": "subtle",
24
+ "registries": {}
25
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "PLACEHOLDER",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "oxlint",
10
+ "lint:fix": "oxlint --fix",
11
+ "typecheck": "tsc -b",
12
+ "test": "vitest run",
13
+ "format": "oxfmt",
14
+ "format:check": "oxfmt --check",
15
+ "preview": "vite preview"
16
+ },
17
+ "dependencies": {
18
+ "@base-ui/react": "^1.7.0",
19
+ "@fontsource-variable/inter": "^5.3.0",
20
+ "@hookform/resolvers": "^5.9.1",
21
+ "@tanstack/react-query": "^5.102.8",
22
+ "@tanstack/react-router": "^1.170.32",
23
+ "@tanstack/react-start": "^1.168.0",
24
+ "class-variance-authority": "^0.7.1",
25
+ "cnfast": "^0.1.0",
26
+ "ky": "^2.1.0",
27
+ "lucide-react": "^1.35.0",
28
+ "react": "^19.2.8",
29
+ "react-dom": "^19.2.8",
30
+ "react-hook-form": "^7.86.0",
31
+ "sonner": "^2.0.8",
32
+ "tw-animate-css": "^1.4.0",
33
+ "zod": "^4.5.1"
34
+ },
35
+ "devDependencies": {
36
+ "@babel/core": "^8.0.1",
37
+ "@rolldown/plugin-babel": "^0.2.3",
38
+ "@tailwindcss/vite": "^4.3.3",
39
+ "@testing-library/jest-dom": "^7.0.1",
40
+ "@testing-library/react": "^16.3.3",
41
+ "@testing-library/user-event": "^14.6.6",
42
+ "@types/babel__core": "^7.20.5",
43
+ "@types/node": "^26.4.0",
44
+ "@types/react": "^19.2.18",
45
+ "@types/react-dom": "^19.2.5",
46
+ "@vitejs/plugin-react": "^6.1.1",
47
+ "babel-plugin-react-compiler": "^1.0.0",
48
+ "happy-dom": "^20.11.13",
49
+ "oxfmt": "^0.65.0",
50
+ "oxlint": "^1.80.0",
51
+ "shadcn": "^4.19.0",
52
+ "tailwindcss": "^4.3.3",
53
+ "typescript": "^7.0.2",
54
+ "vite": "8.1.4",
55
+ "vitest": "^4.1.11"
56
+ },
57
+ "trustedDependencies": []
58
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
@@ -0,0 +1,46 @@
1
+ import { afterAll, afterEach, beforeAll } from 'vitest'
2
+
3
+ import { fetchMe, login, logout } from '@/api/auth'
4
+ import { sessionStore } from '@/api/auth/session-store'
5
+ import { installMockServer } from '@/mocks/mock-server'
6
+
7
+ const creds = { email: 'demo@example.com', password: 'demo1234' }
8
+
9
+ let uninstall: () => void
10
+
11
+ beforeAll(() => {
12
+ uninstall = installMockServer()
13
+ })
14
+ afterAll(() => uninstall())
15
+ afterEach(() => {
16
+ sessionStore.clear()
17
+ localStorage.clear()
18
+ })
19
+
20
+ it('logs in and persists the session', async () => {
21
+ const session = await login(creds)
22
+ expect(session.user.email).toBe(creds.email)
23
+ expect(sessionStore.get()?.user.email).toBe(creds.email)
24
+ })
25
+
26
+ it('rejects bad credentials without touching the session', async () => {
27
+ await expect(login({ ...creds, password: 'wrong' })).rejects.toThrow()
28
+ expect(sessionStore.get()).toBeNull()
29
+ })
30
+
31
+ it('transparently refreshes when the access token is rejected', async () => {
32
+ await login(creds)
33
+ const session = sessionStore.get()!
34
+ sessionStore.set({ ...session, accessToken: 'expired' })
35
+
36
+ const me = await fetchMe()
37
+
38
+ expect(me.email).toBe(creds.email)
39
+ expect(sessionStore.get()?.accessToken).not.toBe('expired')
40
+ })
41
+
42
+ it('clears the session on logout', async () => {
43
+ await login(creds)
44
+ await logout()
45
+ expect(sessionStore.get()).toBeNull()
46
+ })
@@ -0,0 +1,25 @@
1
+ import { redirect } from '@tanstack/react-router'
2
+
3
+ import { sessionStore } from '@/api/auth/session-store'
4
+
5
+ // The one place that reads the session synchronously: `beforeLoad` runs outside
6
+ // React, so it can't use useSession(). Components use useSession(); React Query
7
+ // never mirrors the session. Guards get these helpers so the redirect contract
8
+ // (`search.redirect` carries where to return after sign-in) lives in one module.
9
+
10
+ // Under SSR the session is unknowable (it lives in localStorage), so a server-side
11
+ // redirect would be wrong for signed-in users. Guards defer to the client: they
12
+ // no-op on the server and run again during hydration, where the session is real.
13
+ const isServer = typeof document === 'undefined'
14
+
15
+ export const requireSession = (location: { href: string }) => {
16
+ if (isServer) return
17
+ if (!sessionStore.get()) {
18
+ throw redirect({ to: '/sign-in', search: { redirect: location.href } })
19
+ }
20
+ }
21
+
22
+ export const redirectIfAuthenticated = (search: { redirect?: string }) => {
23
+ if (isServer) return
24
+ if (sessionStore.get()) throw redirect({ to: search.redirect ?? '/' })
25
+ }
@@ -0,0 +1,46 @@
1
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
2
+
3
+ import { api } from '@/api'
4
+ import { SessionSchema, UserSchema } from '@/api/auth/schema'
5
+ import { sessionStore } from '@/api/auth/session-store'
6
+
7
+ export type Credentials = { email: string; password: string }
8
+
9
+ export const login = (credentials: Credentials) =>
10
+ api
11
+ .post('auth/login', { json: credentials })
12
+ .json()
13
+ .then(data => SessionSchema.parse(data))
14
+ .then(session => {
15
+ sessionStore.set(session)
16
+ return session
17
+ })
18
+
19
+ export const logout = async () => {
20
+ try {
21
+ await api.post('auth/logout')
22
+ } catch {}
23
+ sessionStore.clear()
24
+ }
25
+
26
+ export const fetchMe = () =>
27
+ api
28
+ .get('auth/me')
29
+ .json()
30
+ .then(data => UserSchema.parse(data))
31
+
32
+ export const useLogin = () => {
33
+ const queryClient = useQueryClient()
34
+ return useMutation({
35
+ mutationFn: login,
36
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me'] })
37
+ })
38
+ }
39
+
40
+ export const useLogout = () => {
41
+ const queryClient = useQueryClient()
42
+ return useMutation({
43
+ mutationFn: logout,
44
+ onSuccess: () => queryClient.clear()
45
+ })
46
+ }
@@ -0,0 +1,89 @@
1
+ import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest'
2
+
3
+ import { fetchMe, login } from '@/api/auth'
4
+ import { sessionStore } from '@/api/auth/session-store'
5
+ import { installMockServer } from '@/mocks/mock-server'
6
+
7
+ const creds = { email: 'demo@example.com', password: 'demo1234' }
8
+
9
+ let uninstall: () => void
10
+
11
+ beforeAll(() => {
12
+ uninstall = installMockServer()
13
+ })
14
+ afterAll(() => uninstall())
15
+ afterEach(() => {
16
+ sessionStore.clear()
17
+ localStorage.clear()
18
+ })
19
+
20
+ it('clears the session and stops when the refresh token is rejected', async () => {
21
+ await login(creds)
22
+ const session = sessionStore.get()!
23
+ // both tokens dead: the access token forces a 401, the refresh token can't fix it
24
+ sessionStore.set({
25
+ ...session,
26
+ accessToken: 'expired',
27
+ refreshToken: 'mock.refresh.u_demo.stale'
28
+ })
29
+
30
+ const spy = vi.spyOn(globalThis, 'fetch')
31
+ await expect(fetchMe()).rejects.toThrow()
32
+
33
+ expect(sessionStore.get()).toBeNull()
34
+ // auth/me (401) + auth/refresh (401). A third call means the retry guard broke.
35
+ expect(spy).toHaveBeenCalledTimes(2)
36
+ })
37
+
38
+ it('collapses concurrent 401s into a single refresh call', async () => {
39
+ await login(creds)
40
+ const session = sessionStore.get()!
41
+ sessionStore.set({ ...session, accessToken: 'expired' })
42
+
43
+ const spy = vi.spyOn(globalThis, 'fetch')
44
+ await Promise.all([fetchMe(), fetchMe(), fetchMe()])
45
+
46
+ const refreshCalls = spy.mock.calls.filter(([input]) =>
47
+ String(input instanceof Request ? input.url : input).includes('auth/refresh')
48
+ )
49
+ expect(refreshCalls).toHaveLength(1)
50
+ })
51
+
52
+ // The retryCount guard only matters when the RETRIED request is also rejected —
53
+ // mock-server never does that, so this case needs a purpose-built backend.
54
+ it('stops after one refresh when the fresh token is also rejected', async () => {
55
+ sessionStore.set({
56
+ user: { id: 'u_demo', email: creds.email },
57
+ accessToken: 'stale',
58
+ refreshToken: 'stale'
59
+ })
60
+
61
+ let refreshCalls = 0
62
+ const stub = vi.fn(async (input: RequestInfo | URL) => {
63
+ const url = String(input instanceof Request ? input.url : input)
64
+ if (url.includes('auth/refresh')) {
65
+ refreshCalls += 1
66
+ return new Response(JSON.stringify({ accessToken: 'new', refreshToken: 'new' }), {
67
+ status: 200,
68
+ headers: { 'content-type': 'application/json' }
69
+ })
70
+ }
71
+ return new Response(JSON.stringify({ message: 'Unauthorized' }), { status: 401 })
72
+ })
73
+ const original = globalThis.fetch
74
+ globalThis.fetch = stub as unknown as typeof fetch
75
+
76
+ try {
77
+ await expect(fetchMe()).rejects.toThrow()
78
+ // without the `retryCount > 0` guard the retried 401 triggers another refresh
79
+ expect(refreshCalls).toBe(1)
80
+ } finally {
81
+ globalThis.fetch = original
82
+ }
83
+ })
84
+
85
+ it('refuses to refresh with no session at all', async () => {
86
+ sessionStore.clear()
87
+ await expect(fetchMe()).rejects.toThrow()
88
+ expect(sessionStore.get()).toBeNull()
89
+ })
@@ -0,0 +1,74 @@
1
+ import { expect, it, vi } from 'vitest'
2
+
3
+ import { bindSessionToRouter } from '@/api/auth/router-bridge'
4
+ import type { Session } from '@/api/auth/schema'
5
+
6
+ const session = (accessToken: string): Session => ({
7
+ accessToken,
8
+ refreshToken: 'r1',
9
+ user: { id: 'u_demo', email: 'demo@example.com' }
10
+ })
11
+
12
+ // A hand-rolled store instead of the real sessionStore: the bridge's interface
13
+ // is (get, subscribe), and testing through it avoids localStorage + module reset.
14
+ const fakeStore = (initial: Session | null) => {
15
+ let current = initial
16
+ const listeners = new Set<(s: Session | null) => void>()
17
+ return {
18
+ get: () => current,
19
+ subscribe: (l: (s: Session | null) => void) => {
20
+ listeners.add(l)
21
+ return () => {
22
+ listeners.delete(l)
23
+ }
24
+ },
25
+ emit: (next: Session | null) => {
26
+ current = next
27
+ listeners.forEach(l => l(next))
28
+ }
29
+ }
30
+ }
31
+
32
+ const router = () => ({ invalidate: vi.fn(() => Promise.resolve()) })
33
+
34
+ it('invalidates when a session appears', () => {
35
+ const store = fakeStore(null)
36
+ const r = router()
37
+ bindSessionToRouter(r, store)
38
+
39
+ store.emit(session('a1'))
40
+
41
+ expect(r.invalidate).toHaveBeenCalledTimes(1)
42
+ })
43
+
44
+ it('invalidates when the session disappears', () => {
45
+ const store = fakeStore(session('a1'))
46
+ const r = router()
47
+ bindSessionToRouter(r, store)
48
+
49
+ store.emit(null)
50
+
51
+ expect(r.invalidate).toHaveBeenCalledTimes(1)
52
+ })
53
+
54
+ it('does not invalidate on token rotation — loaders must not restart every refresh', () => {
55
+ const store = fakeStore(session('a1'))
56
+ const r = router()
57
+ bindSessionToRouter(r, store)
58
+
59
+ store.emit(session('a2'))
60
+ store.emit(session('a3'))
61
+
62
+ expect(r.invalidate).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('stops reacting after unsubscribe', () => {
66
+ const store = fakeStore(null)
67
+ const r = router()
68
+ const unsubscribe = bindSessionToRouter(r, store)
69
+
70
+ unsubscribe()
71
+ store.emit(session('a1'))
72
+
73
+ expect(r.invalidate).not.toHaveBeenCalled()
74
+ })
@@ -0,0 +1,26 @@
1
+ import type { Session } from '@/api/auth/schema'
2
+ import { sessionStore } from '@/api/auth/session-store'
3
+
4
+ type SessionSource = {
5
+ get: () => Session | null
6
+ subscribe: (listener: (session: Session | null) => void) => () => void
7
+ }
8
+
9
+ type Invalidatable = { invalidate: () => Promise<void> }
10
+
11
+ // Route guards live in `beforeLoad`, which only runs on navigation. When the
12
+ // session appears or disappears without one — signing in or out in another tab,
13
+ // or a refresh failure clearing the store — that tab would keep rendering the
14
+ // wrong side of the guard. Invalidating re-runs the guards in place.
15
+ //
16
+ // Only presence transitions matter. Token rotation also notifies subscribers,
17
+ // and invalidating on every refresh would restart every loader ~every 15 min.
18
+ export const bindSessionToRouter = (router: Invalidatable, store: SessionSource = sessionStore) => {
19
+ let hadSession = store.get() !== null
20
+ return store.subscribe(session => {
21
+ const hasSession = session !== null
22
+ if (hasSession === hadSession) return
23
+ hadSession = hasSession
24
+ void router.invalidate()
25
+ })
26
+ }
@@ -0,0 +1,20 @@
1
+ import * as z from 'zod/mini'
2
+
3
+ export const TokensSchema = z.object({
4
+ accessToken: z.string(),
5
+ refreshToken: z.string()
6
+ })
7
+ export type Tokens = z.infer<typeof TokensSchema>
8
+
9
+ // TODO: match your backend's user shape
10
+ export const UserSchema = z.object({
11
+ id: z.string(),
12
+ email: z.email()
13
+ })
14
+ export type User = z.infer<typeof UserSchema>
15
+
16
+ export const SessionSchema = z.object({
17
+ ...TokensSchema.shape,
18
+ user: UserSchema
19
+ })
20
+ export type Session = z.infer<typeof SessionSchema>