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.
- package/.claude/settings.local.json +16 -0
- package/build.mjs +409 -0
- package/cli.mjs +87 -109
- package/create.mjs +334 -0
- package/package.json +2 -7
- package/template/.claude/settings.local.json +20 -0
- package/template/.editorconfig +11 -0
- package/template/.github/copilot-instructions.md +186 -0
- package/template/.nvimrc +7 -0
- package/template/.vscode/extensions.json +9 -0
- package/template/.vscode/settings.json +28 -0
- package/template/AGENTS.md +305 -0
- package/template/CLAUDE.md +140 -0
- package/template/architecture.md +518 -0
- package/template/code.md +211 -0
- package/template/dev.sh +45 -0
- package/template/install.sh +47 -0
- package/template/misc/nginx.conf +151 -0
- package/template/misc/systemd/{{PROJECT_SLUG}}-api.service +31 -0
- package/template/misc/systemd/{{PROJECT_SLUG}}-site-api.service +31 -0
- package/template/misc/systemd/{{PROJECT_SLUG}}-site.service +30 -0
- package/template/seed.sh +23 -0
- package/template/server/.env.sample +40 -0
- package/template/server/.prettierrc +4 -0
- package/template/server/apps/api/app.ts +42 -0
- package/template/server/apps/api/routes/account.ts +98 -0
- package/template/server/apps/api/routes/admin.ts +80 -0
- package/template/server/apps/api/routes/auth.ts +222 -0
- package/template/server/apps/api/routes/files.ts +89 -0
- package/template/server/apps/api/routes/index.ts +19 -0
- package/template/server/apps/api/routes/user.ts +17 -0
- package/template/server/apps/shared/middlewares/demand-account-access.ts +52 -0
- package/template/server/apps/shared/middlewares/demand-admin-user.ts +25 -0
- package/template/server/apps/shared/middlewares/demand-user.ts +17 -0
- package/template/server/apps/shared/middlewares/error-handler.ts +28 -0
- package/template/server/apps/shared/middlewares/request-logger.ts +40 -0
- package/template/server/apps/shared/middlewares/try-set-user-by-token.ts +26 -0
- package/template/server/apps/shared/utils.ts +71 -0
- package/template/server/apps/site-api/app.ts +42 -0
- package/template/server/apps/site-api/routes/index.ts +10 -0
- package/template/server/apps/site-api/routes/public.ts +25 -0
- package/template/server/console/index.ts +26 -0
- package/template/server/console/runner.ts +67 -0
- package/template/server/console/tasks/example.ts +29 -0
- package/template/server/console/tasks/seed-admin.ts +71 -0
- package/template/server/core/context.ts +49 -0
- package/template/server/core/db.ts +12 -0
- package/template/server/core/email.ts +87 -0
- package/template/server/core/errors.ts +44 -0
- package/template/server/core/knexfile.ts +28 -0
- package/template/server/core/logger.ts +60 -0
- package/template/server/core/models/account.ts +319 -0
- package/template/server/core/models/auth.ts +317 -0
- package/template/server/core/models/base.ts +19 -0
- package/template/server/core/models/user.ts +343 -0
- package/template/server/core/s3.ts +183 -0
- package/template/server/emails/_styles.css +5 -0
- package/template/server/emails/_template.html +28 -0
- package/template/server/emails/accountWelcome.html +4 -0
- package/template/server/emails/forgetPassword.html +5 -0
- package/template/server/eslint.config.js +16 -0
- package/template/server/knex.sh +4 -0
- package/template/server/migrations/20260208000000_initial-schema.ts +56 -0
- package/template/server/migrations/20260208000001_seed-admin.ts +42 -0
- package/template/server/migrations/20260208000002_add-user-avatar.ts +13 -0
- package/template/server/package.json +58 -0
- package/template/server/tsconfig.json +27 -0
- package/template/server/types/api.ts +20 -0
- package/template/server/types/express.d.ts +35 -0
- package/template/ui/admin/.prettierrc +4 -0
- package/template/ui/admin/components.json +20 -0
- package/template/ui/admin/eslint.config.js +16 -0
- package/template/ui/admin/index.html +12 -0
- package/template/ui/admin/package.json +57 -0
- package/template/ui/admin/postcss.config.js +6 -0
- package/template/ui/admin/src/app.tsx +13 -0
- package/template/ui/admin/src/components/nav-user.tsx +95 -0
- package/template/ui/admin/src/components/theme-provider.tsx +65 -0
- package/template/ui/admin/src/components/theme-toggle.tsx +17 -0
- package/template/ui/admin/src/components/ui/badge.tsx +36 -0
- package/template/ui/admin/src/components/ui/button.tsx +56 -0
- package/template/ui/admin/src/components/ui/calendar.tsx +68 -0
- package/template/ui/admin/src/components/ui/card.tsx +79 -0
- package/template/ui/admin/src/components/ui/checkbox.tsx +28 -0
- package/template/ui/admin/src/components/ui/date-picker.tsx +78 -0
- package/template/ui/admin/src/components/ui/date-range-picker.tsx +208 -0
- package/template/ui/admin/src/components/ui/dialog.tsx +121 -0
- package/template/ui/admin/src/components/ui/dropdown-menu.tsx +200 -0
- package/template/ui/admin/src/components/ui/input.tsx +22 -0
- package/template/ui/admin/src/components/ui/label.tsx +24 -0
- package/template/ui/admin/src/components/ui/popover.tsx +29 -0
- package/template/ui/admin/src/components/ui/select.tsx +158 -0
- package/template/ui/admin/src/components/ui/separator.tsx +29 -0
- package/template/ui/admin/src/components/ui/sheet.tsx +136 -0
- package/template/ui/admin/src/components/ui/sidebar.tsx +759 -0
- package/template/ui/admin/src/components/ui/skeleton.tsx +15 -0
- package/template/ui/admin/src/components/ui/sonner.tsx +26 -0
- package/template/ui/admin/src/components/ui/switch.tsx +27 -0
- package/template/ui/admin/src/components/ui/table.tsx +117 -0
- package/template/ui/admin/src/components/ui/tabs.tsx +53 -0
- package/template/ui/admin/src/components/ui/textarea.tsx +22 -0
- package/template/ui/admin/src/components/ui/tooltip.tsx +28 -0
- package/template/ui/admin/src/hooks/queries/use-accounts.ts +68 -0
- package/template/ui/admin/src/hooks/queries/use-auth.ts +61 -0
- package/template/ui/admin/src/hooks/queries/use-dashboard.ts +70 -0
- package/template/ui/admin/src/hooks/queries/use-members.ts +90 -0
- package/template/ui/admin/src/hooks/queries/use-users.ts +99 -0
- package/template/ui/admin/src/hooks/use-confirm.tsx +92 -0
- package/template/ui/admin/src/hooks/use-mobile.tsx +22 -0
- package/template/ui/admin/src/hooks/use-sidebar.tsx +4 -0
- package/template/ui/admin/src/hooks/use-user.tsx +86 -0
- package/template/ui/admin/src/index.css +22 -0
- package/template/ui/admin/src/layouts/account.tsx +278 -0
- package/template/ui/admin/src/layouts/admin.tsx +182 -0
- package/template/ui/admin/src/layouts/public.tsx +9 -0
- package/template/ui/admin/src/layouts/user.tsx +107 -0
- package/template/ui/admin/src/lib/config.ts +9 -0
- package/template/ui/admin/src/lib/fetch-api.ts +101 -0
- package/template/ui/admin/src/lib/query-client.ts +12 -0
- package/template/ui/admin/src/lib/query-keys.ts +44 -0
- package/template/ui/admin/src/lib/utils.ts +24 -0
- package/template/ui/admin/src/main.tsx +17 -0
- package/template/ui/admin/src/pages/account/dashboard.tsx +128 -0
- package/template/ui/admin/src/pages/account/members.tsx +414 -0
- package/template/ui/admin/src/pages/account/settings.tsx +210 -0
- package/template/ui/admin/src/pages/admin/accounts.tsx +164 -0
- package/template/ui/admin/src/pages/admin/dashboard.tsx +243 -0
- package/template/ui/admin/src/pages/admin/users.tsx +395 -0
- package/template/ui/admin/src/pages/public/error.tsx +24 -0
- package/template/ui/admin/src/pages/public/forgot-password.tsx +74 -0
- package/template/ui/admin/src/pages/public/login.tsx +102 -0
- package/template/ui/admin/src/pages/public/reset-password.tsx +106 -0
- package/template/ui/admin/src/pages/public/signup.tsx +118 -0
- package/template/ui/admin/src/pages/user/profile.tsx +313 -0
- package/template/ui/admin/src/pages/user/select-account.tsx +114 -0
- package/template/ui/admin/src/router.tsx +71 -0
- package/template/ui/admin/tailwind.config.ts +71 -0
- package/template/ui/admin/tsconfig.json +24 -0
- package/template/ui/admin/vite.config.ts +15 -0
- package/template/ui/site/.env.sample +3 -0
- package/template/ui/site/.prettierrc +4 -0
- package/template/ui/site/app.ts +50 -0
- package/template/ui/site/eslint.config.js +16 -0
- package/template/ui/site/package.json +33 -0
- package/template/ui/site/styles/input.css +3 -0
- package/template/ui/site/tailwind.config.js +8 -0
- package/template/ui/site/tsconfig.json +15 -0
- package/template/ui/site/views/pages/home.ejs +305 -0
- package/template/ui/site/views/partials/footer.ejs +44 -0
- package/template/ui/site/views/partials/header.ejs +83 -0
- 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;
|