@nubase/create 0.1.1 → 0.1.3
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 +1 -4
- package/package.json +1 -1
- package/templates/backend/package.json +2 -2
- package/templates/backend/src/api/routes/auth.ts +3 -3
- package/templates/backend/src/auth/index.ts +1 -6
- package/templates/frontend/package.json +3 -2
- package/templates/frontend/src/auth/__PROJECT_NAME_PASCAL__AuthController.ts +248 -38
- package/templates/frontend/src/config.tsx +10 -48
- package/templates/frontend/src/resources/ticket.ts +39 -15
- package/templates/root/README.md +4 -4
- package/templates/root/package.json +11 -9
- package/templates/schema/package.json +1 -1
- package/templates/schema/src/schema/auth.ts +88 -33
- package/templates/schema/src/schema/ticket.ts +27 -24
package/dist/index.js
CHANGED
|
@@ -107,10 +107,7 @@ Creating project in ${chalk.bold(targetDir)}...
|
|
|
107
107
|
if (template === "root") {
|
|
108
108
|
copyTemplateDir(templatePath, targetDir, options);
|
|
109
109
|
} else {
|
|
110
|
-
const destPath = path.join(
|
|
111
|
-
targetDir,
|
|
112
|
-
`${toKebabCase(projectName)}-${template}`
|
|
113
|
-
);
|
|
110
|
+
const destPath = path.join(targetDir, template);
|
|
114
111
|
copyTemplateDir(templatePath, destPath, options);
|
|
115
112
|
}
|
|
116
113
|
console.log(chalk.green(` \u2713 Created ${template}`));
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
2
|
+
"name": "backend",
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"hono": "^4.6.14",
|
|
32
32
|
"jsonwebtoken": "^9.0.2",
|
|
33
33
|
"pg": "^8.13.1",
|
|
34
|
-
"
|
|
34
|
+
"schema": "*"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@faker-js/faker": "^9.3.0",
|
|
@@ -62,7 +62,7 @@ export const authHandlers = {
|
|
|
62
62
|
const token = authController.generateToken({
|
|
63
63
|
userId: user.id,
|
|
64
64
|
workspaceId: workspace.id,
|
|
65
|
-
|
|
65
|
+
username: user.username,
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
return c.json({
|
|
@@ -104,7 +104,7 @@ export const authHandlers = {
|
|
|
104
104
|
const token = authController.generateToken({
|
|
105
105
|
userId: user.id,
|
|
106
106
|
workspaceId: userWs.id,
|
|
107
|
-
|
|
107
|
+
username: user.username,
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
return c.json({
|
|
@@ -197,7 +197,7 @@ export const authHandlers = {
|
|
|
197
197
|
const token = authController.generateToken({
|
|
198
198
|
userId: user.id,
|
|
199
199
|
workspaceId: workspace.id,
|
|
200
|
-
|
|
200
|
+
username: user.username,
|
|
201
201
|
});
|
|
202
202
|
|
|
203
203
|
return c.json({
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import bcrypt from "bcrypt";
|
|
2
2
|
import jwt from "jsonwebtoken";
|
|
3
|
-
import type { User, Workspace } from "__PROJECT_NAME__-schema";
|
|
4
|
-
|
|
5
|
-
export interface __PROJECT_NAME_PASCAL__User extends User {
|
|
6
|
-
workspace: Workspace;
|
|
7
|
-
}
|
|
8
3
|
|
|
9
4
|
export interface TokenPayload {
|
|
10
5
|
userId: number;
|
|
11
6
|
workspaceId: number;
|
|
12
|
-
|
|
7
|
+
username: string;
|
|
13
8
|
}
|
|
14
9
|
|
|
15
10
|
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
2
|
+
"name": "frontend",
|
|
3
3
|
"private": true,
|
|
4
4
|
"version": "0.1.0",
|
|
5
5
|
"type": "module",
|
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
"@nubase/frontend": "*",
|
|
16
16
|
"@tailwindcss/vite": "^4.1.18",
|
|
17
17
|
"@tanstack/react-router": "^1.141.8",
|
|
18
|
+
"lucide-react": "^0.511.0",
|
|
18
19
|
"react": "^19.0.0",
|
|
19
20
|
"react-dom": "^19.0.0",
|
|
20
21
|
"tailwindcss": "^4.1.18",
|
|
21
|
-
"
|
|
22
|
+
"schema": "*"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
25
|
"@types/react": "^19.0.10",
|
|
@@ -1,59 +1,269 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
AuthenticatedUser,
|
|
3
|
+
AuthenticationController,
|
|
4
|
+
AuthenticationState,
|
|
5
|
+
AuthenticationStateListener,
|
|
6
|
+
LoginCompleteCredentials,
|
|
7
|
+
LoginCredentials,
|
|
8
|
+
LoginStartResponse,
|
|
9
|
+
SignupCredentials,
|
|
10
|
+
WorkspaceInfo,
|
|
11
|
+
} from "@nubase/frontend";
|
|
3
12
|
|
|
4
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Application-specific implementation of AuthenticationController.
|
|
15
|
+
* Communicates with the backend auth endpoints using HttpOnly cookies.
|
|
16
|
+
*/
|
|
17
|
+
export class __PROJECT_NAME_PASCAL__AuthController
|
|
18
|
+
implements AuthenticationController
|
|
19
|
+
{
|
|
20
|
+
private state: AuthenticationState = {
|
|
21
|
+
status: "loading",
|
|
22
|
+
user: null,
|
|
23
|
+
error: null,
|
|
24
|
+
};
|
|
5
25
|
|
|
6
|
-
|
|
7
|
-
private
|
|
26
|
+
private listeners = new Set<AuthenticationStateListener>();
|
|
27
|
+
private apiBaseUrl: string;
|
|
8
28
|
|
|
9
|
-
constructor() {
|
|
10
|
-
this.
|
|
29
|
+
constructor(apiBaseUrl: string) {
|
|
30
|
+
this.apiBaseUrl = apiBaseUrl;
|
|
11
31
|
}
|
|
12
32
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
getState(): AuthenticationState {
|
|
34
|
+
return this.state;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
subscribe(listener: AuthenticationStateListener): () => void {
|
|
38
|
+
this.listeners.add(listener);
|
|
39
|
+
return () => {
|
|
40
|
+
this.listeners.delete(listener);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private setState(newState: Partial<AuthenticationState>): void {
|
|
45
|
+
this.state = { ...this.state, ...newState };
|
|
46
|
+
for (const listener of this.listeners) {
|
|
47
|
+
listener(this.state);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async login(credentials: LoginCredentials): Promise<void> {
|
|
52
|
+
this.setState({ status: "loading", error: null });
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// Include workspace in the login request body for path-based multi-workspace
|
|
56
|
+
const response = await fetch(`${this.apiBaseUrl}/auth/login`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: {
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
},
|
|
61
|
+
credentials: "include", // Important for cookies
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
username: credentials.username,
|
|
64
|
+
password: credentials.password,
|
|
65
|
+
workspace: credentials.workspace,
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
const errorData = await response.json().catch(() => ({}));
|
|
71
|
+
throw new Error(errorData.error || "Invalid username or password");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const data = await response.json();
|
|
75
|
+
const user: AuthenticatedUser = data.user;
|
|
76
|
+
|
|
77
|
+
this.setState({
|
|
78
|
+
status: "authenticated",
|
|
79
|
+
user,
|
|
80
|
+
error: null,
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const err = error instanceof Error ? error : new Error("Login failed");
|
|
84
|
+
this.setState({
|
|
85
|
+
status: "unauthenticated",
|
|
86
|
+
user: null,
|
|
87
|
+
error: err,
|
|
88
|
+
});
|
|
89
|
+
throw err;
|
|
24
90
|
}
|
|
91
|
+
}
|
|
25
92
|
|
|
93
|
+
async logout(): Promise<void> {
|
|
26
94
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
workspace: response.workspace,
|
|
32
|
-
};
|
|
95
|
+
await fetch(`${this.apiBaseUrl}/auth/logout`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
credentials: "include",
|
|
98
|
+
});
|
|
33
99
|
} catch {
|
|
34
|
-
|
|
35
|
-
return { isAuthenticated: false };
|
|
100
|
+
// Logout errors are not critical - clear local state anyway
|
|
36
101
|
}
|
|
102
|
+
|
|
103
|
+
this.setState({
|
|
104
|
+
status: "unauthenticated",
|
|
105
|
+
user: null,
|
|
106
|
+
error: null,
|
|
107
|
+
});
|
|
37
108
|
}
|
|
38
109
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
110
|
+
async initialize(): Promise<void> {
|
|
111
|
+
this.setState({ status: "loading", error: null });
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch(`${this.apiBaseUrl}/auth/me`, {
|
|
115
|
+
method: "GET",
|
|
116
|
+
credentials: "include",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
this.setState({
|
|
121
|
+
status: "unauthenticated",
|
|
122
|
+
user: null,
|
|
123
|
+
error: null,
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const data = await response.json();
|
|
129
|
+
|
|
130
|
+
if (data.user) {
|
|
131
|
+
this.setState({
|
|
132
|
+
status: "authenticated",
|
|
133
|
+
user: data.user,
|
|
134
|
+
error: null,
|
|
135
|
+
});
|
|
136
|
+
} else {
|
|
137
|
+
this.setState({
|
|
138
|
+
status: "unauthenticated",
|
|
139
|
+
user: null,
|
|
140
|
+
error: null,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Network error or server unavailable - treat as unauthenticated
|
|
145
|
+
this.setState({
|
|
146
|
+
status: "unauthenticated",
|
|
147
|
+
user: null,
|
|
148
|
+
error: null,
|
|
149
|
+
});
|
|
42
150
|
}
|
|
43
|
-
return { Authorization: `Bearer ${this.token}` };
|
|
44
151
|
}
|
|
45
152
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Start the two-step login process.
|
|
155
|
+
* Step 1: Validates credentials and returns list of workspaces user belongs to.
|
|
156
|
+
*/
|
|
157
|
+
async loginStart(credentials: {
|
|
158
|
+
username: string;
|
|
159
|
+
password: string;
|
|
160
|
+
}): Promise<LoginStartResponse> {
|
|
161
|
+
const response = await fetch(`${this.apiBaseUrl}/auth/login/start`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: {
|
|
164
|
+
"Content-Type": "application/json",
|
|
165
|
+
},
|
|
166
|
+
credentials: "include",
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
username: credentials.username,
|
|
169
|
+
password: credentials.password,
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
const errorData = await response.json().catch(() => ({}));
|
|
175
|
+
throw new Error(errorData.error || "Invalid username or password");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return response.json();
|
|
49
179
|
}
|
|
50
180
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Complete the two-step login process.
|
|
183
|
+
* Step 2: Select a workspace and complete authentication.
|
|
184
|
+
*/
|
|
185
|
+
async loginComplete(
|
|
186
|
+
credentials: LoginCompleteCredentials,
|
|
187
|
+
): Promise<WorkspaceInfo> {
|
|
188
|
+
this.setState({ status: "loading", error: null });
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const response = await fetch(`${this.apiBaseUrl}/auth/login/complete`, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: {
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
},
|
|
196
|
+
credentials: "include",
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
loginToken: credentials.loginToken,
|
|
199
|
+
workspace: credentials.workspace,
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
const errorData = await response.json().catch(() => ({}));
|
|
205
|
+
throw new Error(errorData.error || "Login failed");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const data = await response.json();
|
|
209
|
+
const user: AuthenticatedUser = data.user;
|
|
210
|
+
|
|
211
|
+
this.setState({
|
|
212
|
+
status: "authenticated",
|
|
213
|
+
user,
|
|
214
|
+
error: null,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return data.workspace;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const err = error instanceof Error ? error : new Error("Login failed");
|
|
220
|
+
this.setState({
|
|
221
|
+
status: "unauthenticated",
|
|
222
|
+
user: null,
|
|
223
|
+
error: err,
|
|
224
|
+
});
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
54
227
|
}
|
|
55
228
|
|
|
56
|
-
|
|
57
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Sign up a new user and create a new workspace.
|
|
231
|
+
* After successful signup, the user is automatically logged in.
|
|
232
|
+
*/
|
|
233
|
+
async signup(credentials: SignupCredentials): Promise<void> {
|
|
234
|
+
this.setState({ status: "loading", error: null });
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const response = await fetch(`${this.apiBaseUrl}/auth/signup`, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: {
|
|
240
|
+
"Content-Type": "application/json",
|
|
241
|
+
},
|
|
242
|
+
credentials: "include",
|
|
243
|
+
body: JSON.stringify(credentials),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
const errorData = await response.json().catch(() => ({}));
|
|
248
|
+
throw new Error(errorData.error || "Signup failed");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const data = await response.json();
|
|
252
|
+
const user: AuthenticatedUser = data.user;
|
|
253
|
+
|
|
254
|
+
this.setState({
|
|
255
|
+
status: "authenticated",
|
|
256
|
+
user,
|
|
257
|
+
error: null,
|
|
258
|
+
});
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const err = error instanceof Error ? error : new Error("Signup failed");
|
|
261
|
+
this.setState({
|
|
262
|
+
status: "unauthenticated",
|
|
263
|
+
user: null,
|
|
264
|
+
error: err,
|
|
265
|
+
});
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
58
268
|
}
|
|
59
269
|
}
|
|
@@ -1,58 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
} from "@nubase/frontend";
|
|
6
|
-
import { apiEndpoints } from "__PROJECT_NAME__-schema";
|
|
1
|
+
import type { NubaseFrontendConfig } from "@nubase/frontend";
|
|
2
|
+
import { defaultKeybindings, resourceLink } from "@nubase/frontend";
|
|
3
|
+
import { Home, TicketIcon } from "lucide-react";
|
|
4
|
+
import { apiEndpoints } from "schema";
|
|
7
5
|
import { __PROJECT_NAME_PASCAL__AuthController } from "./auth/__PROJECT_NAME_PASCAL__AuthController";
|
|
8
6
|
import { ticketResource } from "./resources/ticket";
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
14
|
-
width="20"
|
|
15
|
-
height="20"
|
|
16
|
-
viewBox="0 0 24 24"
|
|
17
|
-
fill="none"
|
|
18
|
-
stroke="currentColor"
|
|
19
|
-
strokeWidth="2"
|
|
20
|
-
strokeLinecap="round"
|
|
21
|
-
strokeLinejoin="round"
|
|
22
|
-
>
|
|
23
|
-
<path d="M5 12l-2 0l9 -9l9 9l-2 0" />
|
|
24
|
-
<path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7" />
|
|
25
|
-
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" />
|
|
26
|
-
</svg>
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
const TicketIcon = () => (
|
|
30
|
-
<svg
|
|
31
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
32
|
-
width="20"
|
|
33
|
-
height="20"
|
|
34
|
-
viewBox="0 0 24 24"
|
|
35
|
-
fill="none"
|
|
36
|
-
stroke="currentColor"
|
|
37
|
-
strokeWidth="2"
|
|
38
|
-
strokeLinecap="round"
|
|
39
|
-
strokeLinejoin="round"
|
|
40
|
-
>
|
|
41
|
-
<path d="M15 5l0 2" />
|
|
42
|
-
<path d="M15 11l0 2" />
|
|
43
|
-
<path d="M15 17l0 2" />
|
|
44
|
-
<path d="M5 5h14a2 2 0 0 1 2 2v3a2 2 0 0 0 0 4v3a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-3a2 2 0 0 0 0 -4v-3a2 2 0 0 1 2 -2" />
|
|
45
|
-
</svg>
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
const authController = new __PROJECT_NAME_PASCAL__AuthController();
|
|
8
|
+
const apiBaseUrl =
|
|
9
|
+
import.meta.env.VITE_API_BASE_URL || "http://localhost:__BACKEND_PORT__";
|
|
10
|
+
const authController = new __PROJECT_NAME_PASCAL__AuthController(apiBaseUrl);
|
|
49
11
|
|
|
50
12
|
export const config: NubaseFrontendConfig<typeof apiEndpoints> = {
|
|
51
13
|
appName: "__PROJECT_NAME_PASCAL__",
|
|
52
14
|
mainMenu: [
|
|
53
15
|
{
|
|
54
16
|
id: "home",
|
|
55
|
-
icon:
|
|
17
|
+
icon: Home,
|
|
56
18
|
label: "Home",
|
|
57
19
|
href: "/",
|
|
58
20
|
},
|
|
@@ -66,8 +28,8 @@ export const config: NubaseFrontendConfig<typeof apiEndpoints> = {
|
|
|
66
28
|
resources: {
|
|
67
29
|
[ticketResource.id]: ticketResource,
|
|
68
30
|
},
|
|
69
|
-
keybindings: defaultKeybindings,
|
|
70
|
-
apiBaseUrl:
|
|
31
|
+
keybindings: defaultKeybindings.extend(),
|
|
32
|
+
apiBaseUrl: apiBaseUrl,
|
|
71
33
|
apiEndpoints,
|
|
72
34
|
themeIds: ["dark", "light"],
|
|
73
35
|
defaultThemeId: "dark",
|
|
@@ -1,32 +1,56 @@
|
|
|
1
1
|
import { createResource } from "@nubase/frontend";
|
|
2
|
-
import { apiEndpoints
|
|
2
|
+
import { apiEndpoints } from "schema";
|
|
3
3
|
|
|
4
4
|
export const ticketResource = createResource("ticket")
|
|
5
5
|
.withApiEndpoints(apiEndpoints)
|
|
6
6
|
.withViews({
|
|
7
7
|
create: {
|
|
8
8
|
type: "resource-create",
|
|
9
|
+
id: "create-ticket",
|
|
9
10
|
title: "Create Ticket",
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
schemaPost: (api) => api.postTicket.requestBody,
|
|
12
|
+
breadcrumbs: [
|
|
13
|
+
{ label: "Tickets", to: "/r/ticket/search" },
|
|
14
|
+
"Create Ticket",
|
|
15
|
+
],
|
|
16
|
+
onSubmit: async ({ data, context }) => {
|
|
17
|
+
return context.http.postTicket({ data });
|
|
18
|
+
},
|
|
12
19
|
},
|
|
13
20
|
view: {
|
|
14
21
|
type: "resource-view",
|
|
22
|
+
id: "view-ticket",
|
|
15
23
|
title: "View Ticket",
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
schemaGet: (api) => api.getTicket.responseBody.omit("id"),
|
|
25
|
+
schemaParams: (api) => api.getTicket.requestParams,
|
|
26
|
+
breadcrumbs: ({ context, data }) => [
|
|
27
|
+
{ label: "Tickets", to: "/r/ticket/search" },
|
|
28
|
+
{
|
|
29
|
+
label: data?.title || `Ticket #${context.params?.id || "Unknown"}`,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
onLoad: async ({ context }) => {
|
|
33
|
+
return context.http.getTicket({
|
|
34
|
+
params: { id: context.params.id },
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
onPatch: async ({ data, context }) => {
|
|
38
|
+
return context.http.patchTicket({
|
|
39
|
+
params: { id: context.params.id },
|
|
40
|
+
data: data,
|
|
41
|
+
});
|
|
42
|
+
},
|
|
25
43
|
},
|
|
26
44
|
search: {
|
|
27
45
|
type: "resource-search",
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
46
|
+
id: "search-tickets",
|
|
47
|
+
title: "Search Tickets",
|
|
48
|
+
schemaGet: (api) => api.getTickets.responseBody,
|
|
49
|
+
breadcrumbs: () => [{ label: "Tickets", to: "/r/ticket/search" }],
|
|
50
|
+
onLoad: async ({ context }) => {
|
|
51
|
+
return context.http.getTickets({
|
|
52
|
+
params: {},
|
|
53
|
+
});
|
|
54
|
+
},
|
|
31
55
|
},
|
|
32
56
|
});
|
package/templates/root/README.md
CHANGED
|
@@ -49,10 +49,10 @@ The application will be available at:
|
|
|
49
49
|
|
|
50
50
|
```
|
|
51
51
|
__PROJECT_NAME__/
|
|
52
|
-
├──
|
|
53
|
-
├──
|
|
54
|
-
├──
|
|
55
|
-
└── package.json
|
|
52
|
+
├── schema/ # Shared API schemas and types
|
|
53
|
+
├── backend/ # Node.js backend (Hono + PostgreSQL)
|
|
54
|
+
├── frontend/ # React frontend (Vite + Nubase)
|
|
55
|
+
└── package.json # Root workspace configuration
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
## Available Scripts
|
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
7
|
+
"schema",
|
|
8
|
+
"backend",
|
|
9
|
+
"frontend"
|
|
10
10
|
],
|
|
11
11
|
"scripts": {
|
|
12
12
|
"dev": "turbo run dev",
|
|
13
13
|
"build": "turbo run build",
|
|
14
|
-
"db:up": "cd
|
|
15
|
-
"db:down": "cd
|
|
16
|
-
"db:kill": "cd
|
|
17
|
-
"db:seed": "cd
|
|
14
|
+
"db:up": "cd backend && npm run db:dev:up",
|
|
15
|
+
"db:down": "cd backend && npm run db:dev:down",
|
|
16
|
+
"db:kill": "cd backend && npm run db:dev:kill",
|
|
17
|
+
"db:seed": "cd backend && npm run db:seed",
|
|
18
18
|
"typecheck": "turbo run typecheck",
|
|
19
19
|
"lint": "turbo run lint",
|
|
20
20
|
"lint:fix": "turbo run lint:fix"
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"typescript": "^5.7.2"
|
|
26
26
|
},
|
|
27
27
|
"engines": {
|
|
28
|
-
"node": ">=18"
|
|
29
|
-
|
|
28
|
+
"node": ">=18",
|
|
29
|
+
"npm": ">=10.9.2"
|
|
30
|
+
},
|
|
31
|
+
"packageManager": "npm@10.9.2"
|
|
30
32
|
}
|
|
@@ -1,89 +1,144 @@
|
|
|
1
|
-
import { type RequestSchema
|
|
2
|
-
|
|
3
|
-
export const workspaceSchema = nu.object({
|
|
4
|
-
id: nu.number(),
|
|
5
|
-
slug: nu.string(),
|
|
6
|
-
name: nu.string(),
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
export type Workspace = (typeof workspaceSchema)["_output"];
|
|
1
|
+
import { emptySchema, nu, type RequestSchema } from "@nubase/core";
|
|
10
2
|
|
|
3
|
+
/**
|
|
4
|
+
* User schema for authenticated user data
|
|
5
|
+
*/
|
|
11
6
|
export const userSchema = nu.object({
|
|
12
7
|
id: nu.number(),
|
|
13
8
|
email: nu.string(),
|
|
14
9
|
username: nu.string(),
|
|
15
10
|
});
|
|
16
11
|
|
|
17
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Workspace info returned during login
|
|
14
|
+
*/
|
|
15
|
+
export const workspaceInfoSchema = nu.object({
|
|
16
|
+
id: nu.number(),
|
|
17
|
+
slug: nu.string(),
|
|
18
|
+
name: nu.string(),
|
|
19
|
+
});
|
|
18
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Login start request schema - Step 1 of two-step auth
|
|
23
|
+
* POST /auth/login/start
|
|
24
|
+
*
|
|
25
|
+
* Validates credentials and returns list of workspaces the user belongs to.
|
|
26
|
+
* If user has multiple workspaces, frontend should show selection.
|
|
27
|
+
* If user has one workspace, frontend can auto-complete login.
|
|
28
|
+
*/
|
|
19
29
|
export const loginStartSchema = {
|
|
20
|
-
method: "POST",
|
|
30
|
+
method: "POST" as const,
|
|
21
31
|
path: "/auth/login/start",
|
|
32
|
+
requestParams: emptySchema,
|
|
22
33
|
requestBody: nu.object({
|
|
23
|
-
|
|
34
|
+
username: nu.string(),
|
|
24
35
|
password: nu.string(),
|
|
25
36
|
}),
|
|
26
37
|
responseBody: nu.object({
|
|
27
|
-
|
|
38
|
+
/** Temporary token for completing login (short-lived) */
|
|
39
|
+
loginToken: nu.string(),
|
|
40
|
+
/** User's username (for display) */
|
|
41
|
+
username: nu.string(),
|
|
42
|
+
/** List of workspaces the user belongs to */
|
|
43
|
+
workspaces: nu.array(workspaceInfoSchema),
|
|
28
44
|
}),
|
|
29
45
|
} satisfies RequestSchema;
|
|
30
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Login complete request schema - Step 2 of two-step auth
|
|
49
|
+
* POST /auth/login/complete
|
|
50
|
+
*
|
|
51
|
+
* Completes login by selecting a workspace and issuing the full auth token.
|
|
52
|
+
*/
|
|
31
53
|
export const loginCompleteSchema = {
|
|
32
|
-
method: "POST",
|
|
54
|
+
method: "POST" as const,
|
|
33
55
|
path: "/auth/login/complete",
|
|
56
|
+
requestParams: emptySchema,
|
|
34
57
|
requestBody: nu.object({
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
58
|
+
/** Temporary login token from login/start */
|
|
59
|
+
loginToken: nu.string(),
|
|
60
|
+
/** Selected workspace slug */
|
|
61
|
+
workspace: nu.string(),
|
|
38
62
|
}),
|
|
39
63
|
responseBody: nu.object({
|
|
40
|
-
token: nu.string(),
|
|
41
64
|
user: userSchema,
|
|
42
|
-
workspace:
|
|
65
|
+
workspace: workspaceInfoSchema,
|
|
43
66
|
}),
|
|
44
67
|
} satisfies RequestSchema;
|
|
45
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Legacy login request schema (kept for backwards compatibility)
|
|
71
|
+
* POST /auth/login
|
|
72
|
+
*
|
|
73
|
+
* For path-based multi-workspace, the workspace slug is required in the request body.
|
|
74
|
+
* @deprecated Use loginStartSchema and loginCompleteSchema for two-step flow
|
|
75
|
+
*/
|
|
46
76
|
export const loginSchema = {
|
|
47
|
-
method: "POST",
|
|
77
|
+
method: "POST" as const,
|
|
48
78
|
path: "/auth/login",
|
|
79
|
+
requestParams: emptySchema,
|
|
49
80
|
requestBody: nu.object({
|
|
50
|
-
|
|
81
|
+
username: nu.string(),
|
|
51
82
|
password: nu.string(),
|
|
83
|
+
/** Workspace slug for path-based multi-workspace */
|
|
84
|
+
workspace: nu.string(),
|
|
52
85
|
}),
|
|
53
86
|
responseBody: nu.object({
|
|
54
|
-
token: nu.string(),
|
|
55
87
|
user: userSchema,
|
|
56
|
-
workspace: workspaceSchema,
|
|
57
88
|
}),
|
|
58
89
|
} satisfies RequestSchema;
|
|
59
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Logout request schema
|
|
93
|
+
* POST /auth/logout
|
|
94
|
+
*/
|
|
60
95
|
export const logoutSchema = {
|
|
61
|
-
method: "POST",
|
|
96
|
+
method: "POST" as const,
|
|
62
97
|
path: "/auth/logout",
|
|
63
|
-
|
|
98
|
+
requestParams: emptySchema,
|
|
99
|
+
responseBody: nu.object({
|
|
100
|
+
success: nu.boolean(),
|
|
101
|
+
}),
|
|
64
102
|
} satisfies RequestSchema;
|
|
65
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Get current user schema
|
|
106
|
+
* GET /auth/me
|
|
107
|
+
*/
|
|
66
108
|
export const getMeSchema = {
|
|
67
|
-
method: "GET",
|
|
109
|
+
method: "GET" as const,
|
|
68
110
|
path: "/auth/me",
|
|
111
|
+
requestParams: emptySchema,
|
|
69
112
|
responseBody: nu.object({
|
|
70
|
-
user: userSchema,
|
|
71
|
-
workspace: workspaceSchema,
|
|
113
|
+
user: userSchema.optional(),
|
|
72
114
|
}),
|
|
73
115
|
} satisfies RequestSchema;
|
|
74
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Signup request schema
|
|
119
|
+
* POST /auth/signup
|
|
120
|
+
*
|
|
121
|
+
* Creates a new workspace and the initial admin user.
|
|
122
|
+
* After successful signup, the user is automatically logged in.
|
|
123
|
+
*/
|
|
75
124
|
export const signupSchema = {
|
|
76
|
-
method: "POST",
|
|
125
|
+
method: "POST" as const,
|
|
77
126
|
path: "/auth/signup",
|
|
127
|
+
requestParams: emptySchema,
|
|
78
128
|
requestBody: nu.object({
|
|
79
|
-
|
|
129
|
+
/** Workspace slug (unique identifier) */
|
|
130
|
+
workspace: nu.string(),
|
|
131
|
+
/** Display name for the workspace */
|
|
132
|
+
workspaceName: nu.string(),
|
|
133
|
+
/** Username for the admin user */
|
|
80
134
|
username: nu.string(),
|
|
135
|
+
/** Email for the admin user */
|
|
136
|
+
email: nu.string(),
|
|
137
|
+
/** Password for the admin user */
|
|
81
138
|
password: nu.string(),
|
|
82
|
-
workspaceName: nu.string().optional(),
|
|
83
139
|
}),
|
|
84
140
|
responseBody: nu.object({
|
|
85
|
-
token: nu.string(),
|
|
86
141
|
user: userSchema,
|
|
87
|
-
workspace:
|
|
142
|
+
workspace: workspaceInfoSchema,
|
|
88
143
|
}),
|
|
89
144
|
} satisfies RequestSchema;
|
|
@@ -1,68 +1,71 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
emptySchema,
|
|
3
|
+
idNumberSchema,
|
|
4
|
+
nu,
|
|
5
|
+
type RequestSchema,
|
|
6
|
+
successSchema,
|
|
7
|
+
} from "@nubase/core";
|
|
2
8
|
|
|
3
9
|
export const ticketBaseSchema = nu
|
|
4
10
|
.object({
|
|
5
11
|
id: nu.number(),
|
|
6
12
|
title: nu.string().withMeta({
|
|
7
13
|
label: "Title",
|
|
8
|
-
|
|
14
|
+
description: "Enter the title of the ticket",
|
|
9
15
|
}),
|
|
10
16
|
description: nu.string().optional().withMeta({
|
|
11
17
|
label: "Description",
|
|
12
|
-
|
|
18
|
+
description: "Enter the description of the ticket",
|
|
13
19
|
renderer: "multiline",
|
|
14
20
|
}),
|
|
15
|
-
createdAt: nu.string().optional().withMeta({
|
|
16
|
-
label: "Created At",
|
|
17
|
-
}),
|
|
18
|
-
updatedAt: nu.string().optional().withMeta({
|
|
19
|
-
label: "Updated At",
|
|
20
|
-
}),
|
|
21
21
|
})
|
|
22
22
|
.withId("id")
|
|
23
23
|
.withTableLayouts({
|
|
24
24
|
default: {
|
|
25
|
-
fields: [
|
|
25
|
+
fields: [
|
|
26
|
+
{ name: "id", columnWidthPx: 80, pinned: true },
|
|
27
|
+
{ name: "title", columnWidthPx: 300, pinned: true },
|
|
28
|
+
{ name: "description", columnWidthPx: 400 },
|
|
29
|
+
],
|
|
26
30
|
metadata: {
|
|
27
31
|
linkFields: ["title"],
|
|
28
32
|
},
|
|
29
33
|
},
|
|
30
34
|
});
|
|
31
35
|
|
|
32
|
-
export type Ticket = (typeof ticketBaseSchema)["_output"];
|
|
33
|
-
|
|
34
36
|
export const getTicketsSchema = {
|
|
35
|
-
method: "GET",
|
|
37
|
+
method: "GET" as const,
|
|
36
38
|
path: "/tickets",
|
|
37
|
-
requestParams: ticketBaseSchema.omit("id"
|
|
39
|
+
requestParams: ticketBaseSchema.omit("id").partial(),
|
|
38
40
|
responseBody: nu.array(ticketBaseSchema),
|
|
39
41
|
} satisfies RequestSchema;
|
|
40
42
|
|
|
41
43
|
export const getTicketSchema = {
|
|
42
|
-
method: "GET",
|
|
44
|
+
method: "GET" as const,
|
|
43
45
|
path: "/tickets/:id",
|
|
44
|
-
|
|
46
|
+
requestParams: idNumberSchema,
|
|
45
47
|
responseBody: ticketBaseSchema,
|
|
46
48
|
} satisfies RequestSchema;
|
|
47
49
|
|
|
48
50
|
export const postTicketSchema = {
|
|
49
|
-
method: "POST",
|
|
51
|
+
method: "POST" as const,
|
|
50
52
|
path: "/tickets",
|
|
51
|
-
|
|
53
|
+
requestParams: emptySchema,
|
|
54
|
+
requestBody: ticketBaseSchema.omit("id"),
|
|
52
55
|
responseBody: ticketBaseSchema,
|
|
53
56
|
} satisfies RequestSchema;
|
|
54
57
|
|
|
55
58
|
export const patchTicketSchema = {
|
|
56
|
-
method: "PATCH",
|
|
59
|
+
method: "PATCH" as const,
|
|
57
60
|
path: "/tickets/:id",
|
|
58
|
-
|
|
59
|
-
requestBody: ticketBaseSchema.omit("id"
|
|
61
|
+
requestParams: idNumberSchema,
|
|
62
|
+
requestBody: ticketBaseSchema.omit("id").partial(),
|
|
60
63
|
responseBody: ticketBaseSchema,
|
|
61
64
|
} satisfies RequestSchema;
|
|
62
65
|
|
|
63
66
|
export const deleteTicketSchema = {
|
|
64
|
-
method: "DELETE",
|
|
67
|
+
method: "DELETE" as const,
|
|
65
68
|
path: "/tickets/:id",
|
|
66
|
-
|
|
67
|
-
responseBody:
|
|
69
|
+
requestParams: idNumberSchema,
|
|
70
|
+
responseBody: successSchema,
|
|
68
71
|
} satisfies RequestSchema;
|