umpordez 0.0.2 → 1.0.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.
Files changed (151) hide show
  1. package/.claude/settings.local.json +16 -0
  2. package/build.mjs +409 -0
  3. package/cli.mjs +87 -109
  4. package/create.mjs +334 -0
  5. package/package.json +2 -7
  6. package/template/.claude/settings.local.json +20 -0
  7. package/template/.editorconfig +11 -0
  8. package/template/.github/copilot-instructions.md +186 -0
  9. package/template/.nvimrc +7 -0
  10. package/template/.vscode/extensions.json +9 -0
  11. package/template/.vscode/settings.json +28 -0
  12. package/template/AGENTS.md +305 -0
  13. package/template/CLAUDE.md +140 -0
  14. package/template/architecture.md +518 -0
  15. package/template/code.md +211 -0
  16. package/template/dev.sh +45 -0
  17. package/template/install.sh +47 -0
  18. package/template/misc/nginx.conf +151 -0
  19. package/template/misc/systemd/{{PROJECT_SLUG}}-api.service +31 -0
  20. package/template/misc/systemd/{{PROJECT_SLUG}}-site-api.service +31 -0
  21. package/template/misc/systemd/{{PROJECT_SLUG}}-site.service +30 -0
  22. package/template/seed.sh +23 -0
  23. package/template/server/.env.sample +40 -0
  24. package/template/server/.prettierrc +4 -0
  25. package/template/server/apps/api/app.ts +42 -0
  26. package/template/server/apps/api/routes/account.ts +98 -0
  27. package/template/server/apps/api/routes/admin.ts +80 -0
  28. package/template/server/apps/api/routes/auth.ts +222 -0
  29. package/template/server/apps/api/routes/files.ts +89 -0
  30. package/template/server/apps/api/routes/index.ts +19 -0
  31. package/template/server/apps/api/routes/user.ts +17 -0
  32. package/template/server/apps/shared/middlewares/demand-account-access.ts +52 -0
  33. package/template/server/apps/shared/middlewares/demand-admin-user.ts +25 -0
  34. package/template/server/apps/shared/middlewares/demand-user.ts +17 -0
  35. package/template/server/apps/shared/middlewares/error-handler.ts +28 -0
  36. package/template/server/apps/shared/middlewares/request-logger.ts +40 -0
  37. package/template/server/apps/shared/middlewares/try-set-user-by-token.ts +26 -0
  38. package/template/server/apps/shared/utils.ts +71 -0
  39. package/template/server/apps/site-api/app.ts +42 -0
  40. package/template/server/apps/site-api/routes/index.ts +10 -0
  41. package/template/server/apps/site-api/routes/public.ts +25 -0
  42. package/template/server/console/index.ts +26 -0
  43. package/template/server/console/runner.ts +67 -0
  44. package/template/server/console/tasks/example.ts +29 -0
  45. package/template/server/console/tasks/seed-admin.ts +71 -0
  46. package/template/server/core/context.ts +49 -0
  47. package/template/server/core/db.ts +12 -0
  48. package/template/server/core/email.ts +87 -0
  49. package/template/server/core/errors.ts +44 -0
  50. package/template/server/core/knexfile.ts +28 -0
  51. package/template/server/core/logger.ts +60 -0
  52. package/template/server/core/models/account.ts +319 -0
  53. package/template/server/core/models/auth.ts +317 -0
  54. package/template/server/core/models/base.ts +19 -0
  55. package/template/server/core/models/user.ts +343 -0
  56. package/template/server/core/s3.ts +183 -0
  57. package/template/server/emails/_styles.css +5 -0
  58. package/template/server/emails/_template.html +28 -0
  59. package/template/server/emails/accountWelcome.html +4 -0
  60. package/template/server/emails/forgetPassword.html +5 -0
  61. package/template/server/eslint.config.js +16 -0
  62. package/template/server/knex.sh +4 -0
  63. package/template/server/migrations/20260208000000_initial-schema.ts +56 -0
  64. package/template/server/migrations/20260208000001_seed-admin.ts +42 -0
  65. package/template/server/migrations/20260208000002_add-user-avatar.ts +13 -0
  66. package/template/server/package.json +58 -0
  67. package/template/server/tsconfig.json +27 -0
  68. package/template/server/types/api.ts +20 -0
  69. package/template/server/types/express.d.ts +35 -0
  70. package/template/ui/admin/.prettierrc +4 -0
  71. package/template/ui/admin/components.json +20 -0
  72. package/template/ui/admin/eslint.config.js +16 -0
  73. package/template/ui/admin/index.html +12 -0
  74. package/template/ui/admin/package.json +57 -0
  75. package/template/ui/admin/postcss.config.js +6 -0
  76. package/template/ui/admin/src/app.tsx +13 -0
  77. package/template/ui/admin/src/components/nav-user.tsx +95 -0
  78. package/template/ui/admin/src/components/theme-provider.tsx +65 -0
  79. package/template/ui/admin/src/components/theme-toggle.tsx +17 -0
  80. package/template/ui/admin/src/components/ui/badge.tsx +36 -0
  81. package/template/ui/admin/src/components/ui/button.tsx +56 -0
  82. package/template/ui/admin/src/components/ui/calendar.tsx +68 -0
  83. package/template/ui/admin/src/components/ui/card.tsx +79 -0
  84. package/template/ui/admin/src/components/ui/checkbox.tsx +28 -0
  85. package/template/ui/admin/src/components/ui/date-picker.tsx +78 -0
  86. package/template/ui/admin/src/components/ui/date-range-picker.tsx +208 -0
  87. package/template/ui/admin/src/components/ui/dialog.tsx +121 -0
  88. package/template/ui/admin/src/components/ui/dropdown-menu.tsx +200 -0
  89. package/template/ui/admin/src/components/ui/input.tsx +22 -0
  90. package/template/ui/admin/src/components/ui/label.tsx +24 -0
  91. package/template/ui/admin/src/components/ui/popover.tsx +29 -0
  92. package/template/ui/admin/src/components/ui/select.tsx +158 -0
  93. package/template/ui/admin/src/components/ui/separator.tsx +29 -0
  94. package/template/ui/admin/src/components/ui/sheet.tsx +136 -0
  95. package/template/ui/admin/src/components/ui/sidebar.tsx +759 -0
  96. package/template/ui/admin/src/components/ui/skeleton.tsx +15 -0
  97. package/template/ui/admin/src/components/ui/sonner.tsx +26 -0
  98. package/template/ui/admin/src/components/ui/switch.tsx +27 -0
  99. package/template/ui/admin/src/components/ui/table.tsx +117 -0
  100. package/template/ui/admin/src/components/ui/tabs.tsx +53 -0
  101. package/template/ui/admin/src/components/ui/textarea.tsx +22 -0
  102. package/template/ui/admin/src/components/ui/tooltip.tsx +28 -0
  103. package/template/ui/admin/src/hooks/queries/use-accounts.ts +68 -0
  104. package/template/ui/admin/src/hooks/queries/use-auth.ts +61 -0
  105. package/template/ui/admin/src/hooks/queries/use-dashboard.ts +70 -0
  106. package/template/ui/admin/src/hooks/queries/use-members.ts +90 -0
  107. package/template/ui/admin/src/hooks/queries/use-users.ts +99 -0
  108. package/template/ui/admin/src/hooks/use-confirm.tsx +92 -0
  109. package/template/ui/admin/src/hooks/use-mobile.tsx +22 -0
  110. package/template/ui/admin/src/hooks/use-sidebar.tsx +4 -0
  111. package/template/ui/admin/src/hooks/use-user.tsx +86 -0
  112. package/template/ui/admin/src/index.css +22 -0
  113. package/template/ui/admin/src/layouts/account.tsx +278 -0
  114. package/template/ui/admin/src/layouts/admin.tsx +182 -0
  115. package/template/ui/admin/src/layouts/public.tsx +9 -0
  116. package/template/ui/admin/src/layouts/user.tsx +107 -0
  117. package/template/ui/admin/src/lib/config.ts +9 -0
  118. package/template/ui/admin/src/lib/fetch-api.ts +101 -0
  119. package/template/ui/admin/src/lib/query-client.ts +12 -0
  120. package/template/ui/admin/src/lib/query-keys.ts +44 -0
  121. package/template/ui/admin/src/lib/utils.ts +24 -0
  122. package/template/ui/admin/src/main.tsx +17 -0
  123. package/template/ui/admin/src/pages/account/dashboard.tsx +128 -0
  124. package/template/ui/admin/src/pages/account/members.tsx +414 -0
  125. package/template/ui/admin/src/pages/account/settings.tsx +210 -0
  126. package/template/ui/admin/src/pages/admin/accounts.tsx +164 -0
  127. package/template/ui/admin/src/pages/admin/dashboard.tsx +243 -0
  128. package/template/ui/admin/src/pages/admin/users.tsx +395 -0
  129. package/template/ui/admin/src/pages/public/error.tsx +24 -0
  130. package/template/ui/admin/src/pages/public/forgot-password.tsx +74 -0
  131. package/template/ui/admin/src/pages/public/login.tsx +102 -0
  132. package/template/ui/admin/src/pages/public/reset-password.tsx +106 -0
  133. package/template/ui/admin/src/pages/public/signup.tsx +118 -0
  134. package/template/ui/admin/src/pages/user/profile.tsx +313 -0
  135. package/template/ui/admin/src/pages/user/select-account.tsx +114 -0
  136. package/template/ui/admin/src/router.tsx +71 -0
  137. package/template/ui/admin/tailwind.config.ts +71 -0
  138. package/template/ui/admin/tsconfig.json +24 -0
  139. package/template/ui/admin/vite.config.ts +15 -0
  140. package/template/ui/site/.env.sample +3 -0
  141. package/template/ui/site/.prettierrc +4 -0
  142. package/template/ui/site/app.ts +50 -0
  143. package/template/ui/site/eslint.config.js +16 -0
  144. package/template/ui/site/package.json +33 -0
  145. package/template/ui/site/styles/input.css +3 -0
  146. package/template/ui/site/tailwind.config.js +8 -0
  147. package/template/ui/site/tsconfig.json +15 -0
  148. package/template/ui/site/views/pages/home.ejs +305 -0
  149. package/template/ui/site/views/partials/footer.ejs +44 -0
  150. package/template/ui/site/views/partials/header.ejs +83 -0
  151. package/template-variables.mjs +186 -0
@@ -0,0 +1,60 @@
1
+ import util from 'util';
2
+
3
+ const isTTYout = Boolean(process.stdout.isTTY);
4
+ const isTTYerr = Boolean(process.stderr.isTTY);
5
+
6
+ const colors = {
7
+ green: isTTYout ? '\x1b[32m' : '',
8
+ yellow: isTTYout ? '\x1b[33m' : '',
9
+ red: isTTYerr ? '\x1b[31m' : '',
10
+ cyan: isTTYout ? '\x1b[36m' : '',
11
+ reset: (isTTYout || isTTYerr) ? '\x1b[0m' : ''
12
+ };
13
+
14
+ function ts(): string {
15
+ return new Date().toISOString();
16
+ }
17
+
18
+ function fmt(data?: Record<string, unknown>): string {
19
+ if (!data) return '';
20
+ return ' ' + util.inspect(data, { depth: 4, colors: isTTYout });
21
+ }
22
+
23
+ const logger = {
24
+ info(message: string, data?: Record<string, unknown>): void {
25
+ console.log(
26
+ `${ts()} ~ ${colors.green}{info}${colors.reset} ${message}${fmt(data)}`
27
+ );
28
+ },
29
+
30
+ warn(message: string, data?: Record<string, unknown>): void {
31
+ console.warn(
32
+ `${ts()} ~ ${colors.yellow}{warn}${colors.reset} ${message}${fmt(data)}`
33
+ );
34
+ },
35
+
36
+ error(message: string | Error, data?: Record<string, unknown>): void {
37
+ if (message instanceof Error) {
38
+ console.error(
39
+ `${ts()} ~ ${colors.red}!error!${colors.reset} ${message.message}${fmt(data)}`
40
+ );
41
+ if (message.stack) {
42
+ console.error(message.stack);
43
+ }
44
+ return;
45
+ }
46
+ console.error(
47
+ `${ts()} ~ ${colors.red}!error!${colors.reset} ${message}${fmt(data)}`
48
+ );
49
+ },
50
+
51
+ debug(message: string, data?: Record<string, unknown>): void {
52
+ if (process.env.DEBUG) {
53
+ console.debug(
54
+ `${ts()} ~ ${colors.cyan}{debug}${colors.reset} ${message}${fmt(data)}`
55
+ );
56
+ }
57
+ }
58
+ };
59
+
60
+ export default logger;
@@ -0,0 +1,319 @@
1
+ import BaseModel from './base.js';
2
+ import logger from '#core/logger.js';
3
+ import { NotFoundError, ForbiddenError, ConflictError, ValidationError } from '#core/errors.js';
4
+
5
+ interface PaginatedResult<T> {
6
+ rows: T[];
7
+ total: number;
8
+ page: number;
9
+ limit: number;
10
+ totalPages: number;
11
+ }
12
+
13
+ class AccountModel extends BaseModel {
14
+ async list(options: {
15
+ page?: number;
16
+ limit?: number;
17
+ search?: string;
18
+ } = {}): Promise<PaginatedResult<{
19
+ id: string;
20
+ name: string;
21
+ status: string;
22
+ utc_created_on: string;
23
+ }>> {
24
+ const page = Math.max(1, options.page || 1);
25
+ const limit = Math.min(100, Math.max(1, options.limit || 20));
26
+ const offset = (page - 1) * limit;
27
+ const search = options.search?.trim();
28
+
29
+ const baseQuery = this.knex('accounts');
30
+ if (search) {
31
+ baseQuery.whereILike('name', `%${search}%`);
32
+ }
33
+
34
+ const [{ count }] = await baseQuery.clone().count('id as count');
35
+ const total = parseInt(String(count), 10);
36
+
37
+ const rows = await baseQuery.clone()
38
+ .select('id', 'name', 'status', 'utc_created_on')
39
+ .orderBy('utc_created_on', 'desc')
40
+ .limit(limit)
41
+ .offset(offset);
42
+
43
+ return {
44
+ rows,
45
+ total,
46
+ page,
47
+ limit,
48
+ totalPages: Math.ceil(total / limit)
49
+ };
50
+ }
51
+
52
+ async listPublic() {
53
+ return this.knex('accounts')
54
+ .select('id', 'name')
55
+ .where({ status: 'active' })
56
+ .limit(10);
57
+ }
58
+
59
+ async findById(id: string) {
60
+ const account = await this.knex('accounts')
61
+ .where({ id })
62
+ .first();
63
+
64
+ if (!account) {
65
+ throw new NotFoundError('Conta não encontrada');
66
+ }
67
+
68
+ return {
69
+ id: account.id,
70
+ name: account.name,
71
+ status: account.status,
72
+ settings: typeof account.settings === 'string'
73
+ ? JSON.parse(account.settings || '{}')
74
+ : (account.settings || {})
75
+ };
76
+ }
77
+
78
+ async update(id: string, data: { name?: string; settings?: Record<string, unknown> }) {
79
+ await this.findById(id);
80
+
81
+ const updateData: Record<string, unknown> = {};
82
+ if (data.name) updateData.name = data.name;
83
+ if (data.settings) updateData.settings = JSON.stringify(data.settings);
84
+
85
+ if (Object.keys(updateData).length > 0) {
86
+ await this.knex('accounts').where({ id }).update(updateData);
87
+ }
88
+
89
+ logger.info(`{account} updated account_id=${id}`);
90
+
91
+ return this.findById(id);
92
+ }
93
+
94
+ async delete(id: string, userId: string, userRole: string) {
95
+ if (userRole !== 'owner' && userRole !== 'admin') {
96
+ throw new ForbiddenError('Apenas proprietários podem excluir contas');
97
+ }
98
+
99
+ await this.findById(id);
100
+
101
+ // Delete related data first
102
+ await this.knex('user_in_accounts').where({ account_id: id }).delete();
103
+ await this.knex('accounts').where({ id }).delete();
104
+
105
+ logger.info(`{account} deleted account_id=${id} by user_id=${userId}`);
106
+ }
107
+
108
+ async listMembers(accountId: string, options: {
109
+ page?: number;
110
+ limit?: number;
111
+ search?: string;
112
+ } = {}): Promise<PaginatedResult<{
113
+ id: string;
114
+ name: string;
115
+ email: string;
116
+ role: string;
117
+ utc_created_on: string;
118
+ }>> {
119
+ await this.findById(accountId);
120
+
121
+ const page = Math.max(1, options.page || 1);
122
+ const limit = Math.min(100, Math.max(1, options.limit || 20));
123
+ const offset = (page - 1) * limit;
124
+ const search = options.search?.trim();
125
+
126
+ const baseQuery = this.knex('user_in_accounts as ua')
127
+ .join('users as u', 'u.id', 'ua.user_id')
128
+ .where('ua.account_id', accountId);
129
+
130
+ if (search) {
131
+ baseQuery.where(function () {
132
+ this.whereILike('u.name', `%${search}%`)
133
+ .orWhereILike('u.email', `%${search}%`);
134
+ });
135
+ }
136
+
137
+ const [{ count }] = await baseQuery.clone().count('ua.user_id as count');
138
+ const total = parseInt(String(count), 10);
139
+
140
+ const rows = await baseQuery.clone()
141
+ .select(
142
+ 'u.id',
143
+ 'u.name',
144
+ 'u.email',
145
+ 'ua.role',
146
+ 'ua.utc_created_on'
147
+ )
148
+ .orderBy('ua.utc_created_on', 'asc')
149
+ .limit(limit)
150
+ .offset(offset);
151
+
152
+ return {
153
+ rows,
154
+ total,
155
+ page,
156
+ limit,
157
+ totalPages: Math.ceil(total / limit)
158
+ };
159
+ }
160
+
161
+ async addMember(
162
+ accountId: string,
163
+ data: { email: string; role: string; name?: string; password?: string }
164
+ ) {
165
+ await this.findById(accountId);
166
+
167
+ const email = data.email.toLowerCase().trim();
168
+ let user = await this.knex('users').where({ email }).first();
169
+
170
+ if (!user) {
171
+ if (!data.password || data.password.length < 6) {
172
+ throw new ValidationError(
173
+ 'Senha é obrigatória (mínimo 6 caracteres) para novos usuários'
174
+ );
175
+ }
176
+ if (!data.name) {
177
+ throw new ValidationError('Nome é obrigatório para novos usuários');
178
+ }
179
+
180
+ user = await this.context.user.create({
181
+ email,
182
+ password: data.password,
183
+ name: data.name,
184
+ role: 'user',
185
+ skipAccountCreation: true
186
+ });
187
+ }
188
+
189
+ const existing = await this.knex('user_in_accounts')
190
+ .where({ user_id: user.id, account_id: accountId })
191
+ .first();
192
+
193
+ if (existing) {
194
+ throw new ConflictError('Este usuário já faz parte desta conta');
195
+ }
196
+
197
+ await this.knex('user_in_accounts').insert({
198
+ user_id: user.id,
199
+ account_id: accountId,
200
+ role: data.role || 'user'
201
+ });
202
+
203
+ logger.info(`{account} added member user_id=${user.id} to account_id=${accountId} role=${data.role}`);
204
+
205
+ return {
206
+ id: user.id,
207
+ name: user.name,
208
+ email: user.email,
209
+ role: data.role || 'user'
210
+ };
211
+ }
212
+
213
+ async updateMemberRole(accountId: string, userId: string, role: string) {
214
+ const member = await this.knex('user_in_accounts')
215
+ .where({ user_id: userId, account_id: accountId })
216
+ .first();
217
+
218
+ if (!member) {
219
+ throw new NotFoundError('Membro não encontrado nesta conta');
220
+ }
221
+
222
+ await this.knex('user_in_accounts')
223
+ .where({ user_id: userId, account_id: accountId })
224
+ .update({ role });
225
+
226
+ logger.info(`{account} updated member role user_id=${userId} account_id=${accountId} role=${role}`);
227
+ }
228
+
229
+ async removeMember(accountId: string, userId: string) {
230
+ const member = await this.knex('user_in_accounts')
231
+ .where({ user_id: userId, account_id: accountId })
232
+ .first();
233
+
234
+ if (!member) {
235
+ throw new NotFoundError('Membro não encontrado nesta conta');
236
+ }
237
+
238
+ if (member.role === 'owner') {
239
+ throw new ForbiddenError('Não é possível remover o proprietário da conta');
240
+ }
241
+
242
+ await this.knex('user_in_accounts')
243
+ .where({ user_id: userId, account_id: accountId })
244
+ .delete();
245
+
246
+ logger.info(`{account} removed member user_id=${userId} from account_id=${accountId}`);
247
+ }
248
+
249
+ async getAccountDashboard(accountId: string) {
250
+ const [membersCount] = await this.knex('user_in_accounts')
251
+ .where({ account_id: accountId })
252
+ .count('user_id as count');
253
+
254
+ const membersByDay = await this.knex.raw(`
255
+ SELECT
256
+ d::date as date,
257
+ COUNT(ua.user_id)::int as count
258
+ FROM generate_series(
259
+ CURRENT_DATE - INTERVAL '13 days',
260
+ CURRENT_DATE,
261
+ '1 day'
262
+ ) d
263
+ LEFT JOIN user_in_accounts ua
264
+ ON ua.utc_created_on::date = d::date
265
+ AND ua.account_id = ?
266
+ GROUP BY d::date
267
+ ORDER BY d::date ASC
268
+ `, [accountId]);
269
+
270
+ return {
271
+ stats: {
272
+ members: parseInt(String(membersCount.count), 10)
273
+ },
274
+ membersByDay: membersByDay.rows
275
+ };
276
+ }
277
+
278
+ async getDashboardStats() {
279
+ const [usersCount] = await this.knex('users').count('id as count');
280
+ const [accountsCount] = await this.knex('accounts').count('id as count');
281
+
282
+ const recentUsers = await this.knex('users')
283
+ .select('id', 'name', 'email', 'role', 'utc_created_on')
284
+ .orderBy('utc_created_on', 'desc')
285
+ .limit(5);
286
+
287
+ const recentAccounts = await this.knex('accounts')
288
+ .select('id', 'name', 'status', 'utc_created_on')
289
+ .orderBy('utc_created_on', 'desc')
290
+ .limit(5);
291
+
292
+ // Users created per day (last 14 days)
293
+ const usersByDay = await this.knex.raw(`
294
+ SELECT
295
+ d::date as date,
296
+ COUNT(u.id)::int as count
297
+ FROM generate_series(
298
+ CURRENT_DATE - INTERVAL '13 days',
299
+ CURRENT_DATE,
300
+ '1 day'
301
+ ) d
302
+ LEFT JOIN users u ON u.utc_created_on::date = d::date
303
+ GROUP BY d::date
304
+ ORDER BY d::date ASC
305
+ `);
306
+
307
+ return {
308
+ stats: {
309
+ users: parseInt(String(usersCount.count), 10),
310
+ accounts: parseInt(String(accountsCount.count), 10)
311
+ },
312
+ recentUsers,
313
+ recentAccounts,
314
+ usersByDay: usersByDay.rows
315
+ };
316
+ }
317
+ }
318
+
319
+ export default AccountModel;
@@ -0,0 +1,317 @@
1
+ import bcrypt from 'bcrypt';
2
+ import crypto from 'crypto';
3
+ import jwt from 'jsonwebtoken';
4
+ import BaseModel from './base.js';
5
+ import { sendEmail, isEmailConfigured } from '#core/email.js';
6
+ import logger from '#core/logger.js';
7
+ import {
8
+ ValidationError,
9
+ UnauthorizedError,
10
+ NotFoundError
11
+ } from '#core/errors.js';
12
+
13
+ export interface User {
14
+ id: string;
15
+ email: string;
16
+ name: string;
17
+ role: string;
18
+ utc_created_on: string;
19
+ }
20
+
21
+ export interface LoginResult {
22
+ user: User;
23
+ token: string;
24
+ }
25
+
26
+ export interface SignupResult {
27
+ user: User;
28
+ account: { id: string; name: string; status: string };
29
+ token: string;
30
+ existingUser?: false;
31
+ }
32
+
33
+ export interface SignupExistingUserResult {
34
+ existingUser: true;
35
+ account: { id: string; name: string; status: string };
36
+ }
37
+
38
+ class AuthModel extends BaseModel {
39
+ generateToken(userId: string): string {
40
+ return jwt.sign(
41
+ { user_id: userId },
42
+ process.env.JWT_TOKEN_SECRET as string,
43
+ { expiresIn: '30d' }
44
+ );
45
+ }
46
+
47
+ verifyToken(token: string): { user_id: string } {
48
+ return jwt.verify(
49
+ token,
50
+ process.env.JWT_TOKEN_SECRET as string
51
+ ) as { user_id: string };
52
+ }
53
+
54
+ async login(email: string, password: string): Promise<LoginResult> {
55
+ if (!email || !password) {
56
+ throw new ValidationError('Email e senha são obrigatórios');
57
+ }
58
+
59
+ const user = await this.knex('users')
60
+ .where({ email: email.toLowerCase().trim() })
61
+ .first();
62
+
63
+ if (!user) {
64
+ throw new UnauthorizedError('Email ou senha incorretos');
65
+ }
66
+
67
+ const isValid = await bcrypt.compare(password, user.password_hash);
68
+ if (!isValid) {
69
+ throw new UnauthorizedError('Email ou senha incorretos');
70
+ }
71
+
72
+ const token = this.generateToken(user.id);
73
+
74
+ logger.info(`{auth} login for ${user.email}`);
75
+
76
+ return {
77
+ user: {
78
+ id: user.id,
79
+ email: user.email,
80
+ name: user.name,
81
+ role: user.role,
82
+ utc_created_on: user.utc_created_on
83
+ },
84
+ token
85
+ };
86
+ }
87
+
88
+ async signup(data: {
89
+ email: string;
90
+ password: string;
91
+ name: string;
92
+ accountName?: string;
93
+ }): Promise<SignupResult | SignupExistingUserResult> {
94
+ if (!data.email || !data.password) {
95
+ throw new ValidationError('Email e senha são obrigatórios');
96
+ }
97
+ if (!data.name) {
98
+ throw new ValidationError('Nome é obrigatório');
99
+ }
100
+ if (data.password.length < 6) {
101
+ throw new ValidationError(
102
+ 'A senha deve ter pelo menos 6 caracteres'
103
+ );
104
+ }
105
+
106
+ const email = data.email.toLowerCase().trim();
107
+ const existingUser = await this.knex('users').where({ email }).first();
108
+
109
+ if (existingUser) {
110
+ const [account] = await this.knex('accounts')
111
+ .insert({
112
+ name: data.accountName || data.name,
113
+ status: 'active',
114
+ settings: JSON.stringify({})
115
+ })
116
+ .returning('*');
117
+
118
+ await this.knex('user_in_accounts').insert({
119
+ user_id: existingUser.id,
120
+ account_id: account.id,
121
+ role: 'owner'
122
+ });
123
+
124
+ logger.info(`{auth} signup existing user=${email}, new account=${account.name}`);
125
+
126
+ return {
127
+ existingUser: true,
128
+ account: {
129
+ id: account.id,
130
+ name: account.name,
131
+ status: account.status
132
+ }
133
+ };
134
+ }
135
+
136
+ const user = await this.context.user.create({
137
+ email: data.email,
138
+ password: data.password,
139
+ name: data.name,
140
+ role: 'user',
141
+ accountName: data.accountName
142
+ });
143
+
144
+ const account = await this.knex('user_in_accounts as ua')
145
+ .join('accounts as a', 'a.id', 'ua.account_id')
146
+ .where('ua.user_id', user.id)
147
+ .select('a.id', 'a.name', 'a.status')
148
+ .first();
149
+
150
+ const token = this.generateToken(user.id);
151
+
152
+ logger.info(`{auth} signup for ${user.email}, account=${account.name}`);
153
+
154
+ if (isEmailConfigured()) {
155
+ try {
156
+ await sendEmail(user.email, 'Bem-vindo!', 'accountWelcome', {
157
+ user: { name: user.name || user.email }
158
+ });
159
+ } catch (emailError) {
160
+ logger.error('Failed to send welcome email', {
161
+ error: emailError instanceof Error
162
+ ? emailError.message
163
+ : String(emailError)
164
+ });
165
+ }
166
+ }
167
+
168
+ return {
169
+ user,
170
+ account: {
171
+ id: account.id,
172
+ name: account.name,
173
+ status: account.status
174
+ },
175
+ token
176
+ };
177
+ }
178
+
179
+ async forgotPassword(email: string): Promise<void> {
180
+ if (!email) {
181
+ throw new ValidationError('Email é obrigatório');
182
+ }
183
+
184
+ const user = await this.knex('users')
185
+ .select('id', 'email', 'name')
186
+ .where({ email: email.toLowerCase().trim() })
187
+ .first();
188
+
189
+ if (!user) {
190
+ logger.info(`{auth} forgot-password for unknown email: ${email}`);
191
+ return;
192
+ }
193
+
194
+ const token = crypto.randomBytes(32).toString('hex');
195
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
196
+
197
+ await this.knex('password_reset_tokens')
198
+ .where({ user_id: user.id, used: false })
199
+ .update({ used: true });
200
+
201
+ await this.knex('password_reset_tokens').insert({
202
+ user_id: user.id,
203
+ token,
204
+ expires_at: expiresAt
205
+ });
206
+
207
+ const baseUiUrl = process.env.BASE_UI_URL || 'http://localhost:{{ADMIN_UI_PORT}}';
208
+ const recoverPasswordUrl = `${baseUiUrl}/recover/${token}`;
209
+
210
+ if (!isEmailConfigured()) {
211
+ logger.warn(
212
+ '{auth} email not configured, skipping forgot-password email',
213
+ { recoverPasswordUrl }
214
+ );
215
+ return;
216
+ }
217
+
218
+ logger.info(`{auth} sending forgot-password email to ${user.email}`);
219
+
220
+ await sendEmail(user.email, 'Recupere sua senha', 'forgetPassword', {
221
+ user: { name: user.name || user.email },
222
+ recoverPasswordUrl
223
+ });
224
+ }
225
+
226
+ async validateResetToken(token: string): Promise<boolean> {
227
+ const resetToken = await this.knex('password_reset_tokens')
228
+ .where({ token, used: false })
229
+ .where('expires_at', '>', new Date())
230
+ .first();
231
+
232
+ return !!resetToken;
233
+ }
234
+
235
+ async resetPassword(token: string, password: string): Promise<void> {
236
+ if (!token || !password || password.length < 6) {
237
+ throw new ValidationError(
238
+ 'Token e senha válida são obrigatórios'
239
+ );
240
+ }
241
+
242
+ const resetToken = await this.knex('password_reset_tokens')
243
+ .where({ token, used: false })
244
+ .where('expires_at', '>', new Date())
245
+ .first();
246
+
247
+ if (!resetToken) {
248
+ throw new NotFoundError('Token inválido ou expirado');
249
+ }
250
+
251
+ const passwordHash = await bcrypt.hash(password, 10);
252
+
253
+ await this.knex('users')
254
+ .where({ id: resetToken.user_id })
255
+ .update({ password_hash: passwordHash });
256
+
257
+ await this.knex('password_reset_tokens')
258
+ .where({ id: resetToken.id })
259
+ .update({ used: true });
260
+
261
+ logger.info(`{auth} password reset for user_id=${resetToken.user_id}`);
262
+ }
263
+
264
+ async changePassword(
265
+ userId: string,
266
+ currentPassword: string,
267
+ newPassword: string
268
+ ): Promise<void> {
269
+ if (!currentPassword || !newPassword) {
270
+ throw new ValidationError(
271
+ 'Senha atual e nova senha são obrigatórias'
272
+ );
273
+ }
274
+ if (newPassword.length < 6) {
275
+ throw new ValidationError(
276
+ 'A nova senha deve ter pelo menos 6 caracteres'
277
+ );
278
+ }
279
+
280
+ const user = await this.knex('users')
281
+ .select('id', 'password_hash')
282
+ .where({ id: userId })
283
+ .first();
284
+
285
+ if (!user?.password_hash) {
286
+ throw new NotFoundError('Usuário não encontrado');
287
+ }
288
+
289
+ const isValid = await bcrypt.compare(currentPassword, user.password_hash);
290
+ if (!isValid) {
291
+ throw new ValidationError('Senha atual incorreta');
292
+ }
293
+
294
+ const newHash = await bcrypt.hash(newPassword, 10);
295
+ await this.knex('users')
296
+ .where({ id: userId })
297
+ .update({ password_hash: newHash });
298
+
299
+ logger.info(`{auth} password changed for user_id=${userId}`);
300
+ }
301
+
302
+ async getUserByToken(token: string): Promise<User | null> {
303
+ try {
304
+ const decoded = this.verifyToken(token);
305
+ const user = await this.knex('users')
306
+ .select('id', 'email', 'name', 'role', 'utc_created_on')
307
+ .where({ id: decoded.user_id })
308
+ .first();
309
+
310
+ return user || null;
311
+ } catch {
312
+ return null;
313
+ }
314
+ }
315
+ }
316
+
317
+ export default AuthModel;
@@ -0,0 +1,19 @@
1
+ import type { Knex } from 'knex';
2
+ import Db from '#core/db.js';
3
+ import type Context from '#core/context.js';
4
+
5
+ const db = new Db();
6
+
7
+ class BaseModel {
8
+ knex: Knex;
9
+ db: Db;
10
+ context: Context;
11
+
12
+ constructor(context: Context) {
13
+ this.context = context;
14
+ this.db = db;
15
+ this.knex = db.knex;
16
+ }
17
+ }
18
+
19
+ export default BaseModel;