create-ab-app 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.
- package/README.md +60 -0
- package/index.js +380 -0
- package/package.json +36 -0
- package/template/.env.example +8 -0
- package/template/.oxlintrc.json +8 -0
- package/template/README.md +196 -0
- package/template/_gitignore +24 -0
- package/template/components.json +25 -0
- package/template/index.html +13 -0
- package/template/package-lock.json +6096 -0
- package/template/package.json +40 -0
- package/template/public/_redirects +1 -0
- package/template/public/favicon.svg +1 -0
- package/template/public/icons.svg +24 -0
- package/template/src/App.tsx +60 -0
- package/template/src/assets/hero.png +0 -0
- package/template/src/assets/vite.svg +1 -0
- package/template/src/components/app-sidebar.tsx +53 -0
- package/template/src/components/credit-balance.tsx +31 -0
- package/template/src/components/docs/doc-primitives.tsx +125 -0
- package/template/src/components/full-page-loader.tsx +14 -0
- package/template/src/components/header-user.tsx +64 -0
- package/template/src/components/layouts/app-layout.tsx +80 -0
- package/template/src/components/missing-env.tsx +24 -0
- package/template/src/components/nav-main.tsx +87 -0
- package/template/src/components/nav-user.tsx +69 -0
- package/template/src/components/theme-toggle.tsx +39 -0
- package/template/src/components/ui/avatar.tsx +106 -0
- package/template/src/components/ui/breadcrumb.tsx +124 -0
- package/template/src/components/ui/button.tsx +57 -0
- package/template/src/components/ui/card.tsx +102 -0
- package/template/src/components/ui/collapsible.tsx +19 -0
- package/template/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/src/components/ui/field.tsx +238 -0
- package/template/src/components/ui/input.tsx +19 -0
- package/template/src/components/ui/label.tsx +19 -0
- package/template/src/components/ui/separator.tsx +22 -0
- package/template/src/components/ui/sheet.tsx +136 -0
- package/template/src/components/ui/sidebar.tsx +721 -0
- package/template/src/components/ui/skeleton.tsx +13 -0
- package/template/src/components/ui/sonner.tsx +47 -0
- package/template/src/components/ui/tooltip.tsx +65 -0
- package/template/src/components/user-menu.tsx +96 -0
- package/template/src/config/env.ts +14 -0
- package/template/src/config/navigation.ts +52 -0
- package/template/src/hooks/use-mobile.ts +19 -0
- package/template/src/hooks/use-user-identity.ts +27 -0
- package/template/src/index.css +134 -0
- package/template/src/lib/http.ts +40 -0
- package/template/src/lib/sso/account-settings.tsx +53 -0
- package/template/src/lib/sso/auth-context.ts +16 -0
- package/template/src/lib/sso/auth-provider.tsx +42 -0
- package/template/src/lib/sso/auth-screens.tsx +152 -0
- package/template/src/lib/sso/config.ts +13 -0
- package/template/src/lib/sso/entitlements.ts +101 -0
- package/template/src/lib/sso/index.ts +33 -0
- package/template/src/lib/sso/protected-route.tsx +72 -0
- package/template/src/lib/sso/queries.ts +55 -0
- package/template/src/lib/sso/require-plan.tsx +19 -0
- package/template/src/lib/sso/token-store.ts +62 -0
- package/template/src/lib/utils.ts +1 -0
- package/template/src/main.tsx +42 -0
- package/template/src/routes/app/example.tsx +125 -0
- package/template/src/routes/auth/forgot-password.tsx +5 -0
- package/template/src/routes/auth/login.tsx +5 -0
- package/template/src/routes/auth/signup.tsx +5 -0
- package/template/src/routes/auth/verify-email.tsx +5 -0
- package/template/src/routes/docs/api.tsx +66 -0
- package/template/src/routes/docs/auth.tsx +145 -0
- package/template/src/routes/docs/billing.tsx +103 -0
- package/template/src/routes/docs/configuration.tsx +113 -0
- package/template/src/routes/docs/overview.tsx +97 -0
- package/template/src/routes/docs/routing.tsx +126 -0
- package/template/src/routes/docs/structure.tsx +150 -0
- package/template/src/routes/docs/theming.tsx +88 -0
- package/template/src/routes/not-found.tsx +12 -0
- package/template/src/styles/aas-theme.css +21 -0
- package/template/src/vite-env.d.ts +12 -0
- package/template/tsconfig.app.json +30 -0
- package/template/tsconfig.json +12 -0
- package/template/tsconfig.node.json +23 -0
- package/template/vercel.json +3 -0
- package/template/vite.config.ts +17 -0
- package/variants/minimal/src/App.tsx +43 -0
- package/variants/minimal/src/components/app-sidebar.tsx +50 -0
- package/variants/minimal/src/config/navigation.ts +19 -0
- package/variants/minimal/src/routes/app/home.tsx +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# create-ab-app
|
|
2
|
+
|
|
3
|
+
Scaffold a React + Vite app with authentication and billing already wired up via
|
|
4
|
+
[`ab-ecosystem-sso`](https://www.npmjs.com/package/ab-ecosystem-sso).
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm create ab-app@latest my-app
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
◇ Where should the project be created? my-app
|
|
12
|
+
◇ App display name My App
|
|
13
|
+
◇ Auth project id 459412-review-ai.abprojects.com
|
|
14
|
+
◇ Auth API base URL https://partner-api.example.com
|
|
15
|
+
◇ Your own API base URL (optional)
|
|
16
|
+
◇ Start with the in-app documentation? Yes
|
|
17
|
+
◇ Package manager npm
|
|
18
|
+
◇ Install dependencies now? Yes
|
|
19
|
+
◇ Initialise a git repository? Yes
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
You get React 19, TypeScript, Tailwind v4, shadcn/ui with the `sidebar-07` shell,
|
|
23
|
+
login / signup / 2FA / magic links / password reset / email verification, plans,
|
|
24
|
+
wallets, credits, route guards, entitlement gating, toasts and a theme toggle.
|
|
25
|
+
|
|
26
|
+
## Options
|
|
27
|
+
|
|
28
|
+
Every prompt has a flag, so CI can scaffold non-interactively:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx create-ab-app my-app \
|
|
32
|
+
--project-id=459412-review-ai.abprojects.com \
|
|
33
|
+
--auth-url=https://partner-api.example.com \
|
|
34
|
+
--app-name="Review AI" \
|
|
35
|
+
--minimal --no-install --no-git -y
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| Flag | Effect |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `--project-id=<id>` | `VITE_AUTH_PROJECT_ID` (required) |
|
|
41
|
+
| `--auth-url=<url>` | `VITE_AUTH_API_BASE_URL` |
|
|
42
|
+
| `--api-url=<url>` | `VITE_API_BASE_URL` — your own backend |
|
|
43
|
+
| `--app-name=<name>` | Sidebar and browser-tab name |
|
|
44
|
+
| `--minimal` | Skip the in-app documentation pages |
|
|
45
|
+
| `--pm=<npm\|pnpm\|yarn\|bun>` | Package manager |
|
|
46
|
+
| `--no-install` / `--no-git` | Skip those steps |
|
|
47
|
+
| `-y`, `--yes` | Accept defaults for everything optional |
|
|
48
|
+
|
|
49
|
+
## After scaffolding
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cd my-app
|
|
53
|
+
npm run dev
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Sign in and the sidebar carries the documentation: project structure, auth,
|
|
57
|
+
billing, routing, theming, and calling your own API. The one rule it opens with:
|
|
58
|
+
**import from `@/lib/sso`, never from `ab-ecosystem-sso` directly.**
|
|
59
|
+
|
|
60
|
+
Requires Node 20+.
|
package/index.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* create-ab-app — scaffolds the React + Vite template with auth and billing.
|
|
4
|
+
*
|
|
5
|
+
* Asks for the handful of things the template cannot guess (project id, API
|
|
6
|
+
* origin, app name), writes .env.local, then optionally installs and inits git.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process"
|
|
9
|
+
import { access, cp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
10
|
+
import path from "node:path"
|
|
11
|
+
import process from "node:process"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
cancel,
|
|
16
|
+
confirm,
|
|
17
|
+
intro,
|
|
18
|
+
isCancel,
|
|
19
|
+
log,
|
|
20
|
+
note,
|
|
21
|
+
outro,
|
|
22
|
+
select,
|
|
23
|
+
spinner,
|
|
24
|
+
text,
|
|
25
|
+
} from "@clack/prompts"
|
|
26
|
+
import pc from "picocolors"
|
|
27
|
+
|
|
28
|
+
const cliDir = path.dirname(fileURLToPath(import.meta.url))
|
|
29
|
+
const templateDir = path.join(cliDir, "template")
|
|
30
|
+
const variantsDir = path.join(cliDir, "variants")
|
|
31
|
+
|
|
32
|
+
// ─── args ────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const argv = process.argv.slice(2)
|
|
35
|
+
const flags = new Map()
|
|
36
|
+
const positionals = []
|
|
37
|
+
|
|
38
|
+
for (const arg of argv) {
|
|
39
|
+
if (arg.startsWith("--")) {
|
|
40
|
+
const [key, value] = arg.slice(2).split("=")
|
|
41
|
+
flags.set(key, value ?? true)
|
|
42
|
+
} else if (arg.startsWith("-") && arg !== "-") {
|
|
43
|
+
flags.set(arg.slice(1), true)
|
|
44
|
+
} else {
|
|
45
|
+
positionals.push(arg)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (flags.has("help") || flags.has("h")) {
|
|
50
|
+
console.log(`
|
|
51
|
+
${pc.bold("create-ab-app")} — React + Vite starter with auth and billing
|
|
52
|
+
|
|
53
|
+
${pc.dim("npm create ab-app@latest my-app")}
|
|
54
|
+
|
|
55
|
+
Options
|
|
56
|
+
--project-id=<id> VITE_AUTH_PROJECT_ID
|
|
57
|
+
--auth-url=<url> VITE_AUTH_API_BASE_URL
|
|
58
|
+
--api-url=<url> VITE_API_BASE_URL (your own backend)
|
|
59
|
+
--app-name=<name> Display name in the sidebar
|
|
60
|
+
--minimal Skip the in-app documentation pages
|
|
61
|
+
--pm=<npm|pnpm|yarn|bun>
|
|
62
|
+
--no-install Do not install dependencies
|
|
63
|
+
--no-git Do not initialise a git repository
|
|
64
|
+
-y, --yes Accept defaults for everything optional
|
|
65
|
+
`)
|
|
66
|
+
process.exit(0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const assumeYes = flags.has("yes") || flags.has("y")
|
|
70
|
+
|
|
71
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/** Every prompt goes through here so Ctrl+C exits cleanly everywhere. */
|
|
74
|
+
async function ask(promptPromise) {
|
|
75
|
+
const value = await promptPromise
|
|
76
|
+
if (isCancel(value)) {
|
|
77
|
+
cancel("Cancelled. Nothing was written.")
|
|
78
|
+
process.exit(0)
|
|
79
|
+
}
|
|
80
|
+
return value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function exists(target) {
|
|
84
|
+
try {
|
|
85
|
+
await access(target)
|
|
86
|
+
return true
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toPackageName(input) {
|
|
93
|
+
return (
|
|
94
|
+
input
|
|
95
|
+
.trim()
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/^[._]+/, "")
|
|
98
|
+
.replace(/[^a-z0-9-~._]+/g, "-")
|
|
99
|
+
.replace(/^-+|-+$/g, "") || "app"
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function toTitle(input) {
|
|
104
|
+
return input
|
|
105
|
+
.replace(/[-_]+/g, " ")
|
|
106
|
+
.trim()
|
|
107
|
+
.replace(/\b\w/g, (character) => character.toUpperCase())
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function detectPackageManager() {
|
|
111
|
+
const agent = process.env.npm_config_user_agent ?? ""
|
|
112
|
+
if (agent.startsWith("pnpm")) return "pnpm"
|
|
113
|
+
if (agent.startsWith("yarn")) return "yarn"
|
|
114
|
+
if (agent.startsWith("bun")) return "bun"
|
|
115
|
+
return "npm"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function run(command, args, cwd) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const child = spawn(command, args, {
|
|
121
|
+
cwd,
|
|
122
|
+
stdio: "pipe",
|
|
123
|
+
shell: process.platform === "win32",
|
|
124
|
+
})
|
|
125
|
+
let stderr = ""
|
|
126
|
+
child.stderr.on("data", (chunk) => {
|
|
127
|
+
stderr += chunk
|
|
128
|
+
})
|
|
129
|
+
child.on("error", reject)
|
|
130
|
+
child.on("close", (code) =>
|
|
131
|
+
code === 0 ? resolve() : reject(new Error(stderr.trim() || `exit ${code}`)),
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── prompts ─────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
intro(pc.bgCyan(pc.black(" create-ab-app ")))
|
|
139
|
+
|
|
140
|
+
const targetInput =
|
|
141
|
+
positionals[0] ??
|
|
142
|
+
(await ask(
|
|
143
|
+
text({
|
|
144
|
+
message: "Where should the project be created?",
|
|
145
|
+
placeholder: "my-app",
|
|
146
|
+
defaultValue: "my-app",
|
|
147
|
+
validate: (value) =>
|
|
148
|
+
value && value.trim().startsWith("-")
|
|
149
|
+
? "Directory name cannot start with a dash"
|
|
150
|
+
: undefined,
|
|
151
|
+
}),
|
|
152
|
+
))
|
|
153
|
+
|
|
154
|
+
const targetDir = path.resolve(process.cwd(), targetInput || "my-app")
|
|
155
|
+
const projectName = toPackageName(path.basename(targetDir))
|
|
156
|
+
|
|
157
|
+
if (await exists(targetDir)) {
|
|
158
|
+
const entries = await readdir(targetDir)
|
|
159
|
+
if (entries.length > 0) {
|
|
160
|
+
const overwrite = await ask(
|
|
161
|
+
confirm({
|
|
162
|
+
message: `${pc.yellow(path.basename(targetDir))} is not empty. Delete its contents and continue?`,
|
|
163
|
+
initialValue: false,
|
|
164
|
+
}),
|
|
165
|
+
)
|
|
166
|
+
if (!overwrite) {
|
|
167
|
+
cancel("Cancelled. Nothing was written.")
|
|
168
|
+
process.exit(0)
|
|
169
|
+
}
|
|
170
|
+
await rm(targetDir, { recursive: true, force: true })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const appName =
|
|
175
|
+
flags.get("app-name") ??
|
|
176
|
+
(assumeYes
|
|
177
|
+
? toTitle(projectName)
|
|
178
|
+
: await ask(
|
|
179
|
+
text({
|
|
180
|
+
message: "App display name",
|
|
181
|
+
placeholder: toTitle(projectName),
|
|
182
|
+
defaultValue: toTitle(projectName),
|
|
183
|
+
}),
|
|
184
|
+
))
|
|
185
|
+
|
|
186
|
+
const authProjectId =
|
|
187
|
+
flags.get("project-id") ??
|
|
188
|
+
(await ask(
|
|
189
|
+
text({
|
|
190
|
+
message: "Auth project id",
|
|
191
|
+
placeholder: "459412-review-ai.abprojects.com",
|
|
192
|
+
validate: (value) =>
|
|
193
|
+
value && value.trim() ? undefined : "Required — the SDK cannot resolve a project without it",
|
|
194
|
+
}),
|
|
195
|
+
))
|
|
196
|
+
|
|
197
|
+
const authApiBaseUrl =
|
|
198
|
+
flags.get("auth-url") ??
|
|
199
|
+
(assumeYes
|
|
200
|
+
? ""
|
|
201
|
+
: await ask(
|
|
202
|
+
text({
|
|
203
|
+
message: "Auth API base URL",
|
|
204
|
+
placeholder: "https://partner-api.example.com",
|
|
205
|
+
defaultValue: "",
|
|
206
|
+
validate: (value) =>
|
|
207
|
+
!value || /^https?:\/\//.test(value.trim())
|
|
208
|
+
? undefined
|
|
209
|
+
: "Must start with http:// or https://",
|
|
210
|
+
}),
|
|
211
|
+
))
|
|
212
|
+
|
|
213
|
+
const apiBaseUrl =
|
|
214
|
+
flags.get("api-url") ??
|
|
215
|
+
(assumeYes
|
|
216
|
+
? ""
|
|
217
|
+
: await ask(
|
|
218
|
+
text({
|
|
219
|
+
message: `Your own API base URL ${pc.dim("(optional, press enter to skip)")}`,
|
|
220
|
+
placeholder: "https://api.example.com",
|
|
221
|
+
defaultValue: "",
|
|
222
|
+
}),
|
|
223
|
+
))
|
|
224
|
+
|
|
225
|
+
const includeDocs = flags.has("minimal")
|
|
226
|
+
? false
|
|
227
|
+
: assumeYes
|
|
228
|
+
? true
|
|
229
|
+
: await ask(
|
|
230
|
+
select({
|
|
231
|
+
message: "Start with the in-app documentation?",
|
|
232
|
+
initialValue: true,
|
|
233
|
+
options: [
|
|
234
|
+
{
|
|
235
|
+
value: true,
|
|
236
|
+
label: "Yes — keep the guides and the live example",
|
|
237
|
+
hint: "recommended for a first project",
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
value: false,
|
|
241
|
+
label: "No — minimal starter",
|
|
242
|
+
hint: "one page, empty nav",
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
}),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const packageManager =
|
|
249
|
+
flags.get("pm") ??
|
|
250
|
+
(assumeYes
|
|
251
|
+
? detectPackageManager()
|
|
252
|
+
: await ask(
|
|
253
|
+
select({
|
|
254
|
+
message: "Package manager",
|
|
255
|
+
initialValue: detectPackageManager(),
|
|
256
|
+
options: [
|
|
257
|
+
{ value: "npm", label: "npm" },
|
|
258
|
+
{ value: "pnpm", label: "pnpm" },
|
|
259
|
+
{ value: "yarn", label: "yarn" },
|
|
260
|
+
{ value: "bun", label: "bun" },
|
|
261
|
+
],
|
|
262
|
+
}),
|
|
263
|
+
))
|
|
264
|
+
|
|
265
|
+
const shouldInstall = flags.has("no-install")
|
|
266
|
+
? false
|
|
267
|
+
: assumeYes
|
|
268
|
+
? true
|
|
269
|
+
: await ask(confirm({ message: "Install dependencies now?", initialValue: true }))
|
|
270
|
+
|
|
271
|
+
const shouldGit = flags.has("no-git")
|
|
272
|
+
? false
|
|
273
|
+
: assumeYes
|
|
274
|
+
? true
|
|
275
|
+
: await ask(confirm({ message: "Initialise a git repository?", initialValue: true }))
|
|
276
|
+
|
|
277
|
+
// ─── scaffold ────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
const progress = spinner()
|
|
280
|
+
progress.start("Creating project")
|
|
281
|
+
|
|
282
|
+
await cp(templateDir, targetDir, { recursive: true })
|
|
283
|
+
|
|
284
|
+
// npm strips .gitignore from tarballs, so the staged copy ships it renamed.
|
|
285
|
+
const stagedGitignore = path.join(targetDir, "_gitignore")
|
|
286
|
+
if (await exists(stagedGitignore)) {
|
|
287
|
+
await rename(stagedGitignore, path.join(targetDir, ".gitignore"))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const pkgPath = path.join(targetDir, "package.json")
|
|
291
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf8"))
|
|
292
|
+
pkg.name = projectName
|
|
293
|
+
pkg.version = "0.1.0"
|
|
294
|
+
pkg.private = true
|
|
295
|
+
await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
|
|
296
|
+
|
|
297
|
+
// The lockfile carries the old project name in its root entry.
|
|
298
|
+
const lockPath = path.join(targetDir, "package-lock.json")
|
|
299
|
+
if (await exists(lockPath)) {
|
|
300
|
+
const lock = JSON.parse(await readFile(lockPath, "utf8"))
|
|
301
|
+
lock.name = projectName
|
|
302
|
+
if (lock.packages?.[""]) lock.packages[""].name = projectName
|
|
303
|
+
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}
|
|
304
|
+
`)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const envLines = [
|
|
308
|
+
"# Required — the SDK cannot resolve a project without it.",
|
|
309
|
+
`VITE_AUTH_PROJECT_ID=${authProjectId.trim()}`,
|
|
310
|
+
"",
|
|
311
|
+
"# Auth-as-a-Service origin.",
|
|
312
|
+
`VITE_AUTH_API_BASE_URL=${(authApiBaseUrl || "").trim()}`,
|
|
313
|
+
"",
|
|
314
|
+
"# Your own product API (src/lib/http.ts). Unrelated to auth.",
|
|
315
|
+
`VITE_API_BASE_URL=${(apiBaseUrl || "").trim()}`,
|
|
316
|
+
"",
|
|
317
|
+
"# Shown in the sidebar header and breadcrumb.",
|
|
318
|
+
`VITE_APP_NAME=${appName}`,
|
|
319
|
+
"",
|
|
320
|
+
]
|
|
321
|
+
await writeFile(path.join(targetDir, ".env.local"), envLines.join("\n"))
|
|
322
|
+
|
|
323
|
+
if (!includeDocs) {
|
|
324
|
+
await rm(path.join(targetDir, "src/routes/docs"), { recursive: true, force: true })
|
|
325
|
+
await rm(path.join(targetDir, "src/components/docs"), { recursive: true, force: true })
|
|
326
|
+
await rm(path.join(targetDir, "src/routes/app/example.tsx"), { force: true })
|
|
327
|
+
await cp(path.join(variantsDir, "minimal"), targetDir, { recursive: true })
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// index.html carries the tab title.
|
|
331
|
+
const htmlPath = path.join(targetDir, "index.html")
|
|
332
|
+
const html = await readFile(htmlPath, "utf8")
|
|
333
|
+
await writeFile(htmlPath, html.replace(/<title>.*?<\/title>/, `<title>${appName}</title>`))
|
|
334
|
+
|
|
335
|
+
progress.stop("Project created")
|
|
336
|
+
|
|
337
|
+
if (shouldInstall) {
|
|
338
|
+
progress.start(`Installing dependencies with ${packageManager}`)
|
|
339
|
+
try {
|
|
340
|
+
await run(packageManager, ["install"], targetDir)
|
|
341
|
+
progress.stop("Dependencies installed")
|
|
342
|
+
} catch (error) {
|
|
343
|
+
progress.stop("Dependency install failed")
|
|
344
|
+
log.warn(`${error.message}\nRun ${pc.cyan(`${packageManager} install`)} yourself.`)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (shouldGit) {
|
|
349
|
+
try {
|
|
350
|
+
await run("git", ["init"], targetDir)
|
|
351
|
+
await run("git", ["add", "-A"], targetDir)
|
|
352
|
+
await run("git", ["commit", "-m", "Initial commit from create-ab-app"], targetDir)
|
|
353
|
+
log.success("Git repository initialised")
|
|
354
|
+
} catch {
|
|
355
|
+
// Usually an unconfigured user.email — the repo still exists.
|
|
356
|
+
log.warn("Git repository created, but the initial commit failed.")
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── done ────────────────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
const relative = path.relative(process.cwd(), targetDir) || "."
|
|
363
|
+
const runCmd = packageManager === "npm" ? "npm run dev" : `${packageManager} dev`
|
|
364
|
+
|
|
365
|
+
note(
|
|
366
|
+
[
|
|
367
|
+
`cd ${relative}`,
|
|
368
|
+
shouldInstall ? null : `${packageManager} install`,
|
|
369
|
+
runCmd,
|
|
370
|
+
]
|
|
371
|
+
.filter(Boolean)
|
|
372
|
+
.join("\n"),
|
|
373
|
+
"Next steps",
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
log.info(
|
|
377
|
+
`${pc.dim("Auth and billing live in")} src/lib/sso ${pc.dim("— import from")} @/lib/sso${pc.dim(", never from the SDK directly.")}`,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
outro(pc.green("Done."))
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-ab-app",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Scaffold a React + Vite app with ab-ecosystem-sso auth and billing already wired up.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"private": false,
|
|
8
|
+
"bin": {
|
|
9
|
+
"create-ab-app": "index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"template",
|
|
14
|
+
"variants",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"stage": "node scripts/prepare-template.js",
|
|
22
|
+
"prepack": "node scripts/prepare-template.js",
|
|
23
|
+
"check": "node scripts/prepare-template.js && npm pack --dry-run"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"react",
|
|
27
|
+
"vite",
|
|
28
|
+
"template",
|
|
29
|
+
"auth",
|
|
30
|
+
"billing"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@clack/prompts": "^0.11.0",
|
|
34
|
+
"picocolors": "^1.1.1"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Required — your project id in the partner ecosystem.
|
|
2
|
+
VITE_AUTH_PROJECT_ID=436582-forms.abprojects.com
|
|
3
|
+
|
|
4
|
+
# Auth-as-a-Service origin. The SDK also reads this var directly.
|
|
5
|
+
VITE_AUTH_API_BASE_URL=https://sso.1automations.com
|
|
6
|
+
|
|
7
|
+
# Your own product API (used by src/lib/http.ts, unrelated to auth).
|
|
8
|
+
VITE_API_BASE_URL=
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# React + Vite starter — auth & billing included
|
|
2
|
+
|
|
3
|
+
A team template: React 19, Vite, TypeScript, Tailwind v4, shadcn/ui, and
|
|
4
|
+
[`ab-ecosystem-sso`](https://www.npmjs.com/package/ab-ecosystem-sso) wired up for
|
|
5
|
+
authentication **and** billing.
|
|
6
|
+
|
|
7
|
+
## Use it
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm create ab-app@latest my-app
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The CLI (`cli/`) asks for the project id, API origins and app name, writes
|
|
14
|
+
`.env.local`, and optionally installs and inits git. See [PUBLISHING.md](PUBLISHING.md)
|
|
15
|
+
to publish it.
|
|
16
|
+
|
|
17
|
+
## Or run this repo directly
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cp .env.example .env.local # set VITE_AUTH_PROJECT_ID
|
|
21
|
+
npm install
|
|
22
|
+
npm run dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**The running app is the documentation.** Sign in and the sidebar has Overview,
|
|
26
|
+
Project structure, Configuration, and guides for Auth, Billing, Routing, Theming
|
|
27
|
+
and calling your own API — plus a Live example page showing the hooks against
|
|
28
|
+
real data. Source: `src/routes/docs/`.
|
|
29
|
+
|
|
30
|
+
When you start building your product, delete `src/routes/docs/` and
|
|
31
|
+
`src/routes/app/example.tsx`, and replace the groups in `src/config/navigation.ts`.
|
|
32
|
+
This README stays as the offline copy.
|
|
33
|
+
|
|
34
|
+
Missing config fails loudly: without `VITE_AUTH_PROJECT_ID` the app renders a
|
|
35
|
+
setup screen instead of an opaque network error.
|
|
36
|
+
|
|
37
|
+
## The one rule
|
|
38
|
+
|
|
39
|
+
**Product code imports from `@/lib/sso`, never from `ab-ecosystem-sso`.**
|
|
40
|
+
|
|
41
|
+
Every SDK call takes `(projectId, token, apiBaseUrl)`. Those are bound once in
|
|
42
|
+
`src/lib/sso/config.ts`, so nothing downstream repeats them — and an SDK upgrade
|
|
43
|
+
is a change to one folder.
|
|
44
|
+
|
|
45
|
+
## What's in `src/lib/sso`
|
|
46
|
+
|
|
47
|
+
| File | Responsibility |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `config.ts` | The three SDK inputs, bound once. Spread `ssoProps` into any SDK component. |
|
|
50
|
+
| `token-store.ts` | The **only** place that touches token storage. Cross-tab aware; falls back to memory when storage is blocked. |
|
|
51
|
+
| `auth-provider.tsx` / `auth-context.ts` | Owns the token app-wide. `useAuth() → { token, isAuthenticated, signIn, signOut }`. |
|
|
52
|
+
| `auth-screens.tsx` | The SDK's `Login` / `Signup` / `ForgotPassword` / `VerifyEmail`, bound to config, token store and router. |
|
|
53
|
+
| `protected-route.tsx` | `<ProtectedRoute>` (guard + `SessionProvider`) and `<PublicOnlyRoute>`. |
|
|
54
|
+
| `queries.ts` | Cached, pre-bound `useProfile` / `useWallet` / `useCreditWallets` / `useRawSubscription`. |
|
|
55
|
+
| `entitlements.ts` | Normalises the **raw** `/subscription` payload into `useEntitlements() → { isActive, has(id), countOf(id) }`. |
|
|
56
|
+
| `require-plan.tsx` | `<RequirePlan>` — per-feature gating. |
|
|
57
|
+
| `account-settings.tsx` | `useAccountSettings().open(section)` — the billing surface. |
|
|
58
|
+
|
|
59
|
+
### Auth screens are central
|
|
60
|
+
|
|
61
|
+
The SDK exposes no `loginApi` / `signupApi` — sign-in and sign-up are reachable
|
|
62
|
+
*only* through its components, which is also what gives you the project's
|
|
63
|
+
captcha config, the 2FA OTP step and magic-link sign-in for free.
|
|
64
|
+
|
|
65
|
+
**The auth pages have no layout of their own.** Each SDK screen already renders a
|
|
66
|
+
full-page centered root (`flex min-h-screen items-center justify-center p-4`)
|
|
67
|
+
plus its own card, branding and legal links, driven by the project's
|
|
68
|
+
`auth-config` — so any wrapper would just double-wrap it. Route files are one
|
|
69
|
+
line each:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
export default function LoginPage() {
|
|
73
|
+
return <SsoLogin />
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Routes exist because the emailed links need them:
|
|
78
|
+
|
|
79
|
+
| Route | Screen |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `/login` | `Login` — also consumes a `#<access_token>` magic-link hash |
|
|
82
|
+
| `/signup` | `Signup` |
|
|
83
|
+
| `/forgot-password` | `ForgotPassword` — request a reset link |
|
|
84
|
+
| `/reset-password/:token` | `ForgotPassword` — set a new password |
|
|
85
|
+
| `/verify/:token` | `VerifyEmail` |
|
|
86
|
+
|
|
87
|
+
`/reset-password?token=` and `/verify-email?token=` are kept as aliases in case a
|
|
88
|
+
link carries the token as a query string.
|
|
89
|
+
|
|
90
|
+
The two token routes sit **outside** `<PublicOnlyRoute>`: a user who just signed
|
|
91
|
+
up is already signed in, and must still be able to verify or reset.
|
|
92
|
+
|
|
93
|
+
To restyle these screens, pass `classNames` (`root`, `card`, `button`, `input`, …)
|
|
94
|
+
from `auth-screens.tsx`, or retheme via the `--aas-*` bridge below — not by
|
|
95
|
+
wrapping them in app markup.
|
|
96
|
+
|
|
97
|
+
### The login response shape
|
|
98
|
+
|
|
99
|
+
`onSuccess` receives the **raw API envelope**, not the `{ token, user }` that the
|
|
100
|
+
SDK's `AuthResponse` type declares:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{ "success": true, "message": "Login successful",
|
|
104
|
+
"data": { "accessToken": "eyJhbGciOi…" } }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`extractToken()` in `auth-screens.tsx` reads `data.accessToken` first and falls
|
|
108
|
+
back through the other spellings, so a change on either side cannot silently
|
|
109
|
+
break sign-in — and a response with no token now raises an error toast instead
|
|
110
|
+
of doing nothing. Because `onSuccess` is typed `(data: any)`, TypeScript will not
|
|
111
|
+
catch this for you.
|
|
112
|
+
|
|
113
|
+
The token then goes to `tokenStore` (localStorage), which is what `useAuth()`,
|
|
114
|
+
the SDK getters in `queries.ts`, and the `Authorization: Bearer` header in
|
|
115
|
+
`src/lib/http.ts` all read from.
|
|
116
|
+
|
|
117
|
+
### Notifications
|
|
118
|
+
|
|
119
|
+
Each completed auth call raises one sonner toast from `auth-screens.tsx`, all
|
|
120
|
+
sharing a single toast id so a retry replaces the previous one rather than
|
|
121
|
+
stacking. Errors are echoed the same way, alongside the screens' own inline
|
|
122
|
+
error state.
|
|
123
|
+
|
|
124
|
+
| Flow | Callback | Toast |
|
|
125
|
+
| --- | --- | --- |
|
|
126
|
+
| Login | `onSuccess` | Signed in. |
|
|
127
|
+
| Signup (session returned) | `onSuccess` | Account created. |
|
|
128
|
+
| Signup (verification required) | `onSuccess`, no token | Account created. Check your email to verify it. |
|
|
129
|
+
| Reset password | `onResetSuccess` | Password updated. |
|
|
130
|
+
| Verify email | `onVerified` | Email verified. |
|
|
131
|
+
| Any failure | `onError` | The error message. |
|
|
132
|
+
|
|
133
|
+
**Two gaps, both in the SDK rather than here:**
|
|
134
|
+
|
|
135
|
+
- *Forgot password (request a link)* fires no success callback — `ForgotPassword`
|
|
136
|
+
accepts only `onError`, `onResetSuccess` and `onBackToLogin`, and there is no
|
|
137
|
+
exported `forgotPasswordApi` to call directly. The screen shows its own inline
|
|
138
|
+
confirmation; a toast would need the SDK to expose `onSuccess` on that step.
|
|
139
|
+
- *Resend verification email* does not exist — no exported function, no endpoint,
|
|
140
|
+
no UI anywhere in the package.
|
|
141
|
+
|
|
142
|
+
### Billing has no route — by design
|
|
143
|
+
|
|
144
|
+
Plans, add-ons, wallet and credit wallets all live in the SDK's Account Settings
|
|
145
|
+
modal. The sidebar user menu opens it on the right section:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
const { open } = useAccountSettings()
|
|
149
|
+
open("plans") // "general" | "account" | "wallet" | "plans" | "credits"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Gating comes in two strengths:
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
// Hard paywall for the whole app shell — src/App.tsx
|
|
156
|
+
<ProtectedRoute enforceActiveSubscription />
|
|
157
|
+
|
|
158
|
+
// Per-feature
|
|
159
|
+
<RequirePlan ids={["addon-exports"]} fallback={<Upgrade />}>…</RequirePlan>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
> **Check this first when gates misbehave.** `getUserSubscription` returns the
|
|
163
|
+
> API response unmapped, so `mapSubscription()` in `entitlements.ts` guesses at
|
|
164
|
+
> field spellings. Log one real payload and correct the readers there — every
|
|
165
|
+
> gate in the app follows from that one function.
|
|
166
|
+
|
|
167
|
+
## Dashboard
|
|
168
|
+
|
|
169
|
+
`sidebar-07`, rewired: nav comes from `src/config/navigation.ts` (with optional
|
|
170
|
+
`requiresIds` for entitlement-gated items), links use the router instead of
|
|
171
|
+
`<a href>`, the user card reads the live profile, and the footer shows the first
|
|
172
|
+
credit-wallet balance. The block's `team-switcher` was dropped — add it back as a
|
|
173
|
+
project switcher if one app ever serves multiple `projectId`s.
|
|
174
|
+
|
|
175
|
+
## Theming
|
|
176
|
+
|
|
177
|
+
`src/styles/aas-theme.css` maps the SDK's `--aas-*` variables onto the shadcn
|
|
178
|
+
tokens, so the auth screens and the settings modal match the app in light and
|
|
179
|
+
dark. The SDK's CSS is scoped and unlayered — it cannot leak into your app, and
|
|
180
|
+
your preflight cannot override it — which is exactly why this bridge is needed.
|
|
181
|
+
|
|
182
|
+
## Your own API
|
|
183
|
+
|
|
184
|
+
`src/lib/http.ts` attaches the same bearer token and, on a 401, clears the token
|
|
185
|
+
so the route guard bounces to `/login`. Set `VITE_API_BASE_URL`.
|
|
186
|
+
|
|
187
|
+
## Notes
|
|
188
|
+
|
|
189
|
+
- `ab-ecosystem-sso` is **pinned exactly**. It is pre-1.0; upgrade deliberately.
|
|
190
|
+
- Tokens live in `localStorage` (the SDK exposes no refresh flow), so an XSS bug
|
|
191
|
+
exposes them. Swapping strategy is a one-file change in `token-store.ts`.
|
|
192
|
+
- Deep links need an SPA fallback — `public/_redirects` and `vercel.json` are included.
|
|
193
|
+
|
|
194
|
+
## Scripts
|
|
195
|
+
|
|
196
|
+
`npm run dev` · `npm run build` · `npm run typecheck` · `npm run lint` · `npm run preview`
|