create-blitzpack 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/dist/index.js +452 -0
  2. package/package.json +57 -0
  3. package/template/.dockerignore +59 -0
  4. package/template/.github/workflows/ci.yml +157 -0
  5. package/template/.husky/pre-commit +1 -0
  6. package/template/.husky/pre-push +1 -0
  7. package/template/.lintstagedrc.cjs +4 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/.prettierrc +9 -0
  10. package/template/.vscode/settings.json +13 -0
  11. package/template/CLAUDE.md +175 -0
  12. package/template/CONTRIBUTING.md +32 -0
  13. package/template/Dockerfile +90 -0
  14. package/template/GETTING_STARTED.md +35 -0
  15. package/template/LICENSE +21 -0
  16. package/template/README.md +116 -0
  17. package/template/apps/api/.dockerignore +51 -0
  18. package/template/apps/api/.env.local.example +62 -0
  19. package/template/apps/api/emails/account-deleted-email.tsx +69 -0
  20. package/template/apps/api/emails/components/email-layout.tsx +154 -0
  21. package/template/apps/api/emails/config.ts +22 -0
  22. package/template/apps/api/emails/password-changed-email.tsx +88 -0
  23. package/template/apps/api/emails/password-reset-email.tsx +86 -0
  24. package/template/apps/api/emails/verification-email.tsx +85 -0
  25. package/template/apps/api/emails/welcome-email.tsx +70 -0
  26. package/template/apps/api/package.json +84 -0
  27. package/template/apps/api/prisma/migrations/20251012111439_init/migration.sql +13 -0
  28. package/template/apps/api/prisma/migrations/20251018162629_add_better_auth_fields/migration.sql +67 -0
  29. package/template/apps/api/prisma/migrations/20251019142208_add_user_role_enum/migration.sql +5 -0
  30. package/template/apps/api/prisma/migrations/20251019182151_user_auth/migration.sql +7 -0
  31. package/template/apps/api/prisma/migrations/20251019211416_faster_session_lookup/migration.sql +2 -0
  32. package/template/apps/api/prisma/migrations/20251119124337_add_upload_model/migration.sql +26 -0
  33. package/template/apps/api/prisma/migrations/20251120071241_add_scope_to_account/migration.sql +2 -0
  34. package/template/apps/api/prisma/migrations/20251120072608_add_oauth_token_expiration_fields/migration.sql +10 -0
  35. package/template/apps/api/prisma/migrations/20251120144705_add_audit_logs/migration.sql +29 -0
  36. package/template/apps/api/prisma/migrations/20251127123614_remove_impersonated_by/migration.sql +8 -0
  37. package/template/apps/api/prisma/migrations/20251127125630_remove_audit_logs/migration.sql +11 -0
  38. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  39. package/template/apps/api/prisma/schema.prisma +116 -0
  40. package/template/apps/api/prisma/seed.ts +159 -0
  41. package/template/apps/api/prisma.config.ts +14 -0
  42. package/template/apps/api/src/app.ts +377 -0
  43. package/template/apps/api/src/common/logger.service.ts +227 -0
  44. package/template/apps/api/src/config/env.ts +60 -0
  45. package/template/apps/api/src/config/rate-limit.ts +29 -0
  46. package/template/apps/api/src/hooks/auth.ts +122 -0
  47. package/template/apps/api/src/plugins/auth.ts +198 -0
  48. package/template/apps/api/src/plugins/database.ts +45 -0
  49. package/template/apps/api/src/plugins/logger.ts +33 -0
  50. package/template/apps/api/src/plugins/multipart.ts +16 -0
  51. package/template/apps/api/src/plugins/scalar.ts +20 -0
  52. package/template/apps/api/src/plugins/schedule.ts +52 -0
  53. package/template/apps/api/src/plugins/services.ts +66 -0
  54. package/template/apps/api/src/plugins/swagger.ts +56 -0
  55. package/template/apps/api/src/routes/accounts.ts +91 -0
  56. package/template/apps/api/src/routes/admin-sessions.ts +92 -0
  57. package/template/apps/api/src/routes/metrics.ts +71 -0
  58. package/template/apps/api/src/routes/password.ts +46 -0
  59. package/template/apps/api/src/routes/sessions.ts +53 -0
  60. package/template/apps/api/src/routes/stats.ts +38 -0
  61. package/template/apps/api/src/routes/uploads-serve.ts +27 -0
  62. package/template/apps/api/src/routes/uploads.ts +154 -0
  63. package/template/apps/api/src/routes/users.ts +114 -0
  64. package/template/apps/api/src/routes/verification.ts +90 -0
  65. package/template/apps/api/src/server.ts +34 -0
  66. package/template/apps/api/src/services/accounts.service.ts +125 -0
  67. package/template/apps/api/src/services/authorization.service.ts +162 -0
  68. package/template/apps/api/src/services/email.service.ts +170 -0
  69. package/template/apps/api/src/services/file-storage.service.ts +267 -0
  70. package/template/apps/api/src/services/metrics.service.ts +175 -0
  71. package/template/apps/api/src/services/password.service.ts +56 -0
  72. package/template/apps/api/src/services/sessions.service.spec.ts +134 -0
  73. package/template/apps/api/src/services/sessions.service.ts +276 -0
  74. package/template/apps/api/src/services/stats.service.ts +273 -0
  75. package/template/apps/api/src/services/uploads.service.ts +163 -0
  76. package/template/apps/api/src/services/users.service.spec.ts +249 -0
  77. package/template/apps/api/src/services/users.service.ts +198 -0
  78. package/template/apps/api/src/utils/file-validation.ts +108 -0
  79. package/template/apps/api/start.sh +33 -0
  80. package/template/apps/api/test/helpers/fastify-app.ts +24 -0
  81. package/template/apps/api/test/helpers/mock-authorization.ts +16 -0
  82. package/template/apps/api/test/helpers/mock-logger.ts +28 -0
  83. package/template/apps/api/test/helpers/mock-prisma.ts +30 -0
  84. package/template/apps/api/test/helpers/test-db.ts +125 -0
  85. package/template/apps/api/test/integration/auth-flow.integration.spec.ts +449 -0
  86. package/template/apps/api/test/integration/password.integration.spec.ts +427 -0
  87. package/template/apps/api/test/integration/rate-limit.integration.spec.ts +51 -0
  88. package/template/apps/api/test/integration/sessions.integration.spec.ts +445 -0
  89. package/template/apps/api/test/integration/users.integration.spec.ts +211 -0
  90. package/template/apps/api/test/setup.ts +31 -0
  91. package/template/apps/api/tsconfig.json +26 -0
  92. package/template/apps/api/vitest.config.ts +35 -0
  93. package/template/apps/web/.env.local.example +11 -0
  94. package/template/apps/web/components.json +24 -0
  95. package/template/apps/web/next.config.ts +22 -0
  96. package/template/apps/web/package.json +56 -0
  97. package/template/apps/web/postcss.config.js +5 -0
  98. package/template/apps/web/public/apple-icon.png +0 -0
  99. package/template/apps/web/public/icon.png +0 -0
  100. package/template/apps/web/public/robots.txt +3 -0
  101. package/template/apps/web/src/app/(admin)/admin/layout.tsx +222 -0
  102. package/template/apps/web/src/app/(admin)/admin/page.tsx +157 -0
  103. package/template/apps/web/src/app/(admin)/admin/sessions/page.tsx +18 -0
  104. package/template/apps/web/src/app/(admin)/admin/users/page.tsx +20 -0
  105. package/template/apps/web/src/app/(auth)/forgot-password/page.tsx +177 -0
  106. package/template/apps/web/src/app/(auth)/login/page.tsx +159 -0
  107. package/template/apps/web/src/app/(auth)/reset-password/page.tsx +245 -0
  108. package/template/apps/web/src/app/(auth)/signup/page.tsx +153 -0
  109. package/template/apps/web/src/app/dashboard/change-password/page.tsx +255 -0
  110. package/template/apps/web/src/app/dashboard/page.tsx +296 -0
  111. package/template/apps/web/src/app/error.tsx +32 -0
  112. package/template/apps/web/src/app/examples/file-upload/page.tsx +200 -0
  113. package/template/apps/web/src/app/favicon.ico +0 -0
  114. package/template/apps/web/src/app/global-error.tsx +96 -0
  115. package/template/apps/web/src/app/globals.css +22 -0
  116. package/template/apps/web/src/app/icon.png +0 -0
  117. package/template/apps/web/src/app/layout.tsx +34 -0
  118. package/template/apps/web/src/app/not-found.tsx +28 -0
  119. package/template/apps/web/src/app/page.tsx +192 -0
  120. package/template/apps/web/src/components/admin/activity-feed.tsx +101 -0
  121. package/template/apps/web/src/components/admin/charts/auth-breakdown-chart.tsx +114 -0
  122. package/template/apps/web/src/components/admin/charts/chart-tooltip.tsx +124 -0
  123. package/template/apps/web/src/components/admin/charts/realtime-metrics-chart.tsx +511 -0
  124. package/template/apps/web/src/components/admin/charts/role-distribution-chart.tsx +102 -0
  125. package/template/apps/web/src/components/admin/charts/session-activity-chart.tsx +90 -0
  126. package/template/apps/web/src/components/admin/charts/user-growth-chart.tsx +108 -0
  127. package/template/apps/web/src/components/admin/health-indicator.tsx +175 -0
  128. package/template/apps/web/src/components/admin/refresh-control.tsx +90 -0
  129. package/template/apps/web/src/components/admin/session-revoke-all-dialog.tsx +79 -0
  130. package/template/apps/web/src/components/admin/session-revoke-dialog.tsx +74 -0
  131. package/template/apps/web/src/components/admin/sessions-management-table.tsx +372 -0
  132. package/template/apps/web/src/components/admin/stat-card.tsx +137 -0
  133. package/template/apps/web/src/components/admin/user-create-dialog.tsx +152 -0
  134. package/template/apps/web/src/components/admin/user-delete-dialog.tsx +73 -0
  135. package/template/apps/web/src/components/admin/user-edit-dialog.tsx +170 -0
  136. package/template/apps/web/src/components/admin/users-management-table.tsx +285 -0
  137. package/template/apps/web/src/components/auth/email-verification-banner.tsx +85 -0
  138. package/template/apps/web/src/components/auth/github-button.tsx +40 -0
  139. package/template/apps/web/src/components/auth/google-button.tsx +54 -0
  140. package/template/apps/web/src/components/auth/protected-route.tsx +66 -0
  141. package/template/apps/web/src/components/auth/redirect-if-authenticated.tsx +31 -0
  142. package/template/apps/web/src/components/auth/with-auth.tsx +30 -0
  143. package/template/apps/web/src/components/error/error-card.tsx +47 -0
  144. package/template/apps/web/src/components/error/forbidden.tsx +25 -0
  145. package/template/apps/web/src/components/landing/command-block.tsx +64 -0
  146. package/template/apps/web/src/components/landing/feature-card.tsx +60 -0
  147. package/template/apps/web/src/components/landing/included-feature-card.tsx +63 -0
  148. package/template/apps/web/src/components/landing/logo.tsx +41 -0
  149. package/template/apps/web/src/components/landing/tech-badge.tsx +11 -0
  150. package/template/apps/web/src/components/layout/auth-nav.tsx +58 -0
  151. package/template/apps/web/src/components/layout/footer.tsx +3 -0
  152. package/template/apps/web/src/config/landing-data.ts +152 -0
  153. package/template/apps/web/src/config/site.ts +5 -0
  154. package/template/apps/web/src/hooks/api/__tests__/use-users.test.tsx +181 -0
  155. package/template/apps/web/src/hooks/api/use-admin-sessions.ts +75 -0
  156. package/template/apps/web/src/hooks/api/use-admin-stats.ts +33 -0
  157. package/template/apps/web/src/hooks/api/use-sessions.ts +52 -0
  158. package/template/apps/web/src/hooks/api/use-uploads.ts +156 -0
  159. package/template/apps/web/src/hooks/api/use-users.ts +149 -0
  160. package/template/apps/web/src/hooks/use-mobile.ts +21 -0
  161. package/template/apps/web/src/hooks/use-realtime-metrics.ts +120 -0
  162. package/template/apps/web/src/lib/__tests__/utils.test.ts +29 -0
  163. package/template/apps/web/src/lib/api.ts +151 -0
  164. package/template/apps/web/src/lib/auth.ts +13 -0
  165. package/template/apps/web/src/lib/env.ts +52 -0
  166. package/template/apps/web/src/lib/form-utils.ts +11 -0
  167. package/template/apps/web/src/lib/utils.ts +1 -0
  168. package/template/apps/web/src/providers.tsx +34 -0
  169. package/template/apps/web/src/store/atoms.ts +15 -0
  170. package/template/apps/web/src/test/helpers/test-utils.tsx +44 -0
  171. package/template/apps/web/src/test/setup.ts +8 -0
  172. package/template/apps/web/tailwind.config.ts +5 -0
  173. package/template/apps/web/tsconfig.json +26 -0
  174. package/template/apps/web/vitest.config.ts +32 -0
  175. package/template/assets/logo-512.png +0 -0
  176. package/template/assets/logo.svg +4 -0
  177. package/template/docker-compose.prod.yml +66 -0
  178. package/template/docker-compose.yml +36 -0
  179. package/template/eslint.config.ts +119 -0
  180. package/template/package.json +77 -0
  181. package/template/packages/tailwind-config/package.json +9 -0
  182. package/template/packages/tailwind-config/theme.css +179 -0
  183. package/template/packages/types/package.json +29 -0
  184. package/template/packages/types/src/__tests__/schemas.test.ts +255 -0
  185. package/template/packages/types/src/api-response.ts +53 -0
  186. package/template/packages/types/src/health-check.ts +11 -0
  187. package/template/packages/types/src/pagination.ts +41 -0
  188. package/template/packages/types/src/role.ts +5 -0
  189. package/template/packages/types/src/session.ts +48 -0
  190. package/template/packages/types/src/stats.ts +113 -0
  191. package/template/packages/types/src/upload.ts +51 -0
  192. package/template/packages/types/src/user.ts +36 -0
  193. package/template/packages/types/tsconfig.json +5 -0
  194. package/template/packages/types/vitest.config.ts +21 -0
  195. package/template/packages/ui/components.json +21 -0
  196. package/template/packages/ui/package.json +108 -0
  197. package/template/packages/ui/src/__tests__/button.test.tsx +70 -0
  198. package/template/packages/ui/src/alert-dialog.tsx +141 -0
  199. package/template/packages/ui/src/alert.tsx +66 -0
  200. package/template/packages/ui/src/animated-theme-toggler.tsx +167 -0
  201. package/template/packages/ui/src/avatar.tsx +53 -0
  202. package/template/packages/ui/src/badge.tsx +36 -0
  203. package/template/packages/ui/src/button.tsx +84 -0
  204. package/template/packages/ui/src/card.tsx +92 -0
  205. package/template/packages/ui/src/checkbox.tsx +32 -0
  206. package/template/packages/ui/src/data-table/data-table-column-header.tsx +68 -0
  207. package/template/packages/ui/src/data-table/data-table-pagination.tsx +99 -0
  208. package/template/packages/ui/src/data-table/data-table-toolbar.tsx +55 -0
  209. package/template/packages/ui/src/data-table/data-table-view-options.tsx +63 -0
  210. package/template/packages/ui/src/data-table/data-table.tsx +167 -0
  211. package/template/packages/ui/src/dialog.tsx +143 -0
  212. package/template/packages/ui/src/dropdown-menu.tsx +257 -0
  213. package/template/packages/ui/src/empty-state.tsx +52 -0
  214. package/template/packages/ui/src/file-upload-input.tsx +202 -0
  215. package/template/packages/ui/src/form.tsx +168 -0
  216. package/template/packages/ui/src/hooks/use-mobile.ts +19 -0
  217. package/template/packages/ui/src/icons/brand-icons.tsx +16 -0
  218. package/template/packages/ui/src/input.tsx +21 -0
  219. package/template/packages/ui/src/label.tsx +24 -0
  220. package/template/packages/ui/src/lib/utils.ts +6 -0
  221. package/template/packages/ui/src/password-input.tsx +102 -0
  222. package/template/packages/ui/src/popover.tsx +48 -0
  223. package/template/packages/ui/src/radio-group.tsx +45 -0
  224. package/template/packages/ui/src/scroll-area.tsx +58 -0
  225. package/template/packages/ui/src/select.tsx +187 -0
  226. package/template/packages/ui/src/separator.tsx +28 -0
  227. package/template/packages/ui/src/sheet.tsx +139 -0
  228. package/template/packages/ui/src/sidebar.tsx +726 -0
  229. package/template/packages/ui/src/skeleton-variants.tsx +87 -0
  230. package/template/packages/ui/src/skeleton.tsx +13 -0
  231. package/template/packages/ui/src/slider.tsx +63 -0
  232. package/template/packages/ui/src/sonner.tsx +25 -0
  233. package/template/packages/ui/src/spinner.tsx +16 -0
  234. package/template/packages/ui/src/switch.tsx +31 -0
  235. package/template/packages/ui/src/table.tsx +116 -0
  236. package/template/packages/ui/src/tabs.tsx +66 -0
  237. package/template/packages/ui/src/textarea.tsx +18 -0
  238. package/template/packages/ui/src/tooltip.tsx +61 -0
  239. package/template/packages/ui/src/user-avatar.tsx +97 -0
  240. package/template/packages/ui/test-config.js +3 -0
  241. package/template/packages/ui/tsconfig.json +12 -0
  242. package/template/packages/ui/turbo.json +18 -0
  243. package/template/packages/ui/vitest.config.ts +17 -0
  244. package/template/packages/ui/vitest.setup.ts +1 -0
  245. package/template/packages/utils/package.json +23 -0
  246. package/template/packages/utils/src/__tests__/utils.test.ts +223 -0
  247. package/template/packages/utils/src/array.ts +18 -0
  248. package/template/packages/utils/src/async.ts +3 -0
  249. package/template/packages/utils/src/date.ts +77 -0
  250. package/template/packages/utils/src/errors.ts +73 -0
  251. package/template/packages/utils/src/number.ts +11 -0
  252. package/template/packages/utils/src/string.ts +13 -0
  253. package/template/packages/utils/tsconfig.json +5 -0
  254. package/template/packages/utils/vitest.config.ts +21 -0
  255. package/template/pnpm-workspace.yaml +4 -0
  256. package/template/tsconfig.base.json +32 -0
  257. package/template/turbo.json +133 -0
  258. package/template/vitest.shared.ts +26 -0
  259. package/template/vitest.workspace.ts +9 -0
@@ -0,0 +1,34 @@
1
+ import closeWithGrace from 'close-with-grace';
2
+
3
+ import { app } from '@/app';
4
+ import { loadEnv } from '@/config/env';
5
+
6
+ const env = loadEnv();
7
+
8
+ const start = async () => {
9
+ try {
10
+ await app.listen({ port: env.PORT, host: '0.0.0.0' });
11
+ app.log.info(`API server ready at ${env.API_URL}`);
12
+ app.log.info(`Environment: ${env.NODE_ENV}`);
13
+ app.log.info(`CORS enabled for: ${env.FRONTEND_URL}`);
14
+ } catch (err) {
15
+ app.log.error(err);
16
+ process.exit(1);
17
+ }
18
+ };
19
+
20
+ const closeListeners = closeWithGrace(
21
+ { delay: Number(process.env.FASTIFY_CLOSE_GRACE_DELAY) || 500 },
22
+ async ({ err }) => {
23
+ if (err) {
24
+ app.log.error(err);
25
+ }
26
+ await app.close();
27
+ }
28
+ );
29
+
30
+ app.addHook('onClose', async () => {
31
+ closeListeners.uninstall();
32
+ });
33
+
34
+ start();
@@ -0,0 +1,125 @@
1
+ import { NotFoundError, ValidationError } from '@repo/packages-utils/errors';
2
+
3
+ import { type LoggerService } from '@/common/logger.service';
4
+ import type { PrismaClient } from '@/generated/client/client.js';
5
+
6
+ export interface ConnectedAccount {
7
+ providerId: string;
8
+ accountId: string;
9
+ connectedAt: Date;
10
+ scope?: string;
11
+ }
12
+
13
+ export interface UserAccounts {
14
+ userId: string;
15
+ hasPassword: boolean;
16
+ connectedAccounts: ConnectedAccount[];
17
+ }
18
+
19
+ export class AccountsService {
20
+ constructor(
21
+ private readonly prisma: PrismaClient,
22
+ private readonly logger: LoggerService
23
+ ) {
24
+ this.logger.setContext('AccountsService');
25
+ }
26
+
27
+ async getUserAccounts(userId: string): Promise<UserAccounts> {
28
+ this.logger.info('Fetching user accounts', { userId });
29
+
30
+ const accounts = await this.prisma.account.findMany({
31
+ where: { userId },
32
+ select: {
33
+ providerId: true,
34
+ accountId: true,
35
+ createdAt: true,
36
+ scope: true,
37
+ password: true,
38
+ },
39
+ });
40
+
41
+ if (accounts.length === 0) {
42
+ throw new NotFoundError('User has no connected accounts');
43
+ }
44
+
45
+ const credentialAccount = accounts.find(
46
+ (a) => a.providerId === 'credential'
47
+ );
48
+ const hasPassword = !!(credentialAccount && credentialAccount.password);
49
+
50
+ const connectedAccounts: ConnectedAccount[] = accounts
51
+ .filter((a) => a.providerId !== 'credential')
52
+ .map((a) => ({
53
+ providerId: a.providerId,
54
+ accountId: a.accountId,
55
+ connectedAt: a.createdAt,
56
+ scope: a.scope || undefined,
57
+ }));
58
+
59
+ if (hasPassword) {
60
+ connectedAccounts.unshift({
61
+ providerId: 'credential',
62
+ accountId: credentialAccount!.accountId,
63
+ connectedAt: credentialAccount!.createdAt,
64
+ });
65
+ }
66
+
67
+ return {
68
+ userId,
69
+ hasPassword,
70
+ connectedAccounts,
71
+ };
72
+ }
73
+
74
+ async unlinkAccount(
75
+ userId: string,
76
+ providerId: string
77
+ ): Promise<{ success: boolean }> {
78
+ this.logger.info('Unlinking account', { userId, providerId });
79
+
80
+ const accounts = await this.prisma.account.findMany({
81
+ where: { userId },
82
+ });
83
+
84
+ if (accounts.length <= 1) {
85
+ throw new ValidationError(
86
+ 'Cannot unlink the only account. User must have at least one login method.'
87
+ );
88
+ }
89
+
90
+ if (providerId === 'credential') {
91
+ throw new ValidationError(
92
+ 'Cannot unlink password login. Please change your password or contact support.'
93
+ );
94
+ }
95
+
96
+ const account = accounts.find((a) => a.providerId === providerId);
97
+ if (!account) {
98
+ throw new NotFoundError(
99
+ `Account with provider ${providerId} not found for this user`
100
+ );
101
+ }
102
+
103
+ await this.prisma.account.delete({
104
+ where: { id: account.id },
105
+ });
106
+
107
+ this.logger.info('Account unlinked successfully', { userId, providerId });
108
+
109
+ return { success: true };
110
+ }
111
+
112
+ async canChangePassword(userId: string): Promise<boolean> {
113
+ const credentialAccount = await this.prisma.account.findFirst({
114
+ where: {
115
+ userId,
116
+ providerId: 'credential',
117
+ },
118
+ select: {
119
+ password: true,
120
+ },
121
+ });
122
+
123
+ return !!(credentialAccount && credentialAccount.password);
124
+ }
125
+ }
@@ -0,0 +1,162 @@
1
+ import type { Role } from '@repo/packages-types/role';
2
+ import { ForbiddenError } from '@repo/packages-utils/errors';
3
+
4
+ import type { LoggerService } from '@/common/logger.service';
5
+
6
+ export interface AuthorizationContext {
7
+ actorId: string;
8
+ actorRole: Role;
9
+ targetUserId?: string;
10
+ targetUserRole?: Role;
11
+ }
12
+
13
+ export class AuthorizationService {
14
+ private readonly roleHierarchy: Record<Role, number> = {
15
+ super_admin: 3,
16
+ admin: 2,
17
+ user: 1,
18
+ };
19
+
20
+ constructor(private readonly logger: LoggerService) {
21
+ this.logger.setContext('AuthorizationService');
22
+ }
23
+
24
+ private getRoleLevel(role: Role): number {
25
+ return this.roleHierarchy[role];
26
+ }
27
+
28
+ canModifyUser(actorRole: Role, targetRole: Role): boolean {
29
+ const actorLevel = this.getRoleLevel(actorRole);
30
+ const targetLevel = this.getRoleLevel(targetRole);
31
+
32
+ return actorLevel > targetLevel;
33
+ }
34
+
35
+ canDeleteUser(actorRole: Role, targetRole: Role): boolean {
36
+ return this.canModifyUser(actorRole, targetRole);
37
+ }
38
+
39
+ canChangeRole(
40
+ actorRole: Role,
41
+ targetCurrentRole: Role,
42
+ newRole: Role
43
+ ): boolean {
44
+ const actorLevel = this.getRoleLevel(actorRole);
45
+ const targetLevel = this.getRoleLevel(targetCurrentRole);
46
+ const newRoleLevel = this.getRoleLevel(newRole);
47
+
48
+ return actorLevel > targetLevel && actorLevel > newRoleLevel;
49
+ }
50
+
51
+ canChangeEmail(actorRole: Role): boolean {
52
+ return actorRole === 'super_admin';
53
+ }
54
+
55
+ assertCanModifyUser(
56
+ actorId: string,
57
+ actorRole: Role,
58
+ targetUserId: string,
59
+ targetRole: Role
60
+ ): void {
61
+ if (actorId === targetUserId) {
62
+ return;
63
+ }
64
+
65
+ if (!this.canModifyUser(actorRole, targetRole)) {
66
+ this.logger.warn('Authorization failed: Cannot modify user', {
67
+ actorId,
68
+ actorRole,
69
+ targetUserId,
70
+ targetRole,
71
+ });
72
+ throw new ForbiddenError(
73
+ `Insufficient permissions to modify user with role: ${targetRole}`,
74
+ {
75
+ requiredLevel: 'higher than target',
76
+ actorRole,
77
+ targetRole,
78
+ }
79
+ );
80
+ }
81
+ }
82
+
83
+ assertCanDeleteUser(
84
+ actorId: string,
85
+ actorRole: Role,
86
+ targetUserId: string,
87
+ targetRole: Role
88
+ ): void {
89
+ if (actorId === targetUserId) {
90
+ this.logger.warn('Authorization failed: Cannot delete own account', {
91
+ actorId,
92
+ });
93
+ throw new ForbiddenError('Cannot delete your own account');
94
+ }
95
+
96
+ if (!this.canDeleteUser(actorRole, targetRole)) {
97
+ this.logger.warn('Authorization failed: Cannot delete user', {
98
+ actorId,
99
+ actorRole,
100
+ targetUserId,
101
+ targetRole,
102
+ });
103
+ throw new ForbiddenError(
104
+ `Insufficient permissions to delete user with role: ${targetRole}`,
105
+ {
106
+ requiredLevel: 'higher than target',
107
+ actorRole,
108
+ targetRole,
109
+ }
110
+ );
111
+ }
112
+ }
113
+
114
+ assertCanChangeRole(
115
+ actorId: string,
116
+ actorRole: Role,
117
+ targetUserId: string,
118
+ targetCurrentRole: Role,
119
+ newRole: Role
120
+ ): void {
121
+ if (actorId === targetUserId) {
122
+ this.logger.warn('Authorization failed: Cannot modify own role', {
123
+ actorId,
124
+ });
125
+ throw new ForbiddenError('Cannot modify your own role');
126
+ }
127
+
128
+ if (!this.canChangeRole(actorRole, targetCurrentRole, newRole)) {
129
+ this.logger.warn('Authorization failed: Cannot change role', {
130
+ actorId,
131
+ actorRole,
132
+ targetUserId,
133
+ targetCurrentRole,
134
+ newRole,
135
+ });
136
+ throw new ForbiddenError(
137
+ `Insufficient permissions to change role from ${targetCurrentRole} to ${newRole}`,
138
+ {
139
+ requiredLevel: 'higher than both current and target roles',
140
+ actorRole,
141
+ targetCurrentRole,
142
+ newRole,
143
+ }
144
+ );
145
+ }
146
+ }
147
+
148
+ assertCanChangeEmail(actorRole: Role): void {
149
+ if (!this.canChangeEmail(actorRole)) {
150
+ this.logger.warn('Authorization failed: Cannot change email', {
151
+ actorRole,
152
+ });
153
+ throw new ForbiddenError(
154
+ 'Only super admins can change user email addresses',
155
+ {
156
+ requiredRole: 'super_admin',
157
+ currentRole: actorRole,
158
+ }
159
+ );
160
+ }
161
+ }
162
+ }
@@ -0,0 +1,170 @@
1
+ import { render } from '@react-email/components';
2
+ import PasswordResetEmail from 'emails/password-reset-email';
3
+ import VerificationEmail from 'emails/verification-email';
4
+ import { Resend } from 'resend';
5
+
6
+ import type { LoggerService } from '@/common/logger.service';
7
+ import type { Env } from '@/config/env';
8
+ import type { PrismaClient } from '@/generated/client/client.js';
9
+
10
+ export class EmailService {
11
+ private resend: Resend | null = null;
12
+ private isConfigured: boolean;
13
+ private emailFrom: string;
14
+
15
+ constructor(
16
+ private readonly env: Env,
17
+ private readonly logger: LoggerService,
18
+ private readonly prisma: PrismaClient
19
+ ) {
20
+ this.logger.setContext('EmailService');
21
+
22
+ this.isConfigured = !!(this.env.RESEND_API_KEY && this.env.EMAIL_FROM);
23
+ this.emailFrom = this.env.EMAIL_FROM || 'noreply@example.com';
24
+
25
+ if (this.isConfigured) {
26
+ this.resend = new Resend(this.env.RESEND_API_KEY!);
27
+ this.logger.info(
28
+ `[+] Email service initialized with Resend. Email: ${this.emailFrom}`
29
+ );
30
+ } else {
31
+ this.logger.warn(
32
+ 'Email service not configured (missing RESEND_API_KEY or EMAIL_FROM) - emails will be logged to console'
33
+ );
34
+ }
35
+ }
36
+
37
+ async sendVerificationEmail(
38
+ email: string,
39
+ verificationUrl: string
40
+ ): Promise<{ success: boolean; error?: string }> {
41
+ const subject = 'Verify your email address';
42
+
43
+ try {
44
+ const html = await render(
45
+ VerificationEmail({
46
+ verificationUrl,
47
+ userEmail: email,
48
+ })
49
+ );
50
+
51
+ await this.sendEmail({
52
+ to: email,
53
+ subject,
54
+ html,
55
+ });
56
+
57
+ this.logger.info('Verification email sent', { to: email });
58
+ return { success: true };
59
+ } catch (error) {
60
+ this.logger.error(
61
+ 'Failed to send verification email - user can resend later',
62
+ error instanceof Error ? error : new Error(String(error)),
63
+ { to: email }
64
+ );
65
+ return {
66
+ success: false,
67
+ error: error instanceof Error ? error.message : String(error),
68
+ };
69
+ }
70
+ }
71
+
72
+ async sendPasswordResetEmail(
73
+ email: string,
74
+ resetUrl: string
75
+ ): Promise<{ success: boolean; error?: string }> {
76
+ const subject = 'Reset your password';
77
+
78
+ try {
79
+ const html = await render(
80
+ PasswordResetEmail({
81
+ resetUrl,
82
+ userEmail: email,
83
+ })
84
+ );
85
+
86
+ await this.sendEmail({
87
+ to: email,
88
+ subject,
89
+ html,
90
+ });
91
+
92
+ this.logger.info('Password reset email sent', { to: email });
93
+ return { success: true };
94
+ } catch (error) {
95
+ this.logger.error(
96
+ 'Failed to send password reset email',
97
+ error instanceof Error ? error : new Error(String(error)),
98
+ { to: email }
99
+ );
100
+ return {
101
+ success: false,
102
+ error: error instanceof Error ? error.message : String(error),
103
+ };
104
+ }
105
+ }
106
+
107
+ private async sendEmail({
108
+ to,
109
+ subject,
110
+ html,
111
+ }: {
112
+ to: string;
113
+ subject: string;
114
+ html: string;
115
+ }) {
116
+ if (!this.isConfigured) {
117
+ this.logger
118
+ .detailed()
119
+ .info('Email not sent (dev mode - no API key configured)', {
120
+ to,
121
+ subject,
122
+ });
123
+
124
+ console.log(
125
+ '\n┌─────────────────────────────────────────────────────────┐'
126
+ );
127
+ console.log(
128
+ '│ 📧 EMAIL PREVIEW │'
129
+ );
130
+ console.log(
131
+ '└─────────────────────────────────────────────────────────┘'
132
+ );
133
+ console.log(`To: ${to}`);
134
+ console.log(`From: ${this.emailFrom}`);
135
+ console.log(`Subject: ${subject}`);
136
+ console.log('─────────────────────────────────────────────────────────');
137
+ console.log(`Preview: ${html.slice(0, 300)}...`);
138
+ console.log(
139
+ '─────────────────────────────────────────────────────────\n'
140
+ );
141
+
142
+ return;
143
+ }
144
+
145
+ try {
146
+ const result = await this.resend!.emails.send({
147
+ from: this.emailFrom,
148
+ to,
149
+ subject,
150
+ html,
151
+ });
152
+
153
+ this.logger.detailed().debug('Email sent successfully', {
154
+ to,
155
+ subject,
156
+ emailId: result.data?.id,
157
+ });
158
+ } catch (error) {
159
+ this.logger.error(
160
+ 'Failed to send email via Resend',
161
+ error instanceof Error ? error : new Error(String(error)),
162
+ {
163
+ to,
164
+ subject,
165
+ }
166
+ );
167
+ throw error;
168
+ }
169
+ }
170
+ }