create-top-secret-starter 0.0.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.
- package/index.js +117 -0
- package/package.json +33 -0
- package/templates/template-router/.env.example +1 -0
- package/templates/template-router/.oxfmtrc.json +13 -0
- package/templates/template-router/.oxlintrc.json +8 -0
- package/templates/template-router/_gitignore +39 -0
- package/templates/template-router/components.json +25 -0
- package/templates/template-router/index.html +24 -0
- package/templates/template-router/package.json +58 -0
- package/templates/template-router/public/favicon.svg +1 -0
- package/templates/template-router/src/api/auth/auth.test.ts +46 -0
- package/templates/template-router/src/api/auth/guards.ts +18 -0
- package/templates/template-router/src/api/auth/index.ts +46 -0
- package/templates/template-router/src/api/auth/refresh.test.ts +89 -0
- package/templates/template-router/src/api/auth/router-bridge.test.ts +74 -0
- package/templates/template-router/src/api/auth/router-bridge.ts +26 -0
- package/templates/template-router/src/api/auth/schema.ts +20 -0
- package/templates/template-router/src/api/auth/session-store.test.ts +101 -0
- package/templates/template-router/src/api/auth/session-store.ts +56 -0
- package/templates/template-router/src/api/index.ts +51 -0
- package/templates/template-router/src/components/status-page.tsx +68 -0
- package/templates/template-router/src/components/ui/button.tsx +58 -0
- package/templates/template-router/src/components/ui/dialog.tsx +136 -0
- package/templates/template-router/src/components/ui/empty.tsx +94 -0
- package/templates/template-router/src/components/ui/field.tsx +222 -0
- package/templates/template-router/src/components/ui/input.tsx +20 -0
- package/templates/template-router/src/components/ui/label.tsx +18 -0
- package/templates/template-router/src/components/ui/select.tsx +188 -0
- package/templates/template-router/src/components/ui/separator.tsx +21 -0
- package/templates/template-router/src/components/ui/skeleton.tsx +13 -0
- package/templates/template-router/src/components/ui/sonner.tsx +43 -0
- package/templates/template-router/src/components/ui/tooltip.tsx +52 -0
- package/templates/template-router/src/env.ts +20 -0
- package/templates/template-router/src/index.css +134 -0
- package/templates/template-router/src/lib/query-client.test.ts +82 -0
- package/templates/template-router/src/lib/query-client.ts +30 -0
- package/templates/template-router/src/lib/single-flight.test.ts +48 -0
- package/templates/template-router/src/lib/single-flight.ts +9 -0
- package/templates/template-router/src/lib/utils.ts +1 -0
- package/templates/template-router/src/main.tsx +26 -0
- package/templates/template-router/src/mocks/mock-server.ts +149 -0
- package/templates/template-router/src/providers/index.tsx +27 -0
- package/templates/template-router/src/providers/theme.test.tsx +68 -0
- package/templates/template-router/src/providers/theme.tsx +76 -0
- package/templates/template-router/src/routeTree.gen.ts +102 -0
- package/templates/template-router/src/router.tsx +29 -0
- package/templates/template-router/src/routes/-error.tsx +34 -0
- package/templates/template-router/src/routes/-not-found.tsx +27 -0
- package/templates/template-router/src/routes/__root.tsx +15 -0
- package/templates/template-router/src/routes/_authenticated/index.tsx +9 -0
- package/templates/template-router/src/routes/_authenticated.tsx +38 -0
- package/templates/template-router/src/routes/sign-in.tsx +100 -0
- package/templates/template-router/src/test/setup.ts +5 -0
- package/templates/template-router/tsconfig.app.json +29 -0
- package/templates/template-router/tsconfig.json +7 -0
- package/templates/template-router/tsconfig.node.json +23 -0
- package/templates/template-router/vite.config.ts +33 -0
package/index.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { parseArgs } from 'node:util'
|
|
6
|
+
import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts'
|
|
7
|
+
|
|
8
|
+
// lesson 001: the package manager that launched us sets npm_config_user_agent
|
|
9
|
+
const agent = process.env.npm_config_user_agent ?? ''
|
|
10
|
+
const detectedPm = agent.split('/')[0] || 'npm'
|
|
11
|
+
|
|
12
|
+
const PMS = ['bun', 'pnpm', 'npm']
|
|
13
|
+
const ROUTERS = ['router', 'start']
|
|
14
|
+
const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/
|
|
15
|
+
|
|
16
|
+
// lesson 002 §6: flags bypass prompts so the CLI works in CI
|
|
17
|
+
const { values: flags, positionals } = parseArgs({
|
|
18
|
+
args: process.argv.slice(2),
|
|
19
|
+
allowPositionals: true,
|
|
20
|
+
options: {
|
|
21
|
+
pm: { type: 'string' },
|
|
22
|
+
router: { type: 'string' }
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
function must(value) {
|
|
27
|
+
if (isCancel(value)) {
|
|
28
|
+
cancel('Cancelled.')
|
|
29
|
+
process.exit(0)
|
|
30
|
+
}
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validateName(v) {
|
|
35
|
+
if (!v) return 'Project name is required'
|
|
36
|
+
if (!NAME_RE.test(v)) return 'Lowercase letters, digits, . _ - only (npm package name rules)'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
intro('create-top-secret-starter')
|
|
40
|
+
|
|
41
|
+
let name = positionals[0]
|
|
42
|
+
if (name && validateName(name)) {
|
|
43
|
+
cancel(`Invalid project name "${name}": ${validateName(name)}`)
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
name ??= must(
|
|
47
|
+
await text({
|
|
48
|
+
message: 'Project name?',
|
|
49
|
+
placeholder: 'my-ai-app',
|
|
50
|
+
validate: validateName
|
|
51
|
+
})
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
let pm = flags.pm
|
|
55
|
+
if (pm && !PMS.includes(pm)) {
|
|
56
|
+
cancel(`Unknown package manager "${pm}". Expected: ${PMS.join(', ')}`)
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
59
|
+
pm ??= must(
|
|
60
|
+
await select({
|
|
61
|
+
message: 'Package manager?',
|
|
62
|
+
initialValue: PMS.includes(detectedPm) ? detectedPm : 'npm',
|
|
63
|
+
options: PMS.map(value => ({ value, label: value }))
|
|
64
|
+
})
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
let router = flags.router
|
|
68
|
+
if (router && !ROUTERS.includes(router)) {
|
|
69
|
+
cancel(`Unknown router "${router}". Expected: ${ROUTERS.join(', ')}`)
|
|
70
|
+
process.exit(1)
|
|
71
|
+
}
|
|
72
|
+
router ??= must(
|
|
73
|
+
await select({
|
|
74
|
+
message: 'Routing?',
|
|
75
|
+
options: [
|
|
76
|
+
{ value: 'router', label: 'TanStack Router', hint: 'SPA' },
|
|
77
|
+
{ value: 'start', label: 'TanStack Start', hint: 'SSR, full-stack' }
|
|
78
|
+
]
|
|
79
|
+
})
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const targetDir = path.resolve(process.cwd(), name)
|
|
83
|
+
if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
|
|
84
|
+
cancel(`Directory "${name}" already exists and is not empty.`)
|
|
85
|
+
process.exit(1)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// lesson 003 §4: variant A — one full template per router choice
|
|
89
|
+
const templateDir = path.join(
|
|
90
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
91
|
+
'templates',
|
|
92
|
+
`template-${router}`
|
|
93
|
+
)
|
|
94
|
+
if (!fs.existsSync(templateDir)) {
|
|
95
|
+
cancel(`The ${router === 'start' ? 'TanStack Start' : router} template is not available yet.`)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
fs.cpSync(templateDir, targetDir, { recursive: true })
|
|
99
|
+
|
|
100
|
+
// lesson 003 §2: npm strips .gitignore from packages, so it ships as _gitignore
|
|
101
|
+
const renameFiles = { _gitignore: '.gitignore' }
|
|
102
|
+
for (const [from, to] of Object.entries(renameFiles)) {
|
|
103
|
+
const src = path.join(targetDir, from)
|
|
104
|
+
if (fs.existsSync(src)) fs.renameSync(src, path.join(targetDir, to))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const pkgPath = path.join(targetDir, 'package.json')
|
|
108
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
109
|
+
pkg.name = name
|
|
110
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
111
|
+
|
|
112
|
+
outro(`✔ ${name} created (${router === 'start' ? 'TanStack Start' : 'TanStack Router'})
|
|
113
|
+
|
|
114
|
+
Next steps:
|
|
115
|
+
cd ${name}
|
|
116
|
+
${pm} install
|
|
117
|
+
${pm} run dev`)
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-top-secret-starter",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Scaffold a Vite + React 19 + TypeScript + shadcn starter with TanStack Router",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"create",
|
|
7
|
+
"react",
|
|
8
|
+
"shadcn",
|
|
9
|
+
"starter",
|
|
10
|
+
"tanstack",
|
|
11
|
+
"template",
|
|
12
|
+
"vite"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"bin": {
|
|
16
|
+
"create-top-secret-starter": "index.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"index.js",
|
|
20
|
+
"templates"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"scripts": {
|
|
24
|
+
"sync": "node sync-template.mjs",
|
|
25
|
+
"sync:verify": "node sync-template.mjs --verify"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@clack/prompts": "^1.7.0"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -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,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,24 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>top-secret</title>
|
|
8
|
+
<script>
|
|
9
|
+
;(function () {
|
|
10
|
+
try {
|
|
11
|
+
var t = localStorage.getItem('ui-theme') || 'system'
|
|
12
|
+
var dark =
|
|
13
|
+
t === 'dark' ||
|
|
14
|
+
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
|
15
|
+
document.documentElement.classList.toggle('dark', dark)
|
|
16
|
+
} catch (e) {}
|
|
17
|
+
})()
|
|
18
|
+
</script>
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<div id="root"></div>
|
|
22
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
|
@@ -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
|
+
"class-variance-authority": "^0.7.1",
|
|
24
|
+
"cnfast": "^0.1.0",
|
|
25
|
+
"ky": "^2.1.0",
|
|
26
|
+
"lucide-react": "^1.35.0",
|
|
27
|
+
"react": "^19.2.8",
|
|
28
|
+
"react-dom": "^19.2.8",
|
|
29
|
+
"react-hook-form": "^7.86.0",
|
|
30
|
+
"sonner": "^2.0.8",
|
|
31
|
+
"tw-animate-css": "^1.4.0",
|
|
32
|
+
"zod": "^4.5.1"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@babel/core": "^8.0.1",
|
|
36
|
+
"@rolldown/plugin-babel": "^0.2.3",
|
|
37
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
38
|
+
"@tanstack/router-plugin": "^1.168.35",
|
|
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,18 @@
|
|
|
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
|
+
export const requireSession = (location: { href: string }) => {
|
|
11
|
+
if (!sessionStore.get()) {
|
|
12
|
+
throw redirect({ to: '/sign-in', search: { redirect: location.href } })
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const redirectIfAuthenticated = (search: { redirect?: string }) => {
|
|
17
|
+
if (sessionStore.get()) throw redirect({ to: search.redirect ?? '/' })
|
|
18
|
+
}
|
|
@@ -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
|
+
}
|