@nubase/create 0.1.3 → 0.1.5
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 +5 -3
- package/package.json +1 -1
- package/templates/backend/package.json +5 -4
- package/templates/backend/src/api/routes/auth.ts +378 -203
- package/templates/backend/src/api/routes/index.ts +1 -0
- package/templates/backend/src/api/routes/test-utils.ts +390 -0
- package/templates/backend/src/api/routes/ticket.ts +105 -56
- package/templates/backend/src/auth/index.ts +208 -14
- package/templates/backend/src/db/helpers/drizzle.ts +74 -28
- package/templates/backend/src/db/seed.ts +20 -16
- package/templates/backend/src/index.ts +34 -23
- package/templates/backend/src/middleware/workspace-middleware.ts +92 -6
- package/templates/frontend/.env.test.template +1 -0
- package/templates/frontend/e2e/auth.spec.ts +111 -0
- package/templates/frontend/e2e/fixtures/base.ts +146 -0
- package/templates/frontend/e2e/fixtures/test-api.ts +132 -0
- package/templates/frontend/e2e/global-setup.ts +74 -0
- package/templates/frontend/e2e/ticket.spec.ts +73 -0
- package/templates/frontend/e2e/utils/test-reporter.ts +73 -0
- package/templates/frontend/package.json +8 -1
- package/templates/frontend/playwright.config.ts +60 -0
- package/templates/frontend/src/styles/theme.css +1 -1
- package/templates/frontend/vite.config.ts +1 -1
- package/templates/root/package.json +10 -3
- package/templates/root/turbo.json +8 -1
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ function replaceInContent(content, options) {
|
|
|
26
26
|
const kebabName = toKebabCase(options.name);
|
|
27
27
|
const pascalName = toPascalCase(options.name);
|
|
28
28
|
const camelName = toCamelCase(options.name);
|
|
29
|
-
return content.replace(/__PROJECT_NAME__/g, kebabName).replace(/__PROJECT_NAME_PASCAL__/g, pascalName).replace(/__PROJECT_NAME_CAMEL__/g, camelName).replace(/__DB_NAME__/g, options.dbName).replace(/__DB_USER__/g, options.dbUser).replace(/__DB_PASSWORD__/g, options.dbPassword).replace(/__DEV_PORT__/g, String(options.devPort)).replace(/__TEST_PORT__/g, String(options.testPort)).replace(/__BACKEND_PORT__/g, String(options.backendPort)).replace(/__FRONTEND_PORT__/g, String(options.frontendPort));
|
|
29
|
+
return content.replace(/__PROJECT_NAME__/g, kebabName).replace(/__PROJECT_NAME_PASCAL__/g, pascalName).replace(/__PROJECT_NAME_CAMEL__/g, camelName).replace(/__DB_NAME__/g, options.dbName).replace(/__DB_USER__/g, options.dbUser).replace(/__DB_PASSWORD__/g, options.dbPassword).replace(/__DEV_PORT__/g, String(options.devPort)).replace(/__TEST_PORT__/g, String(options.testPort)).replace(/__BACKEND_PORT__/g, String(options.backendPort)).replace(/__FRONTEND_PORT__/g, String(options.frontendPort)).replace(/__TEST_BACKEND_PORT__/g, String(options.testBackendPort)).replace(/__TEST_FRONTEND_PORT__/g, String(options.testFrontendPort));
|
|
30
30
|
}
|
|
31
31
|
function copyTemplateDir(src, dest, options) {
|
|
32
32
|
fse.ensureDirSync(dest);
|
|
@@ -48,7 +48,7 @@ function copyTemplateDir(src, dest, options) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
async function main() {
|
|
51
|
-
program.name("@nubase/create").description("Create a new Nubase application").argument("[project-name]", "Name of the project").option("--db-name <name>", "Database name").option("--db-user <user>", "Database user").option("--db-password <password>", "Database password").option("--dev-port <port>", "Development database port", "5434").option("--test-port <port>", "Test database port", "5435").option("--backend-port <port>", "Backend server port", "3001").option("--frontend-port <port>", "Frontend dev server port", "3002").option("--skip-install", "Skip npm install").parse();
|
|
51
|
+
program.name("@nubase/create").description("Create a new Nubase application").argument("[project-name]", "Name of the project").option("--db-name <name>", "Database name").option("--db-user <user>", "Database user").option("--db-password <password>", "Database password").option("--dev-port <port>", "Development database port", "5434").option("--test-port <port>", "Test database port", "5435").option("--backend-port <port>", "Backend server port", "3001").option("--frontend-port <port>", "Frontend dev server port", "3002").option("--test-backend-port <port>", "Test backend server port", "4001").option("--test-frontend-port <port>", "Test frontend dev server port", "4002").option("--skip-install", "Skip npm install").parse();
|
|
52
52
|
const args = program.args;
|
|
53
53
|
const opts = program.opts();
|
|
54
54
|
console.log(chalk.bold.cyan("\n Welcome to Nubase!\n"));
|
|
@@ -78,7 +78,9 @@ async function main() {
|
|
|
78
78
|
devPort: Number.parseInt(opts.devPort, 10),
|
|
79
79
|
testPort: Number.parseInt(opts.testPort, 10),
|
|
80
80
|
backendPort: Number.parseInt(opts.backendPort, 10),
|
|
81
|
-
frontendPort: Number.parseInt(opts.frontendPort, 10)
|
|
81
|
+
frontendPort: Number.parseInt(opts.frontendPort, 10),
|
|
82
|
+
testBackendPort: Number.parseInt(opts.testBackendPort, 10),
|
|
83
|
+
testFrontendPort: Number.parseInt(opts.testFrontendPort, 10)
|
|
82
84
|
};
|
|
83
85
|
const targetDir = path.join(process.cwd(), projectName);
|
|
84
86
|
if (fs.existsSync(targetDir)) {
|
package/package.json
CHANGED
|
@@ -4,17 +4,18 @@
|
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "NODE_ENV=development tsx watch src/index.ts",
|
|
7
|
-
"dev:test": "PORT=
|
|
7
|
+
"dev:test": "PORT=__TEST_BACKEND_PORT__ DB_PORT=__TEST_PORT__ NODE_ENV=test tsx watch src/index.ts",
|
|
8
8
|
"build": "tsc",
|
|
9
9
|
"start": "node dist/index.js",
|
|
10
10
|
"db:dev:up": "docker compose -f docker/dev/docker-compose.yml up -d",
|
|
11
11
|
"db:dev:down": "docker compose -f docker/dev/docker-compose.yml down",
|
|
12
|
-
"db:dev:kill": "docker compose -f docker/dev/docker-compose.yml down -v",
|
|
12
|
+
"db:dev:kill": "docker compose -f docker/dev/docker-compose.yml down -v && rm -rf docker/dev/postgresql-data",
|
|
13
13
|
"db:dev:seed": "NODE_ENV=development tsx src/db/seed.ts",
|
|
14
|
+
"db:dev:reset": "npm run db:dev:kill && npm run db:schema-sync && npm run db:dev:up && sleep 5 && npm run db:dev:seed",
|
|
14
15
|
"db:test:up": "docker compose -f docker/test/docker-compose.yml up -d",
|
|
15
16
|
"db:test:down": "docker compose -f docker/test/docker-compose.yml down",
|
|
16
|
-
"db:test:kill": "docker compose -f docker/test/docker-compose.yml down -v",
|
|
17
|
-
"db:
|
|
17
|
+
"db:test:kill": "docker compose -f docker/test/docker-compose.yml down -v && rm -rf docker/test/postgresql-data",
|
|
18
|
+
"db:test:reset": "npm run db:test:kill && npm run db:schema-sync && npm run db:test:up",
|
|
18
19
|
"db:schema-sync": "cp db/schema.sql docker/dev/postgresql-init/dump.sql && cp db/schema.sql docker/test/postgresql-init/dump.sql",
|
|
19
20
|
"typecheck": "tsc --noEmit",
|
|
20
21
|
"lint": "biome check .",
|
|
@@ -1,209 +1,384 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHttpHandler,
|
|
3
|
+
getAuthController,
|
|
4
|
+
HttpError,
|
|
5
|
+
} from "@nubase/backend";
|
|
1
6
|
import bcrypt from "bcrypt";
|
|
2
|
-
import { eq } from "drizzle-orm";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
8
|
+
import { apiEndpoints } from "schema";
|
|
9
|
+
import jwt from "jsonwebtoken";
|
|
10
|
+
import type { __PROJECT_NAME_PASCAL__User } from "../../auth";
|
|
11
|
+
import { getAdminDb } from "../../db/helpers/drizzle";
|
|
12
|
+
import { users, userWorkspaces, workspaces } from "../../db/schema";
|
|
13
|
+
|
|
14
|
+
// Short-lived secret for login tokens (in production, use a proper secret)
|
|
15
|
+
const LOGIN_TOKEN_SECRET =
|
|
16
|
+
process.env.LOGIN_TOKEN_SECRET ||
|
|
17
|
+
"nubase-login-token-secret-change-in-production";
|
|
18
|
+
const LOGIN_TOKEN_EXPIRY = "5m"; // 5 minutes to complete workspace selection
|
|
19
|
+
|
|
20
|
+
interface LoginTokenPayload {
|
|
21
|
+
userId: number;
|
|
22
|
+
username: string;
|
|
23
|
+
}
|
|
9
24
|
|
|
10
25
|
export const authHandlers = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Login Start handler - Step 1 of two-step auth.
|
|
28
|
+
* Validates credentials and returns list of workspaces.
|
|
29
|
+
*/
|
|
30
|
+
loginStart: createHttpHandler({
|
|
31
|
+
endpoint: apiEndpoints.loginStart,
|
|
32
|
+
handler: async ({ body }) => {
|
|
33
|
+
// Find user by username
|
|
34
|
+
const [user] = await getAdminDb()
|
|
35
|
+
.select()
|
|
36
|
+
.from(users)
|
|
37
|
+
.where(eq(users.username, body.username));
|
|
38
|
+
|
|
39
|
+
if (!user) {
|
|
40
|
+
throw new HttpError(401, "Invalid username or password");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Verify password
|
|
44
|
+
const isValidPassword = await bcrypt.compare(
|
|
45
|
+
body.password,
|
|
46
|
+
user.passwordHash,
|
|
47
|
+
);
|
|
48
|
+
if (!isValidPassword) {
|
|
49
|
+
throw new HttpError(401, "Invalid username or password");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Get all workspaces this user belongs to
|
|
53
|
+
const userWorkspaceRows = await getAdminDb()
|
|
54
|
+
.select()
|
|
55
|
+
.from(userWorkspaces)
|
|
56
|
+
.where(eq(userWorkspaces.userId, user.id));
|
|
57
|
+
|
|
58
|
+
if (userWorkspaceRows.length === 0) {
|
|
59
|
+
throw new HttpError(401, "User has no workspace access");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fetch workspace details
|
|
63
|
+
const workspaceIds = userWorkspaceRows.map((uw) => uw.workspaceId);
|
|
64
|
+
const workspaceList = await getAdminDb()
|
|
65
|
+
.select()
|
|
66
|
+
.from(workspaces)
|
|
67
|
+
.where(inArray(workspaces.id, workspaceIds));
|
|
68
|
+
|
|
69
|
+
// Create a short-lived login token
|
|
70
|
+
const loginToken = jwt.sign(
|
|
71
|
+
{
|
|
72
|
+
userId: user.id,
|
|
73
|
+
username: body.username,
|
|
74
|
+
} satisfies LoginTokenPayload,
|
|
75
|
+
LOGIN_TOKEN_SECRET,
|
|
76
|
+
{ expiresIn: LOGIN_TOKEN_EXPIRY },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
loginToken,
|
|
81
|
+
username: body.username,
|
|
82
|
+
workspaces: workspaceList.map((w) => ({
|
|
83
|
+
id: w.id,
|
|
84
|
+
slug: w.slug,
|
|
85
|
+
name: w.name,
|
|
86
|
+
})),
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Login Complete handler - Step 2 of two-step auth.
|
|
93
|
+
* Validates the login token and selected workspace.
|
|
94
|
+
*/
|
|
95
|
+
loginComplete: createHttpHandler({
|
|
96
|
+
endpoint: apiEndpoints.loginComplete,
|
|
97
|
+
handler: async ({ body, ctx }) => {
|
|
98
|
+
const authController = getAuthController<__PROJECT_NAME_PASCAL__User>(ctx);
|
|
99
|
+
|
|
100
|
+
// Verify the login token
|
|
101
|
+
let decoded: LoginTokenPayload;
|
|
102
|
+
try {
|
|
103
|
+
decoded = jwt.verify(
|
|
104
|
+
body.loginToken,
|
|
105
|
+
LOGIN_TOKEN_SECRET,
|
|
106
|
+
) as LoginTokenPayload;
|
|
107
|
+
} catch {
|
|
108
|
+
throw new HttpError(401, "Invalid or expired login token");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Look up the selected workspace
|
|
112
|
+
const [workspace] = await getAdminDb()
|
|
113
|
+
.select()
|
|
114
|
+
.from(workspaces)
|
|
115
|
+
.where(eq(workspaces.slug, body.workspace));
|
|
116
|
+
|
|
117
|
+
if (!workspace) {
|
|
118
|
+
throw new HttpError(404, `Workspace not found: ${body.workspace}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Verify user has access to this workspace
|
|
122
|
+
const [access] = await getAdminDb()
|
|
123
|
+
.select()
|
|
124
|
+
.from(userWorkspaces)
|
|
125
|
+
.where(
|
|
126
|
+
and(
|
|
127
|
+
eq(userWorkspaces.userId, decoded.userId),
|
|
128
|
+
eq(userWorkspaces.workspaceId, workspace.id),
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (!access) {
|
|
133
|
+
throw new HttpError(403, "You do not have access to this workspace");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Fetch the user
|
|
137
|
+
const [dbUser] = await getAdminDb()
|
|
138
|
+
.select()
|
|
139
|
+
.from(users)
|
|
140
|
+
.where(eq(users.id, decoded.userId));
|
|
141
|
+
|
|
142
|
+
if (!dbUser) {
|
|
143
|
+
throw new HttpError(401, "User not found");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Create user object for token
|
|
147
|
+
const user: __PROJECT_NAME_PASCAL__User = {
|
|
148
|
+
id: dbUser.id,
|
|
149
|
+
email: dbUser.email,
|
|
150
|
+
username: dbUser.username,
|
|
151
|
+
workspaceId: workspace.id,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Create and set the auth token
|
|
155
|
+
const token = await authController.createToken(user);
|
|
156
|
+
authController.setTokenInResponse(ctx, token);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
user: {
|
|
160
|
+
id: user.id,
|
|
161
|
+
email: user.email,
|
|
162
|
+
username: user.username,
|
|
163
|
+
},
|
|
164
|
+
workspace: {
|
|
165
|
+
id: workspace.id,
|
|
166
|
+
slug: workspace.slug,
|
|
167
|
+
name: workspace.name,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Legacy Login handler - validates credentials and sets HttpOnly cookie.
|
|
175
|
+
* @deprecated Use loginStart and loginComplete for two-step flow
|
|
176
|
+
*/
|
|
177
|
+
login: createHttpHandler({
|
|
178
|
+
endpoint: apiEndpoints.login,
|
|
179
|
+
handler: async ({ body, ctx }) => {
|
|
180
|
+
const authController = getAuthController<__PROJECT_NAME_PASCAL__User>(ctx);
|
|
181
|
+
|
|
182
|
+
// Look up workspace
|
|
183
|
+
const [workspace] = await getAdminDb()
|
|
184
|
+
.select()
|
|
185
|
+
.from(workspaces)
|
|
186
|
+
.where(eq(workspaces.slug, body.workspace));
|
|
187
|
+
|
|
188
|
+
if (!workspace) {
|
|
189
|
+
throw new HttpError(404, `Workspace not found: ${body.workspace}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Find user by username
|
|
193
|
+
const [dbUser] = await getAdminDb()
|
|
194
|
+
.select()
|
|
195
|
+
.from(users)
|
|
196
|
+
.where(eq(users.username, body.username));
|
|
197
|
+
|
|
198
|
+
if (!dbUser) {
|
|
199
|
+
throw new HttpError(401, "Invalid username or password");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Verify password
|
|
203
|
+
const isValidPassword = await bcrypt.compare(
|
|
204
|
+
body.password,
|
|
205
|
+
dbUser.passwordHash,
|
|
206
|
+
);
|
|
207
|
+
if (!isValidPassword) {
|
|
208
|
+
throw new HttpError(401, "Invalid username or password");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Verify user has access to this workspace
|
|
212
|
+
const [access] = await getAdminDb()
|
|
213
|
+
.select()
|
|
214
|
+
.from(userWorkspaces)
|
|
215
|
+
.where(
|
|
216
|
+
and(
|
|
217
|
+
eq(userWorkspaces.userId, dbUser.id),
|
|
218
|
+
eq(userWorkspaces.workspaceId, workspace.id),
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
if (!access) {
|
|
223
|
+
throw new HttpError(403, "You do not have access to this workspace");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Create user object for token
|
|
227
|
+
const user: __PROJECT_NAME_PASCAL__User = {
|
|
228
|
+
id: dbUser.id,
|
|
229
|
+
email: dbUser.email,
|
|
230
|
+
username: dbUser.username,
|
|
231
|
+
workspaceId: workspace.id,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Create and set token
|
|
235
|
+
const token = await authController.createToken(user);
|
|
236
|
+
authController.setTokenInResponse(ctx, token);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
user: {
|
|
240
|
+
id: user.id,
|
|
241
|
+
email: user.email,
|
|
242
|
+
username: user.username,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
|
|
248
|
+
/** Logout handler - clears the auth cookie. */
|
|
249
|
+
logout: createHttpHandler({
|
|
250
|
+
endpoint: apiEndpoints.logout,
|
|
251
|
+
handler: async ({ ctx }) => {
|
|
252
|
+
const authController = getAuthController(ctx);
|
|
253
|
+
authController.clearTokenFromResponse(ctx);
|
|
254
|
+
return { success: true };
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
|
|
258
|
+
/** Get current user handler. */
|
|
259
|
+
getMe: createHttpHandler<
|
|
260
|
+
typeof apiEndpoints.getMe,
|
|
261
|
+
"optional",
|
|
262
|
+
__PROJECT_NAME_PASCAL__User
|
|
263
|
+
>({
|
|
264
|
+
endpoint: apiEndpoints.getMe,
|
|
265
|
+
auth: "optional",
|
|
266
|
+
handler: async ({ user }) => {
|
|
267
|
+
if (!user) {
|
|
268
|
+
return { user: undefined };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
user: {
|
|
273
|
+
id: user.id,
|
|
274
|
+
email: user.email,
|
|
275
|
+
username: user.username,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
}),
|
|
280
|
+
|
|
281
|
+
/** Signup handler - creates a new workspace and admin user. */
|
|
282
|
+
signup: createHttpHandler({
|
|
283
|
+
endpoint: apiEndpoints.signup,
|
|
284
|
+
handler: async ({ body, ctx }) => {
|
|
285
|
+
const authController = getAuthController<__PROJECT_NAME_PASCAL__User>(ctx);
|
|
286
|
+
|
|
287
|
+
// Validate workspace slug format
|
|
288
|
+
if (!/^[a-z0-9-]+$/.test(body.workspace)) {
|
|
289
|
+
throw new HttpError(
|
|
290
|
+
400,
|
|
291
|
+
"Workspace slug must be lowercase and contain only letters, numbers, and hyphens",
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Check if workspace slug already exists
|
|
296
|
+
const [existingWorkspace] = await getAdminDb()
|
|
297
|
+
.select()
|
|
298
|
+
.from(workspaces)
|
|
299
|
+
.where(eq(workspaces.slug, body.workspace));
|
|
300
|
+
|
|
301
|
+
if (existingWorkspace) {
|
|
302
|
+
throw new HttpError(409, "Organization slug is already taken");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Check if username already exists
|
|
306
|
+
const [existingUser] = await getAdminDb()
|
|
307
|
+
.select()
|
|
308
|
+
.from(users)
|
|
309
|
+
.where(eq(users.username, body.username));
|
|
310
|
+
|
|
311
|
+
if (existingUser) {
|
|
312
|
+
throw new HttpError(409, "Username is already taken");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Check if email already exists
|
|
316
|
+
const [existingEmail] = await getAdminDb()
|
|
317
|
+
.select()
|
|
318
|
+
.from(users)
|
|
319
|
+
.where(eq(users.email, body.email));
|
|
320
|
+
|
|
321
|
+
if (existingEmail) {
|
|
322
|
+
throw new HttpError(409, "Email is already registered");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Validate password length
|
|
326
|
+
if (body.password.length < 8) {
|
|
327
|
+
throw new HttpError(400, "Password must be at least 8 characters long");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Create the workspace
|
|
331
|
+
const [newWorkspace] = await getAdminDb()
|
|
186
332
|
.insert(workspaces)
|
|
187
|
-
.values({
|
|
333
|
+
.values({
|
|
334
|
+
slug: body.workspace,
|
|
335
|
+
name: body.workspaceName,
|
|
336
|
+
})
|
|
337
|
+
.returning();
|
|
338
|
+
|
|
339
|
+
// Hash the password
|
|
340
|
+
const passwordHash = await bcrypt.hash(body.password, 10);
|
|
341
|
+
|
|
342
|
+
// Create the admin user
|
|
343
|
+
const [newUser] = await getAdminDb()
|
|
344
|
+
.insert(users)
|
|
345
|
+
.values({
|
|
346
|
+
email: body.email,
|
|
347
|
+
username: body.username,
|
|
348
|
+
passwordHash,
|
|
349
|
+
})
|
|
188
350
|
.returning();
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
token
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
351
|
+
|
|
352
|
+
// Link user to workspace
|
|
353
|
+
await getAdminDb().insert(userWorkspaces).values({
|
|
354
|
+
userId: newUser.id,
|
|
355
|
+
workspaceId: newWorkspace.id,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Create user object for token
|
|
359
|
+
const user: __PROJECT_NAME_PASCAL__User = {
|
|
360
|
+
id: newUser.id,
|
|
361
|
+
email: newUser.email,
|
|
362
|
+
username: newUser.username,
|
|
363
|
+
workspaceId: newWorkspace.id,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// Create and set the auth token
|
|
367
|
+
const token = await authController.createToken(user);
|
|
368
|
+
authController.setTokenInResponse(ctx, token);
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
user: {
|
|
372
|
+
id: user.id,
|
|
373
|
+
email: user.email,
|
|
374
|
+
username: user.username,
|
|
375
|
+
},
|
|
376
|
+
workspace: {
|
|
377
|
+
id: newWorkspace.id,
|
|
378
|
+
slug: newWorkspace.slug,
|
|
379
|
+
name: newWorkspace.name,
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
},
|
|
383
|
+
}),
|
|
209
384
|
};
|