@alinsafawi/aegis-auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1825 -0
- package/package.json +36 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1825 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/commands/init.ts
|
|
4
|
+
import * as p7 from "@clack/prompts";
|
|
5
|
+
import chalk8 from "chalk";
|
|
6
|
+
import fs from "fs-extra";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import { execa } from "execa";
|
|
9
|
+
|
|
10
|
+
// src/ui/display.ts
|
|
11
|
+
import chalk from "chalk";
|
|
12
|
+
import gradient from "gradient-string";
|
|
13
|
+
var aegisGradient = gradient(["#06b6d4", "#3b82f6", "#6366f1"]);
|
|
14
|
+
var c = {
|
|
15
|
+
brand: (s) => aegisGradient(s),
|
|
16
|
+
dim: (s) => chalk.dim(s),
|
|
17
|
+
bold: (s) => chalk.bold(s),
|
|
18
|
+
success: (s) => chalk.green(s),
|
|
19
|
+
error: (s) => chalk.red(s),
|
|
20
|
+
warn: (s) => chalk.yellow(s),
|
|
21
|
+
info: (s) => chalk.cyan(s),
|
|
22
|
+
muted: (s) => chalk.gray(s),
|
|
23
|
+
file: (s) => chalk.yellow(s),
|
|
24
|
+
cmd: (s) => chalk.greenBright(s),
|
|
25
|
+
role: (s) => chalk.magentaBright(s),
|
|
26
|
+
step: (s) => chalk.cyanBright(s),
|
|
27
|
+
bar: chalk.dim("\u2502"),
|
|
28
|
+
check: chalk.green("\u2713"),
|
|
29
|
+
cross: chalk.red("\u2717"),
|
|
30
|
+
warn_icon: chalk.yellow("\u26A0"),
|
|
31
|
+
arrow: chalk.cyan("\u2192")
|
|
32
|
+
};
|
|
33
|
+
function printBanner(version = "0.1.0") {
|
|
34
|
+
console.log();
|
|
35
|
+
console.log(
|
|
36
|
+
aegisGradient(
|
|
37
|
+
[
|
|
38
|
+
" \u2584\u2580\u2588 \u2588\u2580\u2580 \u2588\u2580\u2580 \u2588 \u2588\u2580",
|
|
39
|
+
" \u2588\u2580\u2588 \u2588\u2588\u2584 \u2588\u2584\u2588 \u2588 \u2584\u2588"
|
|
40
|
+
].join("\n")
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
console.log();
|
|
44
|
+
console.log(
|
|
45
|
+
chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")
|
|
46
|
+
);
|
|
47
|
+
console.log(
|
|
48
|
+
` ${chalk.white("The shield your Next.js app deserves")}` + chalk.dim(` v ${version}`)
|
|
49
|
+
);
|
|
50
|
+
console.log(
|
|
51
|
+
chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")
|
|
52
|
+
);
|
|
53
|
+
console.log();
|
|
54
|
+
}
|
|
55
|
+
function printSection(title) {
|
|
56
|
+
console.log();
|
|
57
|
+
console.log(chalk.dim(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
|
|
58
|
+
console.log(` ${chalk.cyanBright.bold(title)}`);
|
|
59
|
+
console.log(chalk.dim(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
|
|
60
|
+
console.log();
|
|
61
|
+
}
|
|
62
|
+
function printStatusList(items) {
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
const icon = item.status === "ok" ? chalk.green("\u2713") : item.status === "error" ? chalk.red("\u2717") : item.status === "warn" ? chalk.yellow("\u26A0") : chalk.dim("\u2500");
|
|
65
|
+
const label = item.status === "error" ? chalk.red(item.label) : item.status === "warn" ? chalk.yellow(item.label) : chalk.white(item.label);
|
|
66
|
+
const detail = item.detail ? chalk.dim(` \u2192 ${item.detail}`) : "";
|
|
67
|
+
console.log(` ${icon} ${label}${detail}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function printNextSteps(steps) {
|
|
71
|
+
console.log();
|
|
72
|
+
console.log(chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
73
|
+
console.log();
|
|
74
|
+
for (const step of steps) {
|
|
75
|
+
console.log(` ${chalk.cyanBright.bold(`${step.n}`)} ${chalk.bold(step.title)}`);
|
|
76
|
+
console.log();
|
|
77
|
+
for (const line of step.lines) {
|
|
78
|
+
console.log(` ${line}`);
|
|
79
|
+
}
|
|
80
|
+
console.log();
|
|
81
|
+
}
|
|
82
|
+
console.log(chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
83
|
+
}
|
|
84
|
+
function printDone(appName) {
|
|
85
|
+
console.log();
|
|
86
|
+
console.log(chalk.dim(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
|
|
87
|
+
console.log();
|
|
88
|
+
console.log(` ${chalk.dim("\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E")}`);
|
|
89
|
+
console.log(` ${chalk.dim("\u2571")} ${chalk.dim("\u2572")}`);
|
|
90
|
+
console.log(
|
|
91
|
+
` ${chalk.dim("\u2571")} ${aegisGradient(`Your shield is ready.`)} ${chalk.cyan("\u{1F6E1}")} ${chalk.dim("\u2572")}`
|
|
92
|
+
);
|
|
93
|
+
console.log(` ${chalk.dim("\u2572")} ${chalk.dim("\u2571")}`);
|
|
94
|
+
console.log(` ${chalk.dim("\u2572\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2571")}`);
|
|
95
|
+
console.log();
|
|
96
|
+
console.log(chalk.dim(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
|
|
97
|
+
console.log();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/prompts/project.ts
|
|
101
|
+
import * as p from "@clack/prompts";
|
|
102
|
+
import chalk2 from "chalk";
|
|
103
|
+
async function promptProject(detectedPm) {
|
|
104
|
+
console.log(
|
|
105
|
+
`
|
|
106
|
+
${chalk2.dim("A folder with your project name will be created in the current directory.")}
|
|
107
|
+
`
|
|
108
|
+
);
|
|
109
|
+
const name = await p.text({
|
|
110
|
+
message: "Project name",
|
|
111
|
+
placeholder: "my-saas",
|
|
112
|
+
validate: (v) => {
|
|
113
|
+
if (!v) return "Required";
|
|
114
|
+
if (!/^[a-z0-9-]+$/.test(v)) return "Lowercase letters, numbers, and hyphens only";
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
if (p.isCancel(name)) process.exit(0);
|
|
118
|
+
const language = await p.select({
|
|
119
|
+
message: "Language",
|
|
120
|
+
options: [
|
|
121
|
+
{
|
|
122
|
+
value: "typescript",
|
|
123
|
+
label: "TypeScript",
|
|
124
|
+
hint: "typed permission keys, typed session \u2014 recommended"
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
value: "javascript",
|
|
128
|
+
label: "JavaScript",
|
|
129
|
+
hint: "same files, no type annotations"
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
});
|
|
133
|
+
if (p.isCancel(language)) process.exit(0);
|
|
134
|
+
const pmOptions = [
|
|
135
|
+
{ value: "pnpm", label: "pnpm", hint: detectedPm === "pnpm" ? "detected" : "" },
|
|
136
|
+
{ value: "npm", label: "npm", hint: detectedPm === "npm" ? "detected" : "" },
|
|
137
|
+
{ value: "yarn", label: "yarn", hint: detectedPm === "yarn" ? "detected" : "" },
|
|
138
|
+
{ value: "bun", label: "bun", hint: detectedPm === "bun" ? "detected" : "" }
|
|
139
|
+
];
|
|
140
|
+
const packageManager = await p.select({
|
|
141
|
+
message: "Package manager",
|
|
142
|
+
options: pmOptions,
|
|
143
|
+
initialValue: detectedPm ?? "npm"
|
|
144
|
+
});
|
|
145
|
+
if (p.isCancel(packageManager)) process.exit(0);
|
|
146
|
+
const database = await p.select({
|
|
147
|
+
message: "Database",
|
|
148
|
+
options: [
|
|
149
|
+
{ value: "postgresql", label: "PostgreSQL", hint: "recommended for production" },
|
|
150
|
+
{ value: "mysql", label: "MySQL" },
|
|
151
|
+
{ value: "sqlite", label: "SQLite", hint: "great for local dev, swap URL when deploying" }
|
|
152
|
+
]
|
|
153
|
+
});
|
|
154
|
+
if (p.isCancel(database)) process.exit(0);
|
|
155
|
+
return {
|
|
156
|
+
name,
|
|
157
|
+
language,
|
|
158
|
+
packageManager,
|
|
159
|
+
database
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/prompts/app.ts
|
|
164
|
+
import * as p2 from "@clack/prompts";
|
|
165
|
+
import chalk3 from "chalk";
|
|
166
|
+
async function promptApp(suggestedName) {
|
|
167
|
+
console.log(
|
|
168
|
+
`
|
|
169
|
+
${chalk3.dim("Used in email subjects, the login page heading, and cookie names.")}
|
|
170
|
+
`
|
|
171
|
+
);
|
|
172
|
+
const appName = await p2.text({
|
|
173
|
+
message: "App name",
|
|
174
|
+
placeholder: suggestedName ?? "My SaaS",
|
|
175
|
+
validate: (v) => !v ? "Required" : void 0
|
|
176
|
+
});
|
|
177
|
+
if (p2.isCancel(appName)) process.exit(0);
|
|
178
|
+
const suggested = appName.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 16);
|
|
179
|
+
const cookiePrefix = await p2.text({
|
|
180
|
+
message: "Session cookie prefix",
|
|
181
|
+
placeholder: suggested,
|
|
182
|
+
initialValue: suggested,
|
|
183
|
+
validate: (v) => {
|
|
184
|
+
if (!v) return "Required";
|
|
185
|
+
if (!/^[a-z0-9_]+$/.test(v)) return "Lowercase letters, numbers, underscores only";
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
if (p2.isCancel(cookiePrefix)) process.exit(0);
|
|
189
|
+
console.log(
|
|
190
|
+
`
|
|
191
|
+
${chalk3.dim(`Cookies: ${cookiePrefix}_session ${cookiePrefix}_csrf ${cookiePrefix}_2fa_pending`)}
|
|
192
|
+
`
|
|
193
|
+
);
|
|
194
|
+
const sessionDuration = await p2.select({
|
|
195
|
+
message: "Session duration",
|
|
196
|
+
options: [
|
|
197
|
+
{ value: 2 * 3600, label: "2 hours", hint: "high-security \u2014 internal tools, finance" },
|
|
198
|
+
{ value: 8 * 3600, label: "8 hours", hint: "recommended for most apps" },
|
|
199
|
+
{ value: 24 * 3600, label: "24 hours", hint: "relaxed \u2014 consumer / low-risk apps" }
|
|
200
|
+
],
|
|
201
|
+
initialValue: 8 * 3600
|
|
202
|
+
});
|
|
203
|
+
if (p2.isCancel(sessionDuration)) process.exit(0);
|
|
204
|
+
return {
|
|
205
|
+
appName,
|
|
206
|
+
cookiePrefix,
|
|
207
|
+
sessionDuration
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/prompts/roles.ts
|
|
212
|
+
import * as p3 from "@clack/prompts";
|
|
213
|
+
import chalk4 from "chalk";
|
|
214
|
+
async function promptPermissions() {
|
|
215
|
+
const mode = await p3.select({
|
|
216
|
+
message: "Permission model",
|
|
217
|
+
options: [
|
|
218
|
+
{
|
|
219
|
+
value: "all",
|
|
220
|
+
label: "Full access",
|
|
221
|
+
hint: "no permission checks \u2014 use for super-admins"
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
value: "none",
|
|
225
|
+
label: "Auth only",
|
|
226
|
+
hint: "just needs to be logged in, no granular permissions"
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
value: "static",
|
|
230
|
+
label: "Static",
|
|
231
|
+
hint: "you define permissions in auth.config.ts \u2014 type-safe"
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
value: "dynamic",
|
|
235
|
+
label: "Dynamic",
|
|
236
|
+
hint: "app admin creates permissions at runtime, no code changes"
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
value: "hybrid",
|
|
240
|
+
label: "Hybrid",
|
|
241
|
+
hint: "static base + app admin can add more at runtime"
|
|
242
|
+
}
|
|
243
|
+
]
|
|
244
|
+
});
|
|
245
|
+
if (p3.isCancel(mode)) process.exit(0);
|
|
246
|
+
if (mode === "all") return "all";
|
|
247
|
+
if (mode === "none") return "none";
|
|
248
|
+
if (mode === "dynamic") return "dynamic";
|
|
249
|
+
const permissions = {};
|
|
250
|
+
console.log(
|
|
251
|
+
`
|
|
252
|
+
${chalk4.dim("Define each permission. Press Enter on empty key when done.")}
|
|
253
|
+
`
|
|
254
|
+
);
|
|
255
|
+
while (true) {
|
|
256
|
+
const key = await p3.text({
|
|
257
|
+
message: "Permission key (leave blank to finish)",
|
|
258
|
+
placeholder: "manage_users",
|
|
259
|
+
validate: (v) => {
|
|
260
|
+
if (v && !/^[a-z0-9_]+$/.test(v))
|
|
261
|
+
return "Lowercase letters, numbers, underscores only";
|
|
262
|
+
if (v && permissions[v]) return "Key already defined";
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
if (p3.isCancel(key)) process.exit(0);
|
|
266
|
+
if (!key) break;
|
|
267
|
+
const label = await p3.text({
|
|
268
|
+
message: "Label",
|
|
269
|
+
placeholder: "Manage Users",
|
|
270
|
+
validate: (v) => !v ? "Required" : void 0
|
|
271
|
+
});
|
|
272
|
+
if (p3.isCancel(label)) process.exit(0);
|
|
273
|
+
const group = await p3.text({
|
|
274
|
+
message: "Group (optional \u2014 leave blank to skip)",
|
|
275
|
+
placeholder: "Users"
|
|
276
|
+
});
|
|
277
|
+
if (p3.isCancel(group)) process.exit(0);
|
|
278
|
+
const defaultOn = await p3.confirm({
|
|
279
|
+
message: "On by default for new users?",
|
|
280
|
+
initialValue: false
|
|
281
|
+
});
|
|
282
|
+
if (p3.isCancel(defaultOn)) process.exit(0);
|
|
283
|
+
permissions[key] = {
|
|
284
|
+
label,
|
|
285
|
+
group: group || void 0,
|
|
286
|
+
default: defaultOn
|
|
287
|
+
};
|
|
288
|
+
const keys = Object.keys(permissions);
|
|
289
|
+
console.log(
|
|
290
|
+
`
|
|
291
|
+
${chalk4.dim("Permissions so far:")} ${keys.map((k) => chalk4.cyan(k)).join(chalk4.dim(", "))}
|
|
292
|
+
`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return permissions;
|
|
296
|
+
}
|
|
297
|
+
async function promptRole(index, total) {
|
|
298
|
+
console.log(
|
|
299
|
+
`
|
|
300
|
+
${chalk4.dim("\u2500")} ${chalk4.cyanBright.bold(`Role ${index} of ${total}`)} ${chalk4.dim("\u2500".repeat(46))}
|
|
301
|
+
`
|
|
302
|
+
);
|
|
303
|
+
const id = await p3.text({
|
|
304
|
+
message: "Role ID",
|
|
305
|
+
placeholder: "admin",
|
|
306
|
+
validate: (v) => {
|
|
307
|
+
if (!v) return "Required";
|
|
308
|
+
if (!/^[a-z0-9_]+$/.test(v)) return "Lowercase letters, numbers, underscores only";
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
if (p3.isCancel(id)) process.exit(0);
|
|
312
|
+
console.log(
|
|
313
|
+
`
|
|
314
|
+
${chalk4.dim(`Goes in the JWT: session.role === '${id}'`)}
|
|
315
|
+
${chalk4.dim("Cannot be renamed later without a data migration.")}
|
|
316
|
+
`
|
|
317
|
+
);
|
|
318
|
+
const label = await p3.text({
|
|
319
|
+
message: "Display label",
|
|
320
|
+
placeholder: "Admin",
|
|
321
|
+
validate: (v) => !v ? "Required" : void 0
|
|
322
|
+
});
|
|
323
|
+
if (p3.isCancel(label)) process.exit(0);
|
|
324
|
+
const plural = await p3.text({
|
|
325
|
+
message: "Plural label (optional)",
|
|
326
|
+
placeholder: `${label}s`
|
|
327
|
+
});
|
|
328
|
+
if (p3.isCancel(plural)) process.exit(0);
|
|
329
|
+
const prismaModel = await p3.text({
|
|
330
|
+
message: "Prisma model name",
|
|
331
|
+
placeholder: `${label.replace(/\s+/g, "")}`,
|
|
332
|
+
validate: (v) => {
|
|
333
|
+
if (!v) return "Required";
|
|
334
|
+
if (!/^[A-Z][A-Za-z0-9]+$/.test(v)) return "PascalCase required e.g: AdminUser";
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
if (p3.isCancel(prismaModel)) process.exit(0);
|
|
338
|
+
console.log(
|
|
339
|
+
`
|
|
340
|
+
${chalk4.dim("If this model already exists in your schema, Aegis adds the required auth fields to it.")}
|
|
341
|
+
`
|
|
342
|
+
);
|
|
343
|
+
const loginField = await p3.select({
|
|
344
|
+
message: "Login identifier",
|
|
345
|
+
options: [
|
|
346
|
+
{ value: "email", label: "Email address" },
|
|
347
|
+
{ value: "username", label: "Username" },
|
|
348
|
+
{ value: "either", label: "Either (user can type both)" }
|
|
349
|
+
]
|
|
350
|
+
});
|
|
351
|
+
if (p3.isCancel(loginField)) process.exit(0);
|
|
352
|
+
const signup = await p3.select({
|
|
353
|
+
message: "How do new users of this role join?",
|
|
354
|
+
options: [
|
|
355
|
+
{
|
|
356
|
+
value: "public",
|
|
357
|
+
label: "Open signup",
|
|
358
|
+
hint: "anyone can register themselves"
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
value: "invite-only",
|
|
362
|
+
label: "Invite-only",
|
|
363
|
+
hint: "you send time-limited invite links"
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
value: "disabled",
|
|
367
|
+
label: "Disabled",
|
|
368
|
+
hint: "created by a higher role only, no self-service"
|
|
369
|
+
}
|
|
370
|
+
]
|
|
371
|
+
});
|
|
372
|
+
if (p3.isCancel(signup)) process.exit(0);
|
|
373
|
+
const singleton = await p3.confirm({
|
|
374
|
+
message: "Singleton? (only one user of this role allowed)",
|
|
375
|
+
initialValue: false
|
|
376
|
+
});
|
|
377
|
+
if (p3.isCancel(singleton)) process.exit(0);
|
|
378
|
+
const permissions = await promptPermissions();
|
|
379
|
+
return {
|
|
380
|
+
id,
|
|
381
|
+
config: {
|
|
382
|
+
label,
|
|
383
|
+
plural: plural || `${label}s`,
|
|
384
|
+
prismaModel,
|
|
385
|
+
loginField,
|
|
386
|
+
homeRoute: `/${id}/dashboard`,
|
|
387
|
+
singleton,
|
|
388
|
+
signup,
|
|
389
|
+
permissions
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
async function promptRoles() {
|
|
394
|
+
console.log(
|
|
395
|
+
`
|
|
396
|
+
${chalk4.dim("A role is a user type with its own login, permissions, and dashboard.")}
|
|
397
|
+
${chalk4.dim("You can add more later with: aegis-auth add-role")}
|
|
398
|
+
`
|
|
399
|
+
);
|
|
400
|
+
const count = await p3.select({
|
|
401
|
+
message: "How many roles does your app have?",
|
|
402
|
+
options: [
|
|
403
|
+
{ value: 1, label: "1" },
|
|
404
|
+
{ value: 2, label: "2" },
|
|
405
|
+
{ value: 3, label: "3" },
|
|
406
|
+
{ value: 4, label: "4" }
|
|
407
|
+
],
|
|
408
|
+
initialValue: 2
|
|
409
|
+
});
|
|
410
|
+
if (p3.isCancel(count)) process.exit(0);
|
|
411
|
+
const roles = {};
|
|
412
|
+
for (let i = 1; i <= count; i++) {
|
|
413
|
+
const { id, config } = await promptRole(i, count);
|
|
414
|
+
roles[id] = config;
|
|
415
|
+
}
|
|
416
|
+
return roles;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// src/prompts/features.ts
|
|
420
|
+
import * as p4 from "@clack/prompts";
|
|
421
|
+
import chalk5 from "chalk";
|
|
422
|
+
async function promptFeatures(roleIds) {
|
|
423
|
+
console.log(
|
|
424
|
+
`
|
|
425
|
+
${chalk5.dim("All features can be toggled later in auth.config.ts \u2192 features")}
|
|
426
|
+
`
|
|
427
|
+
);
|
|
428
|
+
const selected = await p4.multiselect({
|
|
429
|
+
message: "Which security features do you want?",
|
|
430
|
+
options: [
|
|
431
|
+
{
|
|
432
|
+
value: "twoFactor",
|
|
433
|
+
label: "Two-Factor Authentication",
|
|
434
|
+
hint: "TOTP (Authy, 1Password) + backup codes"
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
value: "emailVerification",
|
|
438
|
+
label: "Email Verification",
|
|
439
|
+
hint: "6-digit code, 15 min expiry, resend flow"
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
value: "passwordReset",
|
|
443
|
+
label: "Password Reset",
|
|
444
|
+
hint: '"Forgot password" via email'
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
value: "accountLockout",
|
|
448
|
+
label: "Account Lockout",
|
|
449
|
+
hint: "per-account, not per-IP \u2014 locks after failed logins"
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
value: "apiKeys",
|
|
453
|
+
label: "API Keys",
|
|
454
|
+
hint: "users generate keys for programmatic access"
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
value: "auditLog",
|
|
458
|
+
label: "Audit Log",
|
|
459
|
+
hint: "records every auth event to a DB table"
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
value: "sessionTracking",
|
|
463
|
+
label: "Session Tracking",
|
|
464
|
+
hint: '"Sign out all devices" \u2014 without this, JWTs are stateless'
|
|
465
|
+
}
|
|
466
|
+
],
|
|
467
|
+
initialValues: ["twoFactor", "emailVerification", "passwordReset", "accountLockout"],
|
|
468
|
+
required: false
|
|
469
|
+
});
|
|
470
|
+
if (p4.isCancel(selected)) process.exit(0);
|
|
471
|
+
const has = (f) => selected.includes(f);
|
|
472
|
+
let lockoutAttempts = 5;
|
|
473
|
+
let lockoutMinutes = 15;
|
|
474
|
+
let enforced2FARoles = [];
|
|
475
|
+
if (has("accountLockout")) {
|
|
476
|
+
console.log(
|
|
477
|
+
`
|
|
478
|
+
${chalk5.dim("\u2500 Account Lockout Settings \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")}
|
|
479
|
+
`
|
|
480
|
+
);
|
|
481
|
+
const attempts = await p4.text({
|
|
482
|
+
message: "Lock after how many failed attempts?",
|
|
483
|
+
initialValue: "5",
|
|
484
|
+
validate: (v) => Number(v) < 1 || isNaN(Number(v)) ? "Enter a number \u2265 1" : void 0
|
|
485
|
+
});
|
|
486
|
+
if (p4.isCancel(attempts)) process.exit(0);
|
|
487
|
+
lockoutAttempts = Number(attempts);
|
|
488
|
+
console.log(
|
|
489
|
+
`
|
|
490
|
+
${chalk5.dim("Recommended: 3\u201310. Too low causes accidental lockouts. Counter resets on successful login.")}
|
|
491
|
+
`
|
|
492
|
+
);
|
|
493
|
+
const duration = await p4.select({
|
|
494
|
+
message: "Lock duration",
|
|
495
|
+
options: [
|
|
496
|
+
{ value: 15, label: "15 minutes", hint: "recommended" },
|
|
497
|
+
{ value: 30, label: "30 minutes" },
|
|
498
|
+
{ value: 60, label: "1 hour" },
|
|
499
|
+
{ value: -1, label: "Permanent", hint: "requires admin to manually unlock" }
|
|
500
|
+
],
|
|
501
|
+
initialValue: 15
|
|
502
|
+
});
|
|
503
|
+
if (p4.isCancel(duration)) process.exit(0);
|
|
504
|
+
lockoutMinutes = duration;
|
|
505
|
+
}
|
|
506
|
+
if (has("twoFactor") && roleIds.length > 0) {
|
|
507
|
+
console.log(
|
|
508
|
+
`
|
|
509
|
+
${chalk5.dim("\u2500 2FA Enforcement \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")}
|
|
510
|
+
`
|
|
511
|
+
);
|
|
512
|
+
console.log(
|
|
513
|
+
` ${chalk5.dim("Enforced roles are redirected to 2FA setup on login and cannot")}
|
|
514
|
+
${chalk5.dim("access the app until setup is complete.")}
|
|
515
|
+
`
|
|
516
|
+
);
|
|
517
|
+
const enforced = await p4.multiselect({
|
|
518
|
+
message: "Require 2FA for any roles? (cannot be skipped)",
|
|
519
|
+
options: [
|
|
520
|
+
{ value: "__none__", label: "No role requires it" },
|
|
521
|
+
...roleIds.map((id) => ({ value: id, label: id }))
|
|
522
|
+
],
|
|
523
|
+
initialValues: ["__none__"],
|
|
524
|
+
required: false
|
|
525
|
+
});
|
|
526
|
+
if (p4.isCancel(enforced)) process.exit(0);
|
|
527
|
+
enforced2FARoles = enforced.filter((r) => r !== "__none__");
|
|
528
|
+
}
|
|
529
|
+
console.log(
|
|
530
|
+
`
|
|
531
|
+
${chalk5.dim("\u2500 Password Policy \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")}
|
|
532
|
+
`
|
|
533
|
+
);
|
|
534
|
+
console.log(
|
|
535
|
+
` ${chalk5.dim("NIST recommends 8+ chars. Avoid forcing complexity rules \u2014")}
|
|
536
|
+
${chalk5.dim("they lead to weaker, more predictable passwords in practice.")}
|
|
537
|
+
`
|
|
538
|
+
);
|
|
539
|
+
const minLen = await p4.text({
|
|
540
|
+
message: "Minimum password length",
|
|
541
|
+
initialValue: "8",
|
|
542
|
+
validate: (v) => Number(v) < 6 || isNaN(Number(v)) ? "Enter a number \u2265 6" : void 0
|
|
543
|
+
});
|
|
544
|
+
if (p4.isCancel(minLen)) process.exit(0);
|
|
545
|
+
const expiry = await p4.select({
|
|
546
|
+
message: "Password expiry",
|
|
547
|
+
options: [
|
|
548
|
+
{ value: null, label: "Never", hint: "recommended for consumer apps" },
|
|
549
|
+
{ value: 90, label: "Every 90 days", hint: "common for internal/enterprise tools" },
|
|
550
|
+
{ value: 180, label: "Every 180 days" }
|
|
551
|
+
],
|
|
552
|
+
initialValue: null
|
|
553
|
+
});
|
|
554
|
+
if (p4.isCancel(expiry)) process.exit(0);
|
|
555
|
+
const reuse = await p4.select({
|
|
556
|
+
message: "Prevent reusing old passwords?",
|
|
557
|
+
options: [
|
|
558
|
+
{ value: null, label: "No" },
|
|
559
|
+
{ value: 5, label: "Yes \u2014 remember last 5" },
|
|
560
|
+
{ value: 10, label: "Yes \u2014 remember last 10" }
|
|
561
|
+
],
|
|
562
|
+
initialValue: null
|
|
563
|
+
});
|
|
564
|
+
if (p4.isCancel(reuse)) process.exit(0);
|
|
565
|
+
return {
|
|
566
|
+
twoFactor: has("twoFactor"),
|
|
567
|
+
emailVerification: has("emailVerification"),
|
|
568
|
+
passwordReset: has("passwordReset"),
|
|
569
|
+
accountLockout: has("accountLockout"),
|
|
570
|
+
lockoutAttempts,
|
|
571
|
+
lockoutMinutes,
|
|
572
|
+
apiKeys: has("apiKeys"),
|
|
573
|
+
auditLog: has("auditLog"),
|
|
574
|
+
sessionTracking: has("sessionTracking"),
|
|
575
|
+
enforced2FARoles,
|
|
576
|
+
minPasswordLength: Number(minLen),
|
|
577
|
+
passwordExpiry: expiry,
|
|
578
|
+
preventPasswordReuse: reuse
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/prompts/infrastructure.ts
|
|
583
|
+
import * as p5 from "@clack/prompts";
|
|
584
|
+
import chalk6 from "chalk";
|
|
585
|
+
async function promptInfrastructure() {
|
|
586
|
+
console.log(
|
|
587
|
+
`
|
|
588
|
+
${chalk6.dim("\u2500 Rate Limiting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")}
|
|
589
|
+
`
|
|
590
|
+
);
|
|
591
|
+
const rateLimitProvider = await p5.select({
|
|
592
|
+
message: "Rate limiting backend",
|
|
593
|
+
options: [
|
|
594
|
+
{
|
|
595
|
+
value: "upstash",
|
|
596
|
+
label: "Upstash Redis",
|
|
597
|
+
hint: "recommended \u2014 serverless, free tier, survives restarts"
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
value: "memory",
|
|
601
|
+
label: "In-memory",
|
|
602
|
+
hint: "no external service \u2014 dev only, resets on restart"
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
value: "none",
|
|
606
|
+
label: "None",
|
|
607
|
+
hint: "not recommended \u2014 login endpoints unprotected"
|
|
608
|
+
}
|
|
609
|
+
],
|
|
610
|
+
initialValue: "upstash"
|
|
611
|
+
});
|
|
612
|
+
if (p5.isCancel(rateLimitProvider)) process.exit(0);
|
|
613
|
+
if (rateLimitProvider === "upstash") {
|
|
614
|
+
console.log(
|
|
615
|
+
`
|
|
616
|
+
${chalk6.dim("Needs: UPSTASH_REDIS_REST_URL UPSTASH_REDIS_REST_TOKEN")}
|
|
617
|
+
${chalk6.dim("These will be added to .env.example for you.")}
|
|
618
|
+
`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
if (rateLimitProvider === "none") {
|
|
622
|
+
console.log(
|
|
623
|
+
`
|
|
624
|
+
${chalk6.yellow("\u26A0")} ${chalk6.yellow("Login and reset endpoints will be open to brute-force attacks.")}
|
|
625
|
+
`
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
console.log(
|
|
629
|
+
`
|
|
630
|
+
${chalk6.dim("\u2500 Email \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")}
|
|
631
|
+
`
|
|
632
|
+
);
|
|
633
|
+
const setupSmtp = await p5.confirm({
|
|
634
|
+
message: "Enter SMTP credentials now?",
|
|
635
|
+
initialValue: false
|
|
636
|
+
});
|
|
637
|
+
if (p5.isCancel(setupSmtp)) process.exit(0);
|
|
638
|
+
if (!setupSmtp) {
|
|
639
|
+
console.log(
|
|
640
|
+
`
|
|
641
|
+
${chalk6.dim("All required SMTP variables will be added to .env.example with instructions.")}
|
|
642
|
+
`
|
|
643
|
+
);
|
|
644
|
+
return { rateLimitProvider, setupSmtp: false };
|
|
645
|
+
}
|
|
646
|
+
const smtpHost = await p5.text({ message: "SMTP host", placeholder: "smtp.resend.com" });
|
|
647
|
+
if (p5.isCancel(smtpHost)) process.exit(0);
|
|
648
|
+
const smtpPort = await p5.text({
|
|
649
|
+
message: "SMTP port",
|
|
650
|
+
initialValue: "587",
|
|
651
|
+
validate: (v) => isNaN(Number(v)) ? "Must be a number" : void 0
|
|
652
|
+
});
|
|
653
|
+
if (p5.isCancel(smtpPort)) process.exit(0);
|
|
654
|
+
const smtpUser = await p5.text({ message: "SMTP username / API key" });
|
|
655
|
+
if (p5.isCancel(smtpUser)) process.exit(0);
|
|
656
|
+
const smtpPass = await p5.password({ message: "SMTP password" });
|
|
657
|
+
if (p5.isCancel(smtpPass)) process.exit(0);
|
|
658
|
+
const smtpFrom = await p5.text({
|
|
659
|
+
message: "From address",
|
|
660
|
+
placeholder: '"My App" <noreply@myapp.com>'
|
|
661
|
+
});
|
|
662
|
+
if (p5.isCancel(smtpFrom)) process.exit(0);
|
|
663
|
+
return {
|
|
664
|
+
rateLimitProvider,
|
|
665
|
+
setupSmtp: true,
|
|
666
|
+
smtpHost,
|
|
667
|
+
smtpPort: Number(smtpPort),
|
|
668
|
+
smtpUser,
|
|
669
|
+
smtpPass,
|
|
670
|
+
smtpFrom
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// src/prompts/style.ts
|
|
675
|
+
import * as p6 from "@clack/prompts";
|
|
676
|
+
import chalk7 from "chalk";
|
|
677
|
+
async function promptStyle() {
|
|
678
|
+
console.log(
|
|
679
|
+
`
|
|
680
|
+
${chalk7.dim("Used on buttons, links, and focus rings in all scaffolded pages.")}
|
|
681
|
+
${chalk7.dim("This becomes a CSS variable \u2014 change it any time in globals.css.")}
|
|
682
|
+
`
|
|
683
|
+
);
|
|
684
|
+
const primaryColor = await p6.text({
|
|
685
|
+
message: "Primary color",
|
|
686
|
+
initialValue: "oklch(0.6 0.2 240)",
|
|
687
|
+
validate: (v) => !v ? "Required" : void 0
|
|
688
|
+
});
|
|
689
|
+
if (p6.isCancel(primaryColor)) process.exit(0);
|
|
690
|
+
console.log(
|
|
691
|
+
`
|
|
692
|
+
${chalk7.dim("Accepts any CSS color:")}
|
|
693
|
+
${chalk7.dim(" #3b82f6 (hex)")}
|
|
694
|
+
${chalk7.dim(" hsl(220 90% 56%) (HSL)")}
|
|
695
|
+
${chalk7.dim(" oklch(0.6 0.2 240) (recommended \u2014 perceptually uniform)")}
|
|
696
|
+
`
|
|
697
|
+
);
|
|
698
|
+
const logoUrl = await p6.text({
|
|
699
|
+
message: "Logo path (optional \u2014 leave blank to skip)",
|
|
700
|
+
placeholder: "/logo.svg"
|
|
701
|
+
});
|
|
702
|
+
if (p6.isCancel(logoUrl)) process.exit(0);
|
|
703
|
+
return {
|
|
704
|
+
primaryColor,
|
|
705
|
+
logoUrl: logoUrl || void 0
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// src/generators/schema.ts
|
|
710
|
+
import { resolvePermissionMode } from "@alinsafawi/aegis-auth-core";
|
|
711
|
+
function roleModel(id, role, features) {
|
|
712
|
+
const f = role.fields ?? {};
|
|
713
|
+
const idField = f.id ?? "id";
|
|
714
|
+
const usernameField = f.username ?? "username";
|
|
715
|
+
const emailField = f.email ?? "email";
|
|
716
|
+
const hashField = f.passwordHash ?? "passwordHash";
|
|
717
|
+
const tfSecretField = f.twoFactorSecret ?? "twoFactorSecret";
|
|
718
|
+
const tfEnabledField = f.twoFactorEnabled ?? "twoFactorEnabled";
|
|
719
|
+
const emailVerifiedField = f.emailVerified ?? "emailVerified";
|
|
720
|
+
const mode = resolvePermissionMode(role);
|
|
721
|
+
const isStatic = mode === "static" || mode === "hybrid";
|
|
722
|
+
const storage = role.permissionsStorage ?? "json";
|
|
723
|
+
const lines = [
|
|
724
|
+
`model ${role.prismaModel} {`,
|
|
725
|
+
` ${idField} String @id @default(cuid())`
|
|
726
|
+
];
|
|
727
|
+
if (role.loginField === "username" || role.loginField === "either") {
|
|
728
|
+
lines.push(` ${usernameField} String @unique`);
|
|
729
|
+
}
|
|
730
|
+
if (role.loginField === "email" || role.loginField === "either") {
|
|
731
|
+
lines.push(` ${emailField} String @unique`);
|
|
732
|
+
}
|
|
733
|
+
lines.push(` ${hashField} String`);
|
|
734
|
+
if (features.emailVerification) {
|
|
735
|
+
lines.push(` ${emailVerifiedField} Boolean @default(false)`);
|
|
736
|
+
}
|
|
737
|
+
if (features.twoFactor) {
|
|
738
|
+
lines.push(` ${tfSecretField} String?`);
|
|
739
|
+
lines.push(` ${tfEnabledField} Boolean @default(false)`);
|
|
740
|
+
}
|
|
741
|
+
lines.push(` requiresPasswordChange Boolean @default(false)`);
|
|
742
|
+
lines.push(` createdAt DateTime @default(now())`);
|
|
743
|
+
lines.push(` updatedAt DateTime @updatedAt`);
|
|
744
|
+
if (isStatic && storage === "columns" && typeof role.permissions === "object") {
|
|
745
|
+
for (const [key, perm] of Object.entries(role.permissions)) {
|
|
746
|
+
lines.push(` ${key.padEnd(24)}Boolean @default(${perm.default})`);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (isStatic && storage === "json") {
|
|
750
|
+
lines.push(` permissions Json @default("{}")`);
|
|
751
|
+
}
|
|
752
|
+
if (features.sessionTracking) {
|
|
753
|
+
lines.push(` sessions AuthSession[]`);
|
|
754
|
+
}
|
|
755
|
+
lines.push(`}`);
|
|
756
|
+
return lines.join("\n");
|
|
757
|
+
}
|
|
758
|
+
function generatePrismaSchema(opts) {
|
|
759
|
+
const { roles, database, features } = opts;
|
|
760
|
+
const hasDynamic = Object.values(roles).some((r) => {
|
|
761
|
+
const m = resolvePermissionMode(r);
|
|
762
|
+
return m === "dynamic" || m === "hybrid";
|
|
763
|
+
});
|
|
764
|
+
const sections = [
|
|
765
|
+
`// Generated by aegis-auth \u2014 do not edit manually`,
|
|
766
|
+
`// Add your own models below the aegis-auth section`,
|
|
767
|
+
``,
|
|
768
|
+
`generator client {`,
|
|
769
|
+
` provider = "prisma-client-js"`,
|
|
770
|
+
`}`,
|
|
771
|
+
``,
|
|
772
|
+
`datasource db {`,
|
|
773
|
+
` provider = "${database}"`,
|
|
774
|
+
` url = env("DATABASE_URL")`,
|
|
775
|
+
`}`,
|
|
776
|
+
``,
|
|
777
|
+
`// \u2500\u2500\u2500 Aegis Auth \u2014 Role Models \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`,
|
|
778
|
+
``
|
|
779
|
+
];
|
|
780
|
+
for (const [id, role] of Object.entries(roles)) {
|
|
781
|
+
sections.push(roleModel(id, role, features));
|
|
782
|
+
sections.push(``);
|
|
783
|
+
}
|
|
784
|
+
sections.push(`// \u2500\u2500\u2500 Aegis Auth \u2014 Shared Tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
|
|
785
|
+
sections.push(``);
|
|
786
|
+
if (features.passwordReset) {
|
|
787
|
+
sections.push(
|
|
788
|
+
`model AuthPasswordResetChallenge {`,
|
|
789
|
+
` id String @id @default(cuid())`,
|
|
790
|
+
` roleType String`,
|
|
791
|
+
` userId String`,
|
|
792
|
+
` codeHash String`,
|
|
793
|
+
` expiresAt DateTime`,
|
|
794
|
+
` usedAt DateTime?`,
|
|
795
|
+
` createdAt DateTime @default(now())`,
|
|
796
|
+
``,
|
|
797
|
+
` @@index([roleType, userId])`,
|
|
798
|
+
`}`,
|
|
799
|
+
``
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
if (features.emailVerification) {
|
|
803
|
+
sections.push(
|
|
804
|
+
`model AuthEmailVerificationChallenge {`,
|
|
805
|
+
` id String @id @default(cuid())`,
|
|
806
|
+
` roleType String`,
|
|
807
|
+
` userId String`,
|
|
808
|
+
` codeHash String`,
|
|
809
|
+
` expiresAt DateTime`,
|
|
810
|
+
` usedAt DateTime?`,
|
|
811
|
+
` createdAt DateTime @default(now())`,
|
|
812
|
+
``,
|
|
813
|
+
` @@index([roleType, userId])`,
|
|
814
|
+
`}`,
|
|
815
|
+
``
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
if (features.accountLockout) {
|
|
819
|
+
sections.push(
|
|
820
|
+
`model AuthAccountLockout {`,
|
|
821
|
+
` id String @id @default(cuid())`,
|
|
822
|
+
` roleType String`,
|
|
823
|
+
` userId String`,
|
|
824
|
+
` failedAttempts Int @default(0)`,
|
|
825
|
+
` lockedUntil DateTime?`,
|
|
826
|
+
` updatedAt DateTime @updatedAt`,
|
|
827
|
+
``,
|
|
828
|
+
` @@unique([roleType, userId])`,
|
|
829
|
+
`}`,
|
|
830
|
+
``
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
if (hasDynamic) {
|
|
834
|
+
sections.push(
|
|
835
|
+
`model AuthPermission {`,
|
|
836
|
+
` id String @id @default(cuid())`,
|
|
837
|
+
` key String @unique`,
|
|
838
|
+
` label String`,
|
|
839
|
+
` description String?`,
|
|
840
|
+
` group String?`,
|
|
841
|
+
` appliesTo String[]`,
|
|
842
|
+
` createdAt DateTime @default(now())`,
|
|
843
|
+
``,
|
|
844
|
+
` grants AuthUserPermission[]`,
|
|
845
|
+
`}`,
|
|
846
|
+
``,
|
|
847
|
+
`model AuthUserPermission {`,
|
|
848
|
+
` id String @id @default(cuid())`,
|
|
849
|
+
` permissionId String`,
|
|
850
|
+
` permission AuthPermission @relation(fields: [permissionId], references: [id], onDelete: Cascade)`,
|
|
851
|
+
` roleType String`,
|
|
852
|
+
` userId String`,
|
|
853
|
+
` granted Boolean @default(true)`,
|
|
854
|
+
` grantedById String?`,
|
|
855
|
+
` grantedAt DateTime @default(now())`,
|
|
856
|
+
``,
|
|
857
|
+
` @@unique([permissionId, roleType, userId])`,
|
|
858
|
+
` @@index([roleType, userId])`,
|
|
859
|
+
`}`,
|
|
860
|
+
``
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
if (features.twoFactor) {
|
|
864
|
+
sections.push(
|
|
865
|
+
`model AuthTwoFactorBackupCode {`,
|
|
866
|
+
` id String @id @default(cuid())`,
|
|
867
|
+
` roleType String`,
|
|
868
|
+
` userId String`,
|
|
869
|
+
` codeHash String`,
|
|
870
|
+
` usedAt DateTime?`,
|
|
871
|
+
` createdAt DateTime @default(now())`,
|
|
872
|
+
``,
|
|
873
|
+
` @@index([roleType, userId])`,
|
|
874
|
+
`}`,
|
|
875
|
+
``
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
if (features.preventPasswordReuse) {
|
|
879
|
+
sections.push(
|
|
880
|
+
`model AuthPasswordHistory {`,
|
|
881
|
+
` id String @id @default(cuid())`,
|
|
882
|
+
` roleType String`,
|
|
883
|
+
` userId String`,
|
|
884
|
+
` hash String`,
|
|
885
|
+
` createdAt DateTime @default(now())`,
|
|
886
|
+
``,
|
|
887
|
+
` @@index([roleType, userId])`,
|
|
888
|
+
`}`,
|
|
889
|
+
``
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
if (features.sessionTracking) {
|
|
893
|
+
sections.push(
|
|
894
|
+
`model AuthSession {`,
|
|
895
|
+
` id String @id @default(cuid())`,
|
|
896
|
+
` roleType String`,
|
|
897
|
+
` userId String`,
|
|
898
|
+
` token String @unique`,
|
|
899
|
+
` userAgent String?`,
|
|
900
|
+
` ip String?`,
|
|
901
|
+
` expiresAt DateTime`,
|
|
902
|
+
` createdAt DateTime @default(now())`,
|
|
903
|
+
``,
|
|
904
|
+
` @@index([roleType, userId])`,
|
|
905
|
+
`}`,
|
|
906
|
+
``
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
if (features.apiKeys) {
|
|
910
|
+
sections.push(
|
|
911
|
+
`model AuthApiKey {`,
|
|
912
|
+
` id String @id @default(cuid())`,
|
|
913
|
+
` roleType String`,
|
|
914
|
+
` userId String`,
|
|
915
|
+
` keyHash String @unique`,
|
|
916
|
+
` label String`,
|
|
917
|
+
` lastUsed DateTime?`,
|
|
918
|
+
` expiresAt DateTime?`,
|
|
919
|
+
` createdAt DateTime @default(now())`,
|
|
920
|
+
``,
|
|
921
|
+
` @@index([roleType, userId])`,
|
|
922
|
+
`}`,
|
|
923
|
+
``
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
if (features.auditLog) {
|
|
927
|
+
sections.push(
|
|
928
|
+
`model AuthAuditLog {`,
|
|
929
|
+
` id String @id @default(cuid())`,
|
|
930
|
+
` roleType String`,
|
|
931
|
+
` userId String`,
|
|
932
|
+
` action String`,
|
|
933
|
+
` resource String?`,
|
|
934
|
+
` metadata Json?`,
|
|
935
|
+
` ip String?`,
|
|
936
|
+
` userAgent String?`,
|
|
937
|
+
` createdAt DateTime @default(now())`,
|
|
938
|
+
``,
|
|
939
|
+
` @@index([roleType, userId])`,
|
|
940
|
+
` @@index([createdAt])`,
|
|
941
|
+
`}`,
|
|
942
|
+
``
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
sections.push(`// \u2500\u2500\u2500 Your Models \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
|
|
946
|
+
sections.push(``);
|
|
947
|
+
sections.push(`// Add your own Prisma models here`);
|
|
948
|
+
return sections.join("\n");
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// src/generators/config-file.ts
|
|
952
|
+
import { resolvePermissionMode as resolvePermissionMode2 } from "@alinsafawi/aegis-auth-core";
|
|
953
|
+
function serializePermissions(role) {
|
|
954
|
+
const mode = resolvePermissionMode2(role);
|
|
955
|
+
if (mode === "all") return `'all'`;
|
|
956
|
+
if (mode === "none") return `'none'`;
|
|
957
|
+
if (mode === "dynamic") return `'dynamic'`;
|
|
958
|
+
const perms = typeof role.permissions === "object" ? role.permissions : {};
|
|
959
|
+
const lines = Object.entries(perms).map(([key, p10]) => {
|
|
960
|
+
const def = p10;
|
|
961
|
+
return [
|
|
962
|
+
` ${key}: {`,
|
|
963
|
+
` label: '${def.label}',`,
|
|
964
|
+
def.group ? ` group: '${def.group}',` : null,
|
|
965
|
+
` default: ${def.default},`,
|
|
966
|
+
` },`
|
|
967
|
+
].filter(Boolean).join("\n");
|
|
968
|
+
});
|
|
969
|
+
return `{
|
|
970
|
+
${lines.join("\n")}
|
|
971
|
+
}`;
|
|
972
|
+
}
|
|
973
|
+
function serializeRole(id, role) {
|
|
974
|
+
return [
|
|
975
|
+
` ${id}: {`,
|
|
976
|
+
` label: '${role.label}',`,
|
|
977
|
+
role.plural ? ` plural: '${role.plural}',` : null,
|
|
978
|
+
` prismaModel: '${role.prismaModel}',`,
|
|
979
|
+
` loginField: '${role.loginField}',`,
|
|
980
|
+
` homeRoute: '${role.homeRoute}',`,
|
|
981
|
+
role.singleton ? ` singleton: true,` : null,
|
|
982
|
+
role.canManage?.length ? ` canManage: [${role.canManage.map((r) => `'${r}'`).join(", ")}],` : null,
|
|
983
|
+
` signup: '${role.signup ?? "public"}',`,
|
|
984
|
+
` permissions: ${serializePermissions(role)},`,
|
|
985
|
+
` },`
|
|
986
|
+
].filter(Boolean).join("\n");
|
|
987
|
+
}
|
|
988
|
+
function generateAuthConfig(opts) {
|
|
989
|
+
const { appName, cookiePrefix, sessionDuration, roles, features, infra, style } = opts;
|
|
990
|
+
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
991
|
+
const typeAnnotation = ext === "ts" ? ": AegisConfig" : "";
|
|
992
|
+
const importStatement = ext === "ts" ? `import { defineAuthConfig, type AegisConfig } from '@alinsafawi/aegis-auth-core'
|
|
993
|
+
` : `const { defineAuthConfig } = require('@alinsafawi/aegis-auth-core')
|
|
994
|
+
`;
|
|
995
|
+
const lockout = features.accountLockout ? `{
|
|
996
|
+
maxAttempts: ${features.lockoutAttempts},
|
|
997
|
+
lockDurationMinutes: ${features.lockoutMinutes},
|
|
998
|
+
notifyOnLock: true,
|
|
999
|
+
}` : "false";
|
|
1000
|
+
return `${importStatement}
|
|
1001
|
+
${ext === "ts" ? `const config${typeAnnotation} = ` : `const config = `}defineAuthConfig({
|
|
1002
|
+
appName: '${appName}',
|
|
1003
|
+
|
|
1004
|
+
session: {
|
|
1005
|
+
secret: process.env.AEGIS_JWT_SECRET${ext === "ts" ? "!" : ""},
|
|
1006
|
+
cookieName: '${cookiePrefix}_session',
|
|
1007
|
+
csrfCookieName: '${cookiePrefix}_csrf',
|
|
1008
|
+
pendingTwoFactorCookieName: '${cookiePrefix}_2fa_pending',
|
|
1009
|
+
maxAge: ${sessionDuration},
|
|
1010
|
+
},
|
|
1011
|
+
|
|
1012
|
+
roles: {
|
|
1013
|
+
${Object.entries(roles).map(([id, role]) => serializeRole(id, role)).join("\n\n")}
|
|
1014
|
+
},
|
|
1015
|
+
|
|
1016
|
+
features: {
|
|
1017
|
+
twoFactor: ${features.twoFactor},
|
|
1018
|
+
emailVerification: ${features.emailVerification},
|
|
1019
|
+
passwordReset: ${features.passwordReset},
|
|
1020
|
+
accountLockout: ${lockout},
|
|
1021
|
+
apiKeys: ${features.apiKeys},
|
|
1022
|
+
auditLog: ${features.auditLog},
|
|
1023
|
+
sessionTracking: ${features.sessionTracking},
|
|
1024
|
+
},
|
|
1025
|
+
|
|
1026
|
+
passwordPolicy: {
|
|
1027
|
+
minLength: ${features.minPasswordLength},
|
|
1028
|
+
maxAgeDays: ${features.passwordExpiry ?? "null"},
|
|
1029
|
+
preventReuse: ${features.preventPasswordReuse ?? "null"},
|
|
1030
|
+
},
|
|
1031
|
+
|
|
1032
|
+
${infra.setupSmtp ? ` email: {
|
|
1033
|
+
host: process.env.AEGIS_SMTP_HOST${ext === "ts" ? "!" : ""},
|
|
1034
|
+
port: Number(process.env.AEGIS_SMTP_PORT),
|
|
1035
|
+
user: process.env.AEGIS_SMTP_USER${ext === "ts" ? "!" : ""},
|
|
1036
|
+
pass: process.env.AEGIS_SMTP_PASS${ext === "ts" ? "!" : ""},
|
|
1037
|
+
from: process.env.AEGIS_SMTP_FROM${ext === "ts" ? "!" : ""},
|
|
1038
|
+
},` : ` // email: { host, port, user, pass, from } \u2190 add when ready`}
|
|
1039
|
+
|
|
1040
|
+
rateLimit: {
|
|
1041
|
+
provider: '${infra.rateLimitProvider}',${infra.rateLimitProvider === "upstash" ? `
|
|
1042
|
+
upstash: {
|
|
1043
|
+
url: process.env.UPSTASH_REDIS_REST_URL${ext === "ts" ? "!" : ""},
|
|
1044
|
+
token: process.env.UPSTASH_REDIS_REST_TOKEN${ext === "ts" ? "!" : ""},
|
|
1045
|
+
},` : ""}
|
|
1046
|
+
},
|
|
1047
|
+
|
|
1048
|
+
ui: {
|
|
1049
|
+
brandName: '${appName}',${style.logoUrl ? `
|
|
1050
|
+
logoUrl: '${style.logoUrl}',` : ""}
|
|
1051
|
+
primaryColor: '${style.primaryColor}',
|
|
1052
|
+
},
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
export default config
|
|
1056
|
+
`;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// src/generators/env.ts
|
|
1060
|
+
function generateEnvExample(cookiePrefix, infra, features) {
|
|
1061
|
+
const lines = [
|
|
1062
|
+
`# \u2500\u2500\u2500 Aegis Auth \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`,
|
|
1063
|
+
``,
|
|
1064
|
+
`# Generate with: openssl rand -base64 32`,
|
|
1065
|
+
`AEGIS_JWT_SECRET=`,
|
|
1066
|
+
``,
|
|
1067
|
+
`# Set to the previous secret when rotating \u2014 gives active sessions a grace period`,
|
|
1068
|
+
`# AEGIS_JWT_SECRET_PREVIOUS=`,
|
|
1069
|
+
``,
|
|
1070
|
+
`# \u2500\u2500\u2500 Database \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`,
|
|
1071
|
+
``,
|
|
1072
|
+
`DATABASE_URL=`,
|
|
1073
|
+
``
|
|
1074
|
+
];
|
|
1075
|
+
if (features.emailVerification || features.passwordReset || features.accountLockout) {
|
|
1076
|
+
lines.push(
|
|
1077
|
+
`# \u2500\u2500\u2500 Email (SMTP) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`,
|
|
1078
|
+
``,
|
|
1079
|
+
`# Works with Resend, Sendgrid, Postmark, Gmail, etc.`,
|
|
1080
|
+
`AEGIS_SMTP_HOST=`,
|
|
1081
|
+
`AEGIS_SMTP_PORT=587`,
|
|
1082
|
+
`AEGIS_SMTP_USER=`,
|
|
1083
|
+
`AEGIS_SMTP_PASS=`,
|
|
1084
|
+
`AEGIS_SMTP_FROM="My App <noreply@myapp.com>"`,
|
|
1085
|
+
``
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
if (infra.rateLimitProvider === "upstash") {
|
|
1089
|
+
lines.push(
|
|
1090
|
+
`# \u2500\u2500\u2500 Upstash Redis (rate limiting) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`,
|
|
1091
|
+
``,
|
|
1092
|
+
`# Get these from upstash.com \u2014 free tier available`,
|
|
1093
|
+
`UPSTASH_REDIS_REST_URL=`,
|
|
1094
|
+
`UPSTASH_REDIS_REST_TOKEN=`,
|
|
1095
|
+
``
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
return lines.join("\n");
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// src/generators/project-files.ts
|
|
1102
|
+
function generateRootLayout(appName, primaryColor, language) {
|
|
1103
|
+
const ext = language === "typescript" ? "tsx" : "jsx";
|
|
1104
|
+
return `import type { Metadata } from 'next'
|
|
1105
|
+
import './globals.css'
|
|
1106
|
+
|
|
1107
|
+
export const metadata: Metadata = {
|
|
1108
|
+
title: '${appName}',
|
|
1109
|
+
description: '${appName}',
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
export default function RootLayout({
|
|
1113
|
+
children,
|
|
1114
|
+
}: ${language === "typescript" ? "{ children: React.ReactNode }" : "props"}) {
|
|
1115
|
+
return (
|
|
1116
|
+
<html lang="en">
|
|
1117
|
+
<body>${`{${language === "typescript" ? "children" : "props.children"}}`}</body>
|
|
1118
|
+
</html>
|
|
1119
|
+
)
|
|
1120
|
+
}
|
|
1121
|
+
`;
|
|
1122
|
+
}
|
|
1123
|
+
function generateGlobalsCss(primaryColor) {
|
|
1124
|
+
return `@tailwind base;
|
|
1125
|
+
@tailwind components;
|
|
1126
|
+
@tailwind utilities;
|
|
1127
|
+
|
|
1128
|
+
:root {
|
|
1129
|
+
--primary: ${primaryColor};
|
|
1130
|
+
--primary-foreground: white;
|
|
1131
|
+
--background: white;
|
|
1132
|
+
--foreground: oklch(0.15 0 0);
|
|
1133
|
+
--card: oklch(0.98 0 0);
|
|
1134
|
+
--border: oklch(0.9 0 0);
|
|
1135
|
+
--input: oklch(0.92 0 0);
|
|
1136
|
+
--muted: oklch(0.55 0 0);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
@media (prefers-color-scheme: dark) {
|
|
1140
|
+
:root {
|
|
1141
|
+
--background: oklch(0.1 0 0);
|
|
1142
|
+
--foreground: oklch(0.95 0 0);
|
|
1143
|
+
--card: oklch(0.13 0 0);
|
|
1144
|
+
--border: oklch(0.22 0 0);
|
|
1145
|
+
--input: oklch(0.18 0 0);
|
|
1146
|
+
--muted: oklch(0.55 0 0);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
* {
|
|
1151
|
+
border-color: var(--border);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
body {
|
|
1155
|
+
background: var(--background);
|
|
1156
|
+
color: var(--foreground);
|
|
1157
|
+
}
|
|
1158
|
+
`;
|
|
1159
|
+
}
|
|
1160
|
+
function generateRootPage(loginRoute) {
|
|
1161
|
+
return `import { redirect } from 'next/navigation'
|
|
1162
|
+
|
|
1163
|
+
export default function RootPage() {
|
|
1164
|
+
redirect('${loginRoute}')
|
|
1165
|
+
}
|
|
1166
|
+
`;
|
|
1167
|
+
}
|
|
1168
|
+
function generateMiddleware(cookiePrefix, language) {
|
|
1169
|
+
return `import { createAuthMiddleware } from '@alinsafawi/aegis-auth-next'
|
|
1170
|
+
import config from './auth.config'
|
|
1171
|
+
|
|
1172
|
+
export default createAuthMiddleware(config)
|
|
1173
|
+
|
|
1174
|
+
export const config = {
|
|
1175
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
|
1176
|
+
}
|
|
1177
|
+
`;
|
|
1178
|
+
}
|
|
1179
|
+
function generateAuthLib(language) {
|
|
1180
|
+
const ts = language === "typescript";
|
|
1181
|
+
return `import { getSession, requireSession, requireRole, hasPermission } from '@alinsafawi/aegis-auth-next'
|
|
1182
|
+
import config from '../../auth.config'
|
|
1183
|
+
${ts ? `import type { AegisSession } from '@alinsafawi/aegis-auth-core'` : ""}
|
|
1184
|
+
|
|
1185
|
+
export { hasPermission }
|
|
1186
|
+
${ts ? `export type { AegisSession }` : ""}
|
|
1187
|
+
|
|
1188
|
+
export const auth = {
|
|
1189
|
+
session: () => getSession(config),
|
|
1190
|
+
require: () => requireSession(config),
|
|
1191
|
+
requireRole: (role${ts ? ": string | string[]" : ""}) => requireRole(config, role),
|
|
1192
|
+
}
|
|
1193
|
+
`;
|
|
1194
|
+
}
|
|
1195
|
+
function generateLoginPage(appName, primaryColor, language) {
|
|
1196
|
+
return `'use client'
|
|
1197
|
+
|
|
1198
|
+
import { useState, useRef } from 'react'
|
|
1199
|
+
import { useRouter } from 'next/navigation'
|
|
1200
|
+
|
|
1201
|
+
export default function LoginPage() {
|
|
1202
|
+
const [identifier, setIdentifier] = useState('')
|
|
1203
|
+
const [password, setPassword] = useState('')
|
|
1204
|
+
const [error, setError] = useState('')
|
|
1205
|
+
const [loading, setLoading] = useState(false)
|
|
1206
|
+
const router = useRouter()
|
|
1207
|
+
|
|
1208
|
+
async function handleSubmit(e${language === "typescript" ? ": React.FormEvent" : ""}) {
|
|
1209
|
+
e.preventDefault()
|
|
1210
|
+
setError('')
|
|
1211
|
+
setLoading(true)
|
|
1212
|
+
|
|
1213
|
+
const csrf = document.cookie
|
|
1214
|
+
.split('; ')
|
|
1215
|
+
.find((r) => r.startsWith('${appName.toLowerCase().replace(/[^a-z]/g, "")}_csrf='))
|
|
1216
|
+
?.split('=')[1]
|
|
1217
|
+
|
|
1218
|
+
const res = await fetch('/api/auth/login', {
|
|
1219
|
+
method: 'POST',
|
|
1220
|
+
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrf ?? '' },
|
|
1221
|
+
body: JSON.stringify({ identifier, password }),
|
|
1222
|
+
})
|
|
1223
|
+
|
|
1224
|
+
const data = await res.json()
|
|
1225
|
+
setLoading(false)
|
|
1226
|
+
|
|
1227
|
+
if (!res.ok) { setError(data.error ?? 'Login failed'); return }
|
|
1228
|
+
if (data.requiresTwoFactor) { router.push('/two-factor'); return }
|
|
1229
|
+
router.push(\`/\${data.role}/dashboard\`)
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
return (
|
|
1233
|
+
<main className="min-h-screen flex items-center justify-center p-4">
|
|
1234
|
+
<div className="w-full max-w-sm">
|
|
1235
|
+
<div className="text-center mb-8">
|
|
1236
|
+
<h1 className="text-2xl font-bold">${appName}</h1>
|
|
1237
|
+
</div>
|
|
1238
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
1239
|
+
{error && (
|
|
1240
|
+
<div className="text-sm text-red-500 bg-red-50 border border-red-200 rounded-md p-3">
|
|
1241
|
+
{error}
|
|
1242
|
+
</div>
|
|
1243
|
+
)}
|
|
1244
|
+
<div>
|
|
1245
|
+
<label className="block text-sm font-medium mb-1">Email or Username</label>
|
|
1246
|
+
<input
|
|
1247
|
+
type="text"
|
|
1248
|
+
value={identifier}
|
|
1249
|
+
onChange={(e) => setIdentifier(e.target.value)}
|
|
1250
|
+
required
|
|
1251
|
+
autoComplete="username"
|
|
1252
|
+
className="w-full px-3 py-2 rounded-md border bg-[var(--input)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
|
1253
|
+
/>
|
|
1254
|
+
</div>
|
|
1255
|
+
<div>
|
|
1256
|
+
<label className="block text-sm font-medium mb-1">Password</label>
|
|
1257
|
+
<input
|
|
1258
|
+
type="password"
|
|
1259
|
+
value={password}
|
|
1260
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
1261
|
+
required
|
|
1262
|
+
autoComplete="current-password"
|
|
1263
|
+
className="w-full px-3 py-2 rounded-md border bg-[var(--input)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
|
1264
|
+
/>
|
|
1265
|
+
</div>
|
|
1266
|
+
<button
|
|
1267
|
+
type="submit"
|
|
1268
|
+
disabled={loading}
|
|
1269
|
+
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}
|
|
1270
|
+
className="w-full py-2 px-4 rounded-md font-medium disabled:opacity-50 transition-opacity"
|
|
1271
|
+
>
|
|
1272
|
+
{loading ? 'Signing in\u2026' : 'Sign in'}
|
|
1273
|
+
</button>
|
|
1274
|
+
<div className="text-center">
|
|
1275
|
+
<a href="/forgot-password" className="text-sm text-[var(--muted)] hover:underline">
|
|
1276
|
+
Forgot password?
|
|
1277
|
+
</a>
|
|
1278
|
+
</div>
|
|
1279
|
+
</form>
|
|
1280
|
+
</div>
|
|
1281
|
+
</main>
|
|
1282
|
+
)
|
|
1283
|
+
}
|
|
1284
|
+
`;
|
|
1285
|
+
}
|
|
1286
|
+
function generateNextConfig() {
|
|
1287
|
+
return `import type { NextConfig } from 'next'
|
|
1288
|
+
|
|
1289
|
+
const nextConfig: NextConfig = {}
|
|
1290
|
+
|
|
1291
|
+
export default nextConfig
|
|
1292
|
+
`;
|
|
1293
|
+
}
|
|
1294
|
+
function generateTailwindConfig() {
|
|
1295
|
+
return `import type { Config } from 'tailwindcss'
|
|
1296
|
+
|
|
1297
|
+
const config: Config = {
|
|
1298
|
+
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
|
1299
|
+
theme: { extend: {} },
|
|
1300
|
+
plugins: [],
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
export default config
|
|
1304
|
+
`;
|
|
1305
|
+
}
|
|
1306
|
+
function generatePackageJson(name, packageManager) {
|
|
1307
|
+
return JSON.stringify(
|
|
1308
|
+
{
|
|
1309
|
+
name,
|
|
1310
|
+
version: "0.1.0",
|
|
1311
|
+
private: true,
|
|
1312
|
+
scripts: {
|
|
1313
|
+
dev: "next dev",
|
|
1314
|
+
build: "next build",
|
|
1315
|
+
start: "next start",
|
|
1316
|
+
lint: "next lint"
|
|
1317
|
+
},
|
|
1318
|
+
dependencies: {
|
|
1319
|
+
"@alinsafawi/aegis-auth-core": "latest",
|
|
1320
|
+
"@alinsafawi/aegis-auth-next": "latest",
|
|
1321
|
+
"@prisma/client": "^5.0.0",
|
|
1322
|
+
next: "^15.0.0",
|
|
1323
|
+
react: "^19.0.0",
|
|
1324
|
+
"react-dom": "^19.0.0"
|
|
1325
|
+
},
|
|
1326
|
+
devDependencies: {
|
|
1327
|
+
"@types/node": "^20.0.0",
|
|
1328
|
+
"@types/react": "^19.0.0",
|
|
1329
|
+
"@types/react-dom": "^19.0.0",
|
|
1330
|
+
prisma: "^5.0.0",
|
|
1331
|
+
tailwindcss: "^3.4.0",
|
|
1332
|
+
typescript: "^5.4.0"
|
|
1333
|
+
}
|
|
1334
|
+
},
|
|
1335
|
+
null,
|
|
1336
|
+
2
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// src/commands/init.ts
|
|
1341
|
+
function detectPackageManager() {
|
|
1342
|
+
if (process.env.npm_config_user_agent?.includes("pnpm")) return "pnpm";
|
|
1343
|
+
if (process.env.npm_config_user_agent?.includes("yarn")) return "yarn";
|
|
1344
|
+
if (process.env.npm_config_user_agent?.includes("bun")) return "bun";
|
|
1345
|
+
return "npm";
|
|
1346
|
+
}
|
|
1347
|
+
function isExistingNextProject(dir) {
|
|
1348
|
+
return fs.existsSync(path.join(dir, "next.config.ts")) || fs.existsSync(path.join(dir, "next.config.js")) || fs.existsSync(path.join(dir, "next.config.mjs"));
|
|
1349
|
+
}
|
|
1350
|
+
var STEPS = ["PROJECT", "APP", "ROLES", "FEATURES", "INFRA", "STYLE"];
|
|
1351
|
+
async function runInit() {
|
|
1352
|
+
printBanner();
|
|
1353
|
+
const mode = await p7.select({
|
|
1354
|
+
message: "What would you like to do?",
|
|
1355
|
+
options: [
|
|
1356
|
+
{
|
|
1357
|
+
value: "new",
|
|
1358
|
+
label: "Create a new project",
|
|
1359
|
+
hint: "start clean \u2014 auth included, all defaults removed"
|
|
1360
|
+
},
|
|
1361
|
+
{
|
|
1362
|
+
value: "existing",
|
|
1363
|
+
label: "Add to existing project",
|
|
1364
|
+
hint: "wire auth into your Next.js app"
|
|
1365
|
+
}
|
|
1366
|
+
]
|
|
1367
|
+
});
|
|
1368
|
+
if (p7.isCancel(mode)) {
|
|
1369
|
+
p7.cancel("Cancelled.");
|
|
1370
|
+
process.exit(0);
|
|
1371
|
+
}
|
|
1372
|
+
let targetDir = process.cwd();
|
|
1373
|
+
let language = "typescript";
|
|
1374
|
+
let packageManager = detectPackageManager();
|
|
1375
|
+
let database = "postgresql";
|
|
1376
|
+
let projectName;
|
|
1377
|
+
if (mode === "new") {
|
|
1378
|
+
printSection(`STEP 1 of ${STEPS.length} \u25B8 ${STEPS.join(" \xB7 ")}`);
|
|
1379
|
+
const project = await promptProject(detectPackageManager());
|
|
1380
|
+
projectName = project.name;
|
|
1381
|
+
language = project.language;
|
|
1382
|
+
packageManager = project.packageManager;
|
|
1383
|
+
database = project.database;
|
|
1384
|
+
targetDir = path.join(process.cwd(), project.name);
|
|
1385
|
+
if (fs.existsSync(targetDir)) {
|
|
1386
|
+
const overwrite = await p7.confirm({
|
|
1387
|
+
message: `Folder '${project.name}' already exists. Continue anyway?`,
|
|
1388
|
+
initialValue: false
|
|
1389
|
+
});
|
|
1390
|
+
if (p7.isCancel(overwrite) || !overwrite) {
|
|
1391
|
+
p7.cancel("Cancelled.");
|
|
1392
|
+
process.exit(0);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
} else {
|
|
1396
|
+
printSection("Scanning your project...");
|
|
1397
|
+
const checks = [
|
|
1398
|
+
{ label: "Next.js", status: isExistingNextProject(targetDir) ? "ok" : "error", detail: !isExistingNextProject(targetDir) ? "next.config.ts not found" : "" },
|
|
1399
|
+
{ label: "TypeScript", status: fs.existsSync(path.join(targetDir, "tsconfig.json")) ? "ok" : "warn", detail: "" },
|
|
1400
|
+
{ label: "Prisma", status: fs.existsSync(path.join(targetDir, "prisma")) ? "ok" : "warn", detail: !fs.existsSync(path.join(targetDir, "prisma")) ? "will be created" : "" },
|
|
1401
|
+
{ label: "Tailwind CSS", status: fs.existsSync(path.join(targetDir, "tailwind.config.ts")) || fs.existsSync(path.join(targetDir, "tailwind.config.js")) ? "ok" : "warn", detail: "" },
|
|
1402
|
+
{ label: "No aegis-auth found", status: fs.existsSync(path.join(targetDir, "auth.config.ts")) ? "warn" : "ok", detail: fs.existsSync(path.join(targetDir, "auth.config.ts")) ? "already installed \u2014 use upgrade instead" : "starting fresh setup" }
|
|
1403
|
+
];
|
|
1404
|
+
printStatusList(checks);
|
|
1405
|
+
language = fs.existsSync(path.join(targetDir, "tsconfig.json")) ? "typescript" : "javascript";
|
|
1406
|
+
if (checks.find((c4) => c4.label === "Next.js")?.status === "error") {
|
|
1407
|
+
p7.cancel(`No Next.js project found in ${targetDir}. Run from inside your project folder.`);
|
|
1408
|
+
process.exit(1);
|
|
1409
|
+
}
|
|
1410
|
+
console.log(`
|
|
1411
|
+
${chalk8.dim("This wizard will:")}
|
|
1412
|
+
`);
|
|
1413
|
+
console.log(` ${chalk8.dim("\xB7")} Generate Prisma models for your roles`);
|
|
1414
|
+
console.log(` ${chalk8.dim("\xB7")} Create API route handlers ${chalk8.dim("src/app/api/auth/**")}`);
|
|
1415
|
+
console.log(` ${chalk8.dim("\xB7")} Scaffold auth pages ${chalk8.dim("(login, 2FA, reset password, etc.)")}`);
|
|
1416
|
+
console.log(` ${chalk8.dim("\xB7")} Create auth.config.ts`);
|
|
1417
|
+
console.log(` ${chalk8.dim("\xB7")} Update .env.example
|
|
1418
|
+
`);
|
|
1419
|
+
}
|
|
1420
|
+
printSection(mode === "new" ? `STEP 2 of ${STEPS.length} \u25B8 PROJECT \u2713 \u25B8 APP` : `STEP 1 of ${STEPS.length - 1} \u25B8 APP`);
|
|
1421
|
+
const app = await promptApp(projectName);
|
|
1422
|
+
printSection(mode === "new" ? `STEP 3 of ${STEPS.length} \u25B8 PROJECT \u2713 APP \u2713 \u25B8 ROLES` : `STEP 2 \u25B8 APP \u2713 \u25B8 ROLES`);
|
|
1423
|
+
const roles = await promptRoles();
|
|
1424
|
+
printSection(mode === "new" ? `STEP 4 of ${STEPS.length} \u25B8 ... ROLES \u2713 \u25B8 FEATURES` : `STEP 3 \u25B8 ROLES \u2713 \u25B8 FEATURES`);
|
|
1425
|
+
const features = await promptFeatures(Object.keys(roles));
|
|
1426
|
+
printSection(mode === "new" ? `STEP 5 of ${STEPS.length} \u25B8 ... FEATURES \u2713 \u25B8 INFRA` : `STEP 4 \u25B8 FEATURES \u2713 \u25B8 INFRA`);
|
|
1427
|
+
const infra = await promptInfrastructure();
|
|
1428
|
+
printSection(mode === "new" ? `STEP 6 of ${STEPS.length} \u25B8 ... INFRA \u2713 \u25B8 STYLE` : `STEP 5 \u25B8 INFRA \u2713 \u25B8 STYLE`);
|
|
1429
|
+
const style = await promptStyle();
|
|
1430
|
+
printSection("REVIEW \xB7 Everything that's about to happen");
|
|
1431
|
+
console.log(` ${chalk8.bold("Configuration")}
|
|
1432
|
+
`);
|
|
1433
|
+
console.log(` App ${chalk8.cyan(app.appName)}`);
|
|
1434
|
+
console.log(` Cookie prefix ${chalk8.cyan(app.cookiePrefix)}`);
|
|
1435
|
+
console.log(` Session ${chalk8.cyan(app.sessionDuration / 3600 + " hours")}`);
|
|
1436
|
+
if (mode === "new") {
|
|
1437
|
+
console.log(` Language ${chalk8.cyan(language)}`);
|
|
1438
|
+
console.log(` Package manager ${chalk8.cyan(packageManager)}`);
|
|
1439
|
+
console.log(` Database ${chalk8.cyan(database)}`);
|
|
1440
|
+
}
|
|
1441
|
+
console.log();
|
|
1442
|
+
console.log(` ${chalk8.bold("Roles")}`);
|
|
1443
|
+
for (const [id, role] of Object.entries(roles)) {
|
|
1444
|
+
console.log(` ${chalk8.dim("\xB7")} ${chalk8.magentaBright(id)} ${chalk8.dim("\u2192")} ${role.prismaModel} ${chalk8.dim("(")}${role.loginField}${chalk8.dim(")")}`);
|
|
1445
|
+
}
|
|
1446
|
+
console.log();
|
|
1447
|
+
const onFeatures = Object.entries(features).filter(([k, v]) => v === true && ["twoFactor", "emailVerification", "passwordReset", "accountLockout", "apiKeys", "auditLog", "sessionTracking"].includes(k)).map(([k]) => k);
|
|
1448
|
+
console.log(` ${chalk8.bold("Features")} ${onFeatures.map((f) => chalk8.cyan(f)).join(chalk8.dim(" \xB7 "))}`);
|
|
1449
|
+
console.log();
|
|
1450
|
+
const confirm4 = await p7.select({
|
|
1451
|
+
message: "Proceed?",
|
|
1452
|
+
options: [
|
|
1453
|
+
{ value: "go", label: mode === "new" ? "Create project" : "Create files" },
|
|
1454
|
+
{ value: "dry", label: "Dry run \u2014 show me what will be created without writing" },
|
|
1455
|
+
{ value: "no", label: "Cancel" }
|
|
1456
|
+
]
|
|
1457
|
+
});
|
|
1458
|
+
if (p7.isCancel(confirm4) || confirm4 === "no") {
|
|
1459
|
+
p7.cancel("Cancelled.");
|
|
1460
|
+
process.exit(0);
|
|
1461
|
+
}
|
|
1462
|
+
const isDry = confirm4 === "dry";
|
|
1463
|
+
printSection(mode === "new" ? `BUILDING ${projectName}` : "INSTALLING");
|
|
1464
|
+
const spin = p7.spinner();
|
|
1465
|
+
async function writeFile(relPath, content) {
|
|
1466
|
+
const full = path.join(targetDir, relPath);
|
|
1467
|
+
if (isDry) {
|
|
1468
|
+
console.log(` ${chalk8.dim("+")} ${chalk8.yellow(relPath)}`);
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
await fs.ensureDir(path.dirname(full));
|
|
1472
|
+
await fs.writeFile(full, content, "utf8");
|
|
1473
|
+
}
|
|
1474
|
+
async function step(label, fn) {
|
|
1475
|
+
spin.start(label);
|
|
1476
|
+
await fn();
|
|
1477
|
+
spin.stop(`${chalk8.green("\u2713")} ${label}`);
|
|
1478
|
+
}
|
|
1479
|
+
if (mode === "new") {
|
|
1480
|
+
await step("Scaffolding Next.js project", async () => {
|
|
1481
|
+
await writeFile("package.json", generatePackageJson(projectName, packageManager));
|
|
1482
|
+
await writeFile("next.config.ts", generateNextConfig());
|
|
1483
|
+
await writeFile("tailwind.config.ts", generateTailwindConfig());
|
|
1484
|
+
await writeFile("tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2017", lib: ["dom", "dom.iterable", "esnext"], allowJs: true, skipLibCheck: true, strict: true, noEmit: true, esModuleInterop: true, module: "esnext", moduleResolution: "bundler", resolveJsonModule: true, isolatedModules: true, jsx: "preserve", incremental: true, plugins: [{ name: "next" }], paths: { "@/*": ["./src/*"] } }, include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], exclude: ["node_modules"] }, null, 2));
|
|
1485
|
+
await writeFile(".gitignore", `node_modules
|
|
1486
|
+
.next
|
|
1487
|
+
dist
|
|
1488
|
+
.env
|
|
1489
|
+
.env.local
|
|
1490
|
+
*.tsbuildinfo
|
|
1491
|
+
`);
|
|
1492
|
+
await writeFile(".env.local", "");
|
|
1493
|
+
await writeFile("src/app/layout.tsx", generateRootLayout(app.appName, style.primaryColor, language));
|
|
1494
|
+
await writeFile("src/app/globals.css", generateGlobalsCss(style.primaryColor));
|
|
1495
|
+
await writeFile("src/app/page.tsx", generateRootPage(roles[Object.keys(roles)[0]] ? "/login" : "/login"));
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
await step("Generating Prisma schema", async () => {
|
|
1499
|
+
const schema = generatePrismaSchema({
|
|
1500
|
+
roles,
|
|
1501
|
+
database: mode === "new" ? database : "postgresql",
|
|
1502
|
+
features: {
|
|
1503
|
+
twoFactor: features.twoFactor,
|
|
1504
|
+
emailVerification: features.emailVerification,
|
|
1505
|
+
passwordReset: features.passwordReset,
|
|
1506
|
+
accountLockout: features.accountLockout,
|
|
1507
|
+
apiKeys: features.apiKeys,
|
|
1508
|
+
auditLog: features.auditLog,
|
|
1509
|
+
sessionTracking: features.sessionTracking,
|
|
1510
|
+
preventPasswordReuse: !!features.preventPasswordReuse
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
await writeFile("prisma/schema.prisma", schema);
|
|
1514
|
+
});
|
|
1515
|
+
await step("Creating auth.config.ts", async () => {
|
|
1516
|
+
const config = generateAuthConfig({ appName: app.appName, cookiePrefix: app.cookiePrefix, sessionDuration: app.sessionDuration, roles, features, infra, style, language });
|
|
1517
|
+
await writeFile(`auth.config.${language === "typescript" ? "ts" : "js"}`, config);
|
|
1518
|
+
});
|
|
1519
|
+
await step("Creating middleware", async () => {
|
|
1520
|
+
await writeFile(`src/middleware.${language === "typescript" ? "ts" : "js"}`, generateMiddleware(app.cookiePrefix, language));
|
|
1521
|
+
});
|
|
1522
|
+
await step("Creating session helpers", async () => {
|
|
1523
|
+
await writeFile(`src/lib/auth.${language === "typescript" ? "ts" : "js"}`, generateAuthLib(language));
|
|
1524
|
+
});
|
|
1525
|
+
await step("Scaffolding auth pages", async () => {
|
|
1526
|
+
const e = language === "typescript" ? "tsx" : "jsx";
|
|
1527
|
+
await writeFile(`src/app/(auth)/login/page.${e}`, generateLoginPage(app.appName, style.primaryColor, language));
|
|
1528
|
+
});
|
|
1529
|
+
await step("Updating .env.example", async () => {
|
|
1530
|
+
await writeFile(".env.example", generateEnvExample(app.cookiePrefix, infra, features));
|
|
1531
|
+
});
|
|
1532
|
+
if (!isDry && mode === "new") {
|
|
1533
|
+
await step(`Installing dependencies with ${packageManager}`, async () => {
|
|
1534
|
+
await execa(packageManager, ["install"], { cwd: targetDir });
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
printDone(app.appName);
|
|
1538
|
+
const cdStep = mode === "new" && projectName ? [{ n: 0, title: `cd ${projectName}`, lines: [] }] : [];
|
|
1539
|
+
printNextSteps([
|
|
1540
|
+
...cdStep,
|
|
1541
|
+
{
|
|
1542
|
+
n: 1,
|
|
1543
|
+
title: "Fill in .env.local",
|
|
1544
|
+
lines: [
|
|
1545
|
+
`${chalk8.cyan("AEGIS_JWT_SECRET=")} ${chalk8.dim("\u2190 openssl rand -base64 32")}`,
|
|
1546
|
+
`${chalk8.cyan("DATABASE_URL=")}`,
|
|
1547
|
+
features.emailVerification || features.passwordReset ? `${chalk8.cyan("AEGIS_SMTP_HOST=")}` : "",
|
|
1548
|
+
infra.rateLimitProvider === "upstash" ? `${chalk8.cyan("UPSTASH_REDIS_REST_URL=")}` : ""
|
|
1549
|
+
].filter(Boolean)
|
|
1550
|
+
},
|
|
1551
|
+
{
|
|
1552
|
+
n: 2,
|
|
1553
|
+
title: "Apply the database schema",
|
|
1554
|
+
lines: [`${chalk8.green("npx prisma migrate dev --name init")}`]
|
|
1555
|
+
},
|
|
1556
|
+
{
|
|
1557
|
+
n: 3,
|
|
1558
|
+
title: "Create your first user",
|
|
1559
|
+
lines: [`${chalk8.green("npx aegis-auth seed")}`]
|
|
1560
|
+
},
|
|
1561
|
+
{
|
|
1562
|
+
n: 4,
|
|
1563
|
+
title: mode === "new" ? "Start building" : "Start your dev server",
|
|
1564
|
+
lines: [
|
|
1565
|
+
`${chalk8.green(packageManager === "npm" ? "npm run dev" : `${packageManager} dev`)} ${chalk8.dim("\u2192 http://localhost:3000")}`
|
|
1566
|
+
]
|
|
1567
|
+
}
|
|
1568
|
+
]);
|
|
1569
|
+
console.log();
|
|
1570
|
+
console.log(
|
|
1571
|
+
` ${chalk8.dim("Commands available any time:")}
|
|
1572
|
+
`
|
|
1573
|
+
);
|
|
1574
|
+
console.log(` ${chalk8.green("aegis-auth add-role")} ${chalk8.dim("add a new role")}`);
|
|
1575
|
+
console.log(` ${chalk8.green("aegis-auth generate")} ${chalk8.dim("regenerate TypeScript types")}`);
|
|
1576
|
+
console.log(` ${chalk8.green("aegis-auth validate")} ${chalk8.dim("check config without hitting DB")}`);
|
|
1577
|
+
console.log(` ${chalk8.green("aegis-auth doctor")} ${chalk8.dim("check env, DB, SMTP, Redis")}`);
|
|
1578
|
+
console.log(` ${chalk8.green("aegis-auth upgrade")} ${chalk8.dim("update to a new version")}`);
|
|
1579
|
+
console.log();
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// src/commands/doctor.ts
|
|
1583
|
+
import chalk9 from "chalk";
|
|
1584
|
+
import fs2 from "fs-extra";
|
|
1585
|
+
import path2 from "path";
|
|
1586
|
+
async function runDoctor() {
|
|
1587
|
+
printBanner();
|
|
1588
|
+
printSection("DOCTOR \xB7 Checking your Aegis setup");
|
|
1589
|
+
const cwd = process.cwd();
|
|
1590
|
+
const results = [];
|
|
1591
|
+
const hasConfig = fs2.existsSync(path2.join(cwd, "auth.config.ts")) || fs2.existsSync(path2.join(cwd, "auth.config.js"));
|
|
1592
|
+
results.push({
|
|
1593
|
+
label: "auth.config.ts found",
|
|
1594
|
+
status: hasConfig ? "ok" : "error",
|
|
1595
|
+
detail: !hasConfig ? "run: npx aegis-auth init" : void 0
|
|
1596
|
+
});
|
|
1597
|
+
const hasSchema = fs2.existsSync(path2.join(cwd, "prisma", "schema.prisma"));
|
|
1598
|
+
results.push({
|
|
1599
|
+
label: "Prisma schema",
|
|
1600
|
+
status: hasSchema ? "ok" : "warn",
|
|
1601
|
+
detail: !hasSchema ? "prisma/schema.prisma not found" : void 0
|
|
1602
|
+
});
|
|
1603
|
+
const requiredEnv = ["AEGIS_JWT_SECRET", "DATABASE_URL"];
|
|
1604
|
+
const optionalEnv = [
|
|
1605
|
+
"AEGIS_SMTP_HOST",
|
|
1606
|
+
"UPSTASH_REDIS_REST_URL",
|
|
1607
|
+
"UPSTASH_REDIS_REST_TOKEN",
|
|
1608
|
+
"AEGIS_JWT_SECRET_PREVIOUS"
|
|
1609
|
+
];
|
|
1610
|
+
console.log(`
|
|
1611
|
+
${chalk9.dim("\u2500 Environment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")}
|
|
1612
|
+
`);
|
|
1613
|
+
for (const key of requiredEnv) {
|
|
1614
|
+
const val = process.env[key];
|
|
1615
|
+
results.push({
|
|
1616
|
+
label: key,
|
|
1617
|
+
status: val ? "ok" : "error",
|
|
1618
|
+
detail: val ? key === "AEGIS_JWT_SECRET" ? `set (${val.length} chars${val.length >= 32 ? " \u2014 good" : " \u2014 too short, need \u2265 32"})` : "set" : "missing"
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
for (const key of optionalEnv) {
|
|
1622
|
+
const val = process.env[key];
|
|
1623
|
+
if (val) {
|
|
1624
|
+
results.push({ label: key, status: "ok", detail: "set" });
|
|
1625
|
+
} else {
|
|
1626
|
+
results.push({ label: key, status: "skip", detail: "not set (optional)" });
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
printStatusList(results);
|
|
1630
|
+
const errors = results.filter((r) => r.status === "error");
|
|
1631
|
+
const warns = results.filter((r) => r.status === "warn");
|
|
1632
|
+
console.log();
|
|
1633
|
+
if (errors.length === 0 && warns.length === 0) {
|
|
1634
|
+
console.log(` ${chalk9.green("\u2713")} ${chalk9.green("Everything looks good.")}`);
|
|
1635
|
+
} else {
|
|
1636
|
+
console.log(
|
|
1637
|
+
` ${chalk9.red(errors.length)} error${errors.length !== 1 ? "s" : ""}` + (warns.length ? ` ${chalk9.yellow(warns.length)} warning${warns.length !== 1 ? "s" : ""}` : "") + ` found.`
|
|
1638
|
+
);
|
|
1639
|
+
console.log();
|
|
1640
|
+
console.log(` Fix the above, then re-run: ${chalk9.green("npx aegis-auth doctor")}`);
|
|
1641
|
+
}
|
|
1642
|
+
console.log();
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// src/commands/add-role.ts
|
|
1646
|
+
import * as p8 from "@clack/prompts";
|
|
1647
|
+
import chalk10 from "chalk";
|
|
1648
|
+
import fs3 from "fs-extra";
|
|
1649
|
+
import path3 from "path";
|
|
1650
|
+
async function runAddRole() {
|
|
1651
|
+
printBanner();
|
|
1652
|
+
printSection("ADD ROLE");
|
|
1653
|
+
const cwd = process.cwd();
|
|
1654
|
+
const configPath = fs3.existsSync(path3.join(cwd, "auth.config.ts")) ? path3.join(cwd, "auth.config.ts") : fs3.existsSync(path3.join(cwd, "auth.config.js")) ? path3.join(cwd, "auth.config.js") : null;
|
|
1655
|
+
if (!configPath) {
|
|
1656
|
+
p8.cancel("No auth.config.ts found. Run: npx aegis-auth init first.");
|
|
1657
|
+
process.exit(1);
|
|
1658
|
+
}
|
|
1659
|
+
console.log(
|
|
1660
|
+
`
|
|
1661
|
+
${chalk10.dim("Adding a new role to your existing setup.")}
|
|
1662
|
+
${chalk10.dim("You will need to run npx prisma migrate dev after.")}
|
|
1663
|
+
`
|
|
1664
|
+
);
|
|
1665
|
+
const { id, config } = await promptRole(1, 1);
|
|
1666
|
+
console.log();
|
|
1667
|
+
console.log(` ${chalk10.green("\u2713")} Role ${chalk10.magentaBright(id)} defined.`);
|
|
1668
|
+
console.log();
|
|
1669
|
+
console.log(` ${chalk10.dim("Next steps:")}`);
|
|
1670
|
+
console.log();
|
|
1671
|
+
console.log(` 1 Add to auth.config.ts \u2192 roles:`);
|
|
1672
|
+
console.log();
|
|
1673
|
+
console.log(
|
|
1674
|
+
chalk10.dim(
|
|
1675
|
+
` ${id}: {
|
|
1676
|
+
label: '${config.label}',
|
|
1677
|
+
prismaModel: '${config.prismaModel}',
|
|
1678
|
+
loginField: '${config.loginField}',
|
|
1679
|
+
homeRoute: '${config.homeRoute}',
|
|
1680
|
+
permissions: ...,
|
|
1681
|
+
},`
|
|
1682
|
+
)
|
|
1683
|
+
);
|
|
1684
|
+
console.log();
|
|
1685
|
+
console.log(` 2 Apply the schema:`);
|
|
1686
|
+
console.log(` ${chalk10.green("npx prisma migrate dev --name add-role-" + id)}`);
|
|
1687
|
+
console.log();
|
|
1688
|
+
console.log(` 3 Regenerate types:`);
|
|
1689
|
+
console.log(` ${chalk10.green("npx aegis-auth generate")}`);
|
|
1690
|
+
console.log();
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// src/commands/generate.ts
|
|
1694
|
+
import chalk11 from "chalk";
|
|
1695
|
+
import fs4 from "fs-extra";
|
|
1696
|
+
import path4 from "path";
|
|
1697
|
+
async function runGenerate() {
|
|
1698
|
+
printBanner();
|
|
1699
|
+
const cwd = process.cwd();
|
|
1700
|
+
const outPath = path4.join(cwd, "src", "lib", "auth-types.generated.ts");
|
|
1701
|
+
console.log(`
|
|
1702
|
+
${chalk11.dim("Regenerating TypeScript types from auth.config.ts...")}
|
|
1703
|
+
`);
|
|
1704
|
+
const content = `// auto-generated by aegis-auth \u2014 do not edit
|
|
1705
|
+
// run: npx aegis-auth generate to update
|
|
1706
|
+
|
|
1707
|
+
import type { AegisSession } from '@alinsafawi/aegis-auth-core'
|
|
1708
|
+
export type { AegisSession }
|
|
1709
|
+
|
|
1710
|
+
// Import your config to derive these types
|
|
1711
|
+
// import config from '../../auth.config'
|
|
1712
|
+
// export type RoleId = keyof typeof config.roles
|
|
1713
|
+
`;
|
|
1714
|
+
await fs4.ensureDir(path4.dirname(outPath));
|
|
1715
|
+
await fs4.writeFile(outPath, content, "utf8");
|
|
1716
|
+
console.log(` ${chalk11.green("\u2713")} ${chalk11.yellow("src/lib/auth-types.generated.ts")} updated`);
|
|
1717
|
+
console.log();
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// src/commands/seed.ts
|
|
1721
|
+
import * as p9 from "@clack/prompts";
|
|
1722
|
+
import chalk12 from "chalk";
|
|
1723
|
+
import fs5 from "fs-extra";
|
|
1724
|
+
import path5 from "path";
|
|
1725
|
+
async function runSeed() {
|
|
1726
|
+
printBanner();
|
|
1727
|
+
printSection("SEED \xB7 Create your first user");
|
|
1728
|
+
const cwd = process.cwd();
|
|
1729
|
+
const hasConfig = fs5.existsSync(path5.join(cwd, "auth.config.ts")) || fs5.existsSync(path5.join(cwd, "auth.config.js"));
|
|
1730
|
+
if (!hasConfig) {
|
|
1731
|
+
p9.cancel("No auth.config.ts found. Run: npx aegis-auth init first.");
|
|
1732
|
+
process.exit(1);
|
|
1733
|
+
}
|
|
1734
|
+
console.log(
|
|
1735
|
+
`
|
|
1736
|
+
${chalk12.dim("This creates the first user account in your database.")}
|
|
1737
|
+
`
|
|
1738
|
+
);
|
|
1739
|
+
const role = await p9.text({
|
|
1740
|
+
message: "Role ID to seed",
|
|
1741
|
+
placeholder: "admin",
|
|
1742
|
+
validate: (v) => !v ? "Required" : void 0
|
|
1743
|
+
});
|
|
1744
|
+
if (p9.isCancel(role)) process.exit(0);
|
|
1745
|
+
const identifier = await p9.text({
|
|
1746
|
+
message: "Email or username",
|
|
1747
|
+
validate: (v) => !v ? "Required" : void 0
|
|
1748
|
+
});
|
|
1749
|
+
if (p9.isCancel(identifier)) process.exit(0);
|
|
1750
|
+
const password3 = await p9.password({
|
|
1751
|
+
message: "Password",
|
|
1752
|
+
validate: (v) => v.length < 8 ? "At least 8 characters" : void 0
|
|
1753
|
+
});
|
|
1754
|
+
if (p9.isCancel(password3)) process.exit(0);
|
|
1755
|
+
console.log();
|
|
1756
|
+
console.log(
|
|
1757
|
+
` ${chalk12.yellow("\u26A0")} ${chalk12.dim("Seed functionality requires your app to be running with Prisma connected.")}`
|
|
1758
|
+
);
|
|
1759
|
+
console.log(
|
|
1760
|
+
` ${chalk12.dim("Add this to a script in your project or run via ts-node:")}`
|
|
1761
|
+
);
|
|
1762
|
+
console.log();
|
|
1763
|
+
console.log(
|
|
1764
|
+
chalk12.dim(
|
|
1765
|
+
` import { PrismaClient } from '@prisma/client'
|
|
1766
|
+
import { hashPassword } from '@alinsafawi/aegis-auth-core'
|
|
1767
|
+
const prisma = new PrismaClient()
|
|
1768
|
+
await prisma.${role}.create({
|
|
1769
|
+
data: { email: '${identifier}', passwordHash: await hashPassword('${password3}') }
|
|
1770
|
+
})`
|
|
1771
|
+
)
|
|
1772
|
+
);
|
|
1773
|
+
console.log();
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// src/index.ts
|
|
1777
|
+
import chalk13 from "chalk";
|
|
1778
|
+
var [, , command, ...args] = process.argv;
|
|
1779
|
+
var COMMANDS = {
|
|
1780
|
+
init: runInit,
|
|
1781
|
+
"add-role": runAddRole,
|
|
1782
|
+
generate: runGenerate,
|
|
1783
|
+
seed: runSeed,
|
|
1784
|
+
doctor: runDoctor
|
|
1785
|
+
};
|
|
1786
|
+
var DESCRIPTIONS = {
|
|
1787
|
+
init: "Set up auth in a new or existing project",
|
|
1788
|
+
"add-role": "Add a new role to an existing setup",
|
|
1789
|
+
generate: "Regenerate TypeScript types from auth.config.ts",
|
|
1790
|
+
seed: "Create the first user in your database",
|
|
1791
|
+
doctor: "Check env vars, DB connection, SMTP, and Redis",
|
|
1792
|
+
upgrade: "Upgrade aegis-auth to the latest version",
|
|
1793
|
+
status: "Print a summary of your current config"
|
|
1794
|
+
};
|
|
1795
|
+
async function main() {
|
|
1796
|
+
if (!command || command === "--help" || command === "-h") {
|
|
1797
|
+
printBanner();
|
|
1798
|
+
console.log(` ${chalk13.bold("Usage")} ${chalk13.cyan("npx aegis-auth")} ${chalk13.yellow("<command>")}`);
|
|
1799
|
+
console.log();
|
|
1800
|
+
console.log(` ${chalk13.bold("Commands")}`);
|
|
1801
|
+
console.log();
|
|
1802
|
+
for (const [cmd, desc] of Object.entries(DESCRIPTIONS)) {
|
|
1803
|
+
console.log(` ${chalk13.green(cmd.padEnd(14))} ${chalk13.dim(desc)}`);
|
|
1804
|
+
}
|
|
1805
|
+
console.log();
|
|
1806
|
+
console.log(` ${chalk13.dim("Run")} ${chalk13.cyan("npx aegis-auth <command> --help")} ${chalk13.dim("for more info.")}`);
|
|
1807
|
+
console.log();
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
const handler = COMMANDS[command];
|
|
1811
|
+
if (!handler) {
|
|
1812
|
+
console.error(`
|
|
1813
|
+
${chalk13.red("Unknown command:")} ${chalk13.yellow(command)}`);
|
|
1814
|
+
console.error(` Run ${chalk13.cyan("npx aegis-auth --help")} for available commands.
|
|
1815
|
+
`);
|
|
1816
|
+
process.exit(1);
|
|
1817
|
+
}
|
|
1818
|
+
await handler();
|
|
1819
|
+
}
|
|
1820
|
+
main().catch((err) => {
|
|
1821
|
+
console.error(`
|
|
1822
|
+
${chalk13.red("Unexpected error:")} ${err.message}
|
|
1823
|
+
`);
|
|
1824
|
+
process.exit(1);
|
|
1825
|
+
});
|