create-arktos 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/.env.example +69 -0
- package/LICENSE +21 -0
- package/README.md +533 -0
- package/bin/cli.js +318 -0
- package/eslint.config.js +79 -0
- package/package.json +108 -0
- package/prisma/schema.prisma +172 -0
- package/src/app.ts +101 -0
- package/src/config/env.validation.ts +15 -0
- package/src/config/logger.ts +76 -0
- package/src/constants/errorCodes.ts +59 -0
- package/src/constants/messages.ts +190 -0
- package/src/controllers/auth.controller.ts +546 -0
- package/src/middleware/index.ts +638 -0
- package/src/routes/auth.routes.ts +29 -0
- package/src/routes/index.ts +63 -0
- package/src/schemas/index.ts +139 -0
- package/src/services/database.service.ts +120 -0
- package/src/services/email.service.ts +244 -0
- package/src/services/jwt.service.ts +227 -0
- package/src/types/express.d.ts +19 -0
- package/src/types/index.ts +156 -0
- package/src/utils/response.ts +55 -0
- package/src/views/emails/notification.html +87 -0
- package/src/views/emails/resetPassword.html +118 -0
- package/src/views/emails/verification.html +107 -0
- package/src/views/emails/welcome.html +113 -0
- package/template.package.json +82 -0
- package/tsconfig.json +35 -0
- package/vercel.json +34 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
|
|
3
|
+
export interface JwtPayload {
|
|
4
|
+
userId: string;
|
|
5
|
+
email: string;
|
|
6
|
+
role: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
type: 'access' | 'refresh';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class JwtService {
|
|
12
|
+
private static getAccessSecret(): string {
|
|
13
|
+
const secret = process.env.JWT_SECRET;
|
|
14
|
+
if (!secret) {
|
|
15
|
+
throw new Error('JWT_SECRET environment variable is not defined');
|
|
16
|
+
}
|
|
17
|
+
return secret;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private static getRefreshSecret(): string {
|
|
21
|
+
const secret = process.env.JWT_REFRESH_SECRET;
|
|
22
|
+
if (!secret) {
|
|
23
|
+
throw new Error('JWT_REFRESH_SECRET environment variable is not defined');
|
|
24
|
+
}
|
|
25
|
+
return secret;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private static getAccessExpiresIn(): string {
|
|
29
|
+
return process.env.JWT_EXPIRES_IN || '15m';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private static getRefreshExpiresIn(): string {
|
|
33
|
+
return process.env.JWT_REFRESH_EXPIRES_IN || '7d';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public static generateAccessToken(
|
|
37
|
+
userId: string,
|
|
38
|
+
email: string,
|
|
39
|
+
role: string,
|
|
40
|
+
sessionId: string
|
|
41
|
+
): string {
|
|
42
|
+
try {
|
|
43
|
+
const payload: Omit<JwtPayload, 'type'> & { type: 'access' } = {
|
|
44
|
+
userId,
|
|
45
|
+
email,
|
|
46
|
+
role,
|
|
47
|
+
sessionId,
|
|
48
|
+
type: 'access',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return jwt.sign(payload, this.getAccessSecret(), {
|
|
52
|
+
expiresIn: this.getAccessExpiresIn(),
|
|
53
|
+
} as jwt.SignOptions);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new Error('Failed to generate access token');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public static generateRefreshToken(
|
|
60
|
+
userId: string,
|
|
61
|
+
email: string,
|
|
62
|
+
role: string,
|
|
63
|
+
sessionId: string
|
|
64
|
+
): string {
|
|
65
|
+
try {
|
|
66
|
+
const payload: Omit<JwtPayload, 'type'> & { type: 'refresh' } = {
|
|
67
|
+
userId,
|
|
68
|
+
email,
|
|
69
|
+
role,
|
|
70
|
+
sessionId,
|
|
71
|
+
type: 'refresh',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return jwt.sign(payload, this.getRefreshSecret(), {
|
|
75
|
+
expiresIn: this.getRefreshExpiresIn(),
|
|
76
|
+
} as jwt.SignOptions);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new Error('Failed to generate refresh token');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public static verifyAccessToken(token: string): JwtPayload {
|
|
83
|
+
try {
|
|
84
|
+
const decoded = jwt.verify(token, this.getAccessSecret());
|
|
85
|
+
return decoded as JwtPayload;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof jwt.TokenExpiredError) {
|
|
88
|
+
throw new Error('Access token expired');
|
|
89
|
+
}
|
|
90
|
+
if (error instanceof jwt.JsonWebTokenError) {
|
|
91
|
+
throw new Error('Invalid access token');
|
|
92
|
+
}
|
|
93
|
+
throw new Error('Access token verification failed');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public static verifyRefreshToken(token: string): JwtPayload {
|
|
98
|
+
try {
|
|
99
|
+
const decoded = jwt.verify(token, this.getRefreshSecret());
|
|
100
|
+
return decoded as JwtPayload;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error instanceof jwt.TokenExpiredError) {
|
|
103
|
+
throw new Error('Refresh token expired');
|
|
104
|
+
}
|
|
105
|
+
if (error instanceof jwt.JsonWebTokenError) {
|
|
106
|
+
throw new Error('Invalid refresh token');
|
|
107
|
+
}
|
|
108
|
+
throw new Error('Refresh token verification failed');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public static generatePasswordResetToken(
|
|
113
|
+
userId: string,
|
|
114
|
+
email: string
|
|
115
|
+
): string {
|
|
116
|
+
try {
|
|
117
|
+
const payload = {
|
|
118
|
+
userId,
|
|
119
|
+
email,
|
|
120
|
+
type: 'password_reset',
|
|
121
|
+
iat: Math.floor(Date.now() / 1000),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return jwt.sign(payload, this.getAccessSecret(), {
|
|
125
|
+
expiresIn: '1h',
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
throw new Error('Failed to generate password reset token');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
public static verifyPasswordResetToken(token: string): {
|
|
133
|
+
userId: string;
|
|
134
|
+
email: string;
|
|
135
|
+
iat: number;
|
|
136
|
+
} {
|
|
137
|
+
try {
|
|
138
|
+
const decoded = jwt.verify(token, this.getAccessSecret()) as any;
|
|
139
|
+
|
|
140
|
+
if (decoded.type !== 'password_reset') {
|
|
141
|
+
throw new Error('Invalid token type');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
userId: decoded.userId,
|
|
146
|
+
email: decoded.email,
|
|
147
|
+
iat: decoded.iat,
|
|
148
|
+
};
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error instanceof jwt.TokenExpiredError) {
|
|
151
|
+
throw new Error('Password reset token expired');
|
|
152
|
+
}
|
|
153
|
+
if (error instanceof jwt.JsonWebTokenError) {
|
|
154
|
+
throw new Error('Invalid password reset token');
|
|
155
|
+
}
|
|
156
|
+
throw new Error('Password reset token verification failed');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public static generateEmailVerifyToken(
|
|
161
|
+
userId: string,
|
|
162
|
+
email: string
|
|
163
|
+
): string {
|
|
164
|
+
try {
|
|
165
|
+
const payload = {
|
|
166
|
+
userId,
|
|
167
|
+
email,
|
|
168
|
+
type: 'email_verify',
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return jwt.sign(payload, this.getAccessSecret(), {
|
|
172
|
+
expiresIn: '24h',
|
|
173
|
+
});
|
|
174
|
+
} catch (error) {
|
|
175
|
+
throw new Error('Failed to generate email verification token');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
public static verifyEmailVerifyToken(token: string): {
|
|
180
|
+
userId: string;
|
|
181
|
+
email: string;
|
|
182
|
+
} {
|
|
183
|
+
try {
|
|
184
|
+
const decoded = jwt.verify(token, this.getAccessSecret()) as any;
|
|
185
|
+
|
|
186
|
+
if (decoded.type !== 'email_verify') {
|
|
187
|
+
throw new Error('Invalid token type');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
userId: decoded.userId,
|
|
192
|
+
email: decoded.email,
|
|
193
|
+
};
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error instanceof jwt.TokenExpiredError) {
|
|
196
|
+
throw new Error('Email verification token expired');
|
|
197
|
+
}
|
|
198
|
+
if (error instanceof jwt.JsonWebTokenError) {
|
|
199
|
+
throw new Error('Invalid email verification token');
|
|
200
|
+
}
|
|
201
|
+
throw new Error('Email verification token verification failed');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
public static getExpiresInSeconds(): number {
|
|
206
|
+
try {
|
|
207
|
+
const expiresIn = this.getAccessExpiresIn();
|
|
208
|
+
|
|
209
|
+
if (expiresIn.endsWith('h')) {
|
|
210
|
+
return parseInt(expiresIn.slice(0, -1)) * 3600;
|
|
211
|
+
}
|
|
212
|
+
if (expiresIn.endsWith('m')) {
|
|
213
|
+
return parseInt(expiresIn.slice(0, -1)) * 60;
|
|
214
|
+
}
|
|
215
|
+
if (expiresIn.endsWith('d')) {
|
|
216
|
+
return parseInt(expiresIn.slice(0, -1)) * 24 * 3600;
|
|
217
|
+
}
|
|
218
|
+
if (expiresIn.endsWith('s')) {
|
|
219
|
+
return parseInt(expiresIn.slice(0, -1));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return parseInt(expiresIn);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
throw new Error('Failed to calculate token expiration time');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { User } from '@prisma/client';
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
namespace Express {
|
|
5
|
+
interface Request {
|
|
6
|
+
user?: User;
|
|
7
|
+
file?: Express.Multer.File;
|
|
8
|
+
files?: Express.Multer.File[];
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
rateLimitInfo?: {
|
|
11
|
+
limit: number;
|
|
12
|
+
remaining: number;
|
|
13
|
+
resetTime: Date;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Request } from 'express';
|
|
2
|
+
import type { User, Role } from '@prisma/client';
|
|
3
|
+
|
|
4
|
+
// API Response Types
|
|
5
|
+
export interface ApiResponse<T = any> {
|
|
6
|
+
success: boolean;
|
|
7
|
+
message?: string;
|
|
8
|
+
data?: T;
|
|
9
|
+
error?: string;
|
|
10
|
+
code?: string;
|
|
11
|
+
timestamp?: string;
|
|
12
|
+
path?: string;
|
|
13
|
+
method?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
|
|
17
|
+
data: T[];
|
|
18
|
+
pagination: {
|
|
19
|
+
total: number;
|
|
20
|
+
page: number;
|
|
21
|
+
limit: number;
|
|
22
|
+
totalPages: number;
|
|
23
|
+
hasNext: boolean;
|
|
24
|
+
hasPrev: boolean;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Authentication Types
|
|
29
|
+
export interface LoginRequest {
|
|
30
|
+
email: string;
|
|
31
|
+
password: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RegisterRequest {
|
|
35
|
+
email: string;
|
|
36
|
+
password: string;
|
|
37
|
+
firstName?: string;
|
|
38
|
+
lastName?: string;
|
|
39
|
+
username?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AuthResponse {
|
|
43
|
+
user: Omit<User, 'password'>;
|
|
44
|
+
tokens: {
|
|
45
|
+
accessToken: string;
|
|
46
|
+
refreshToken: string;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface JwtPayload {
|
|
51
|
+
userId: string;
|
|
52
|
+
email: string;
|
|
53
|
+
role: Role;
|
|
54
|
+
iat?: number;
|
|
55
|
+
exp?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Express Request Extensions
|
|
59
|
+
export interface AuthenticatedRequest extends Omit<Request, 'user'> {
|
|
60
|
+
user?: {
|
|
61
|
+
id: string;
|
|
62
|
+
email: string;
|
|
63
|
+
username: string | null;
|
|
64
|
+
firstName: string | null;
|
|
65
|
+
lastName: string | null;
|
|
66
|
+
avatar: string | null;
|
|
67
|
+
role: Role;
|
|
68
|
+
isActive: boolean;
|
|
69
|
+
isEmailVerified: boolean;
|
|
70
|
+
emailVerifiedAt: Date | null;
|
|
71
|
+
lastLoginAt: Date | null;
|
|
72
|
+
createdAt: Date;
|
|
73
|
+
updatedAt: Date;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Database Types
|
|
78
|
+
export interface DatabaseHealth {
|
|
79
|
+
connected: boolean;
|
|
80
|
+
responseTime?: number;
|
|
81
|
+
error?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// File Upload Types
|
|
85
|
+
export interface FileUploadOptions {
|
|
86
|
+
maxSize?: number;
|
|
87
|
+
allowedTypes?: string[];
|
|
88
|
+
destination?: string;
|
|
89
|
+
filename?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface UploadedFile {
|
|
93
|
+
originalname: string;
|
|
94
|
+
filename: string;
|
|
95
|
+
mimetype: string;
|
|
96
|
+
size: number;
|
|
97
|
+
path: string;
|
|
98
|
+
url?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Error Types
|
|
102
|
+
export interface AppError extends Error {
|
|
103
|
+
statusCode: number;
|
|
104
|
+
code?: string;
|
|
105
|
+
isOperational: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Email Types
|
|
109
|
+
export interface EmailTemplate {
|
|
110
|
+
subject: string;
|
|
111
|
+
html: string;
|
|
112
|
+
text?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface EmailContext {
|
|
116
|
+
[key: string]: any;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Validation Types
|
|
120
|
+
export interface ValidationError {
|
|
121
|
+
field: string;
|
|
122
|
+
message: string;
|
|
123
|
+
value?: any;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Service Types
|
|
127
|
+
export interface ServiceResponse<T = any> {
|
|
128
|
+
success: boolean;
|
|
129
|
+
data?: T;
|
|
130
|
+
error?: string;
|
|
131
|
+
code?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Cache Types
|
|
135
|
+
export interface CacheOptions {
|
|
136
|
+
ttl?: number; // Time to live in seconds
|
|
137
|
+
prefix?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Rate Limiting Types
|
|
141
|
+
export interface RateLimitConfig {
|
|
142
|
+
windowMs: number;
|
|
143
|
+
max: number;
|
|
144
|
+
message?: string;
|
|
145
|
+
standardHeaders?: boolean;
|
|
146
|
+
legacyHeaders?: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
|
|
150
|
+
|
|
151
|
+
export interface LoggerConfig {
|
|
152
|
+
level: LogLevel;
|
|
153
|
+
format: string;
|
|
154
|
+
transports: string[];
|
|
155
|
+
meta?: boolean;
|
|
156
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ApiResponse, PaginatedResponse } from '../types';
|
|
2
|
+
|
|
3
|
+
export const createSuccessResponse = <T = any>(
|
|
4
|
+
data?: T,
|
|
5
|
+
message?: string,
|
|
6
|
+
code?: string
|
|
7
|
+
): ApiResponse<T> => {
|
|
8
|
+
return {
|
|
9
|
+
success: true,
|
|
10
|
+
message,
|
|
11
|
+
data,
|
|
12
|
+
code,
|
|
13
|
+
timestamp: new Date().toISOString(),
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const createErrorResponse = (
|
|
18
|
+
message: string,
|
|
19
|
+
code?: string,
|
|
20
|
+
additionalData?: any
|
|
21
|
+
): ApiResponse => {
|
|
22
|
+
return {
|
|
23
|
+
success: false,
|
|
24
|
+
message,
|
|
25
|
+
error: message,
|
|
26
|
+
code,
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
...additionalData,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const createPaginatedResponse = <T>(
|
|
33
|
+
data: T[],
|
|
34
|
+
total: number,
|
|
35
|
+
page: number,
|
|
36
|
+
limit: number,
|
|
37
|
+
message?: string
|
|
38
|
+
): PaginatedResponse<T> => {
|
|
39
|
+
const totalPages = Math.ceil(total / limit);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
success: true,
|
|
43
|
+
message,
|
|
44
|
+
data,
|
|
45
|
+
pagination: {
|
|
46
|
+
total,
|
|
47
|
+
page,
|
|
48
|
+
limit,
|
|
49
|
+
totalPages,
|
|
50
|
+
hasNext: page < totalPages,
|
|
51
|
+
hasPrev: page > 1,
|
|
52
|
+
},
|
|
53
|
+
timestamp: new Date().toISOString(),
|
|
54
|
+
};
|
|
55
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Notification - {{appName}}</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
10
|
+
line-height: 1.6;
|
|
11
|
+
color: #333;
|
|
12
|
+
background-color: #f4f4f4;
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 0;
|
|
15
|
+
}
|
|
16
|
+
.container {
|
|
17
|
+
max-width: 600px;
|
|
18
|
+
margin: 20px auto;
|
|
19
|
+
background: white;
|
|
20
|
+
padding: 40px;
|
|
21
|
+
border-radius: 10px;
|
|
22
|
+
box-shadow: 0 0 20px rgba(0,0,0,0.1);
|
|
23
|
+
}
|
|
24
|
+
.header {
|
|
25
|
+
text-align: center;
|
|
26
|
+
margin-bottom: 30px;
|
|
27
|
+
}
|
|
28
|
+
.logo {
|
|
29
|
+
font-size: 24px;
|
|
30
|
+
font-weight: bold;
|
|
31
|
+
color: #3b82f6;
|
|
32
|
+
margin-bottom: 10px;
|
|
33
|
+
}
|
|
34
|
+
h1 {
|
|
35
|
+
color: #1f2937;
|
|
36
|
+
margin-bottom: 20px;
|
|
37
|
+
font-size: 24px;
|
|
38
|
+
}
|
|
39
|
+
.message-box {
|
|
40
|
+
background-color: #f8fafc;
|
|
41
|
+
border: 1px solid #e2e8f0;
|
|
42
|
+
border-radius: 8px;
|
|
43
|
+
padding: 20px;
|
|
44
|
+
margin: 20px 0;
|
|
45
|
+
}
|
|
46
|
+
.footer {
|
|
47
|
+
margin-top: 40px;
|
|
48
|
+
text-align: center;
|
|
49
|
+
color: #6b7280;
|
|
50
|
+
font-size: 14px;
|
|
51
|
+
}
|
|
52
|
+
.footer a {
|
|
53
|
+
color: #3b82f6;
|
|
54
|
+
text-decoration: none;
|
|
55
|
+
}
|
|
56
|
+
.divider {
|
|
57
|
+
border-top: 1px solid #e5e7eb;
|
|
58
|
+
margin: 30px 0;
|
|
59
|
+
}
|
|
60
|
+
</style>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<div class="container">
|
|
64
|
+
<div class="header">
|
|
65
|
+
<div class="logo">{{appName}}</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<h1>Hi {{firstName}},</h1>
|
|
69
|
+
|
|
70
|
+
<div class="message-box">
|
|
71
|
+
{{message}}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<p>If you have any questions about this notification, please don't hesitate to contact our support team.</p>
|
|
75
|
+
|
|
76
|
+
<div class="divider"></div>
|
|
77
|
+
|
|
78
|
+
<div class="footer">
|
|
79
|
+
<p>Best regards,</p>
|
|
80
|
+
<p>The {{appName}} Team</p>
|
|
81
|
+
<br>
|
|
82
|
+
<p>Need help? Contact our support team or visit our <a href="{{frontendUrl}}">help center</a>.</p>
|
|
83
|
+
<p>© 2025 {{appName}}. All rights reserved.</p>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</body>
|
|
87
|
+
</html>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Password Reset - {{appName}}</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
10
|
+
line-height: 1.6;
|
|
11
|
+
color: #333;
|
|
12
|
+
background-color: #f4f4f4;
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 0;
|
|
15
|
+
}
|
|
16
|
+
.container {
|
|
17
|
+
max-width: 600px;
|
|
18
|
+
margin: 20px auto;
|
|
19
|
+
background: white;
|
|
20
|
+
padding: 40px;
|
|
21
|
+
border-radius: 10px;
|
|
22
|
+
box-shadow: 0 0 20px rgba(0,0,0,0.1);
|
|
23
|
+
}
|
|
24
|
+
.header {
|
|
25
|
+
text-align: center;
|
|
26
|
+
margin-bottom: 30px;
|
|
27
|
+
}
|
|
28
|
+
.logo {
|
|
29
|
+
font-size: 24px;
|
|
30
|
+
font-weight: bold;
|
|
31
|
+
color: #dc2626;
|
|
32
|
+
margin-bottom: 10px;
|
|
33
|
+
}
|
|
34
|
+
h1 {
|
|
35
|
+
color: #1f2937;
|
|
36
|
+
margin-bottom: 20px;
|
|
37
|
+
font-size: 24px;
|
|
38
|
+
}
|
|
39
|
+
.button {
|
|
40
|
+
display: inline-block;
|
|
41
|
+
padding: 12px 30px;
|
|
42
|
+
background-color: #dc2626;
|
|
43
|
+
color: white;
|
|
44
|
+
text-decoration: none;
|
|
45
|
+
border-radius: 6px;
|
|
46
|
+
font-weight: bold;
|
|
47
|
+
margin: 20px 0;
|
|
48
|
+
transition: background-color 0.3s ease;
|
|
49
|
+
}
|
|
50
|
+
.button:hover {
|
|
51
|
+
background-color: #b91c1c;
|
|
52
|
+
}
|
|
53
|
+
.footer {
|
|
54
|
+
margin-top: 40px;
|
|
55
|
+
text-align: center;
|
|
56
|
+
color: #6b7280;
|
|
57
|
+
font-size: 14px;
|
|
58
|
+
}
|
|
59
|
+
.footer a {
|
|
60
|
+
color: #dc2626;
|
|
61
|
+
text-decoration: none;
|
|
62
|
+
}
|
|
63
|
+
.divider {
|
|
64
|
+
border-top: 1px solid #e5e7eb;
|
|
65
|
+
margin: 30px 0;
|
|
66
|
+
}
|
|
67
|
+
.security-note {
|
|
68
|
+
background-color: #fef2f2;
|
|
69
|
+
border-left: 4px solid #dc2626;
|
|
70
|
+
padding: 15px;
|
|
71
|
+
margin: 20px 0;
|
|
72
|
+
font-size: 14px;
|
|
73
|
+
}
|
|
74
|
+
.warning {
|
|
75
|
+
background-color: #fffbeb;
|
|
76
|
+
border-left: 4px solid #f59e0b;
|
|
77
|
+
padding: 15px;
|
|
78
|
+
margin: 20px 0;
|
|
79
|
+
font-size: 14px;
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
82
|
+
</head>
|
|
83
|
+
<body>
|
|
84
|
+
<div class="container">
|
|
85
|
+
<div class="header">
|
|
86
|
+
<div class="logo">{{appName}}</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<h1>Hi {{firstName}},</h1>
|
|
90
|
+
|
|
91
|
+
<p>We received a request to reset the password for your {{appName}} account.</p>
|
|
92
|
+
|
|
93
|
+
<p>If you requested this password reset, please click the button below to create a new password:</p>
|
|
94
|
+
|
|
95
|
+
<div style="text-align: center;">
|
|
96
|
+
<a href="{{resetUrl}}" class="button">Reset Password</a>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div class="security-note">
|
|
100
|
+
<strong>Security Notice:</strong> This password reset link will expire in 1 hour for your security. If you didn't request a password reset, please ignore this email and your password will remain unchanged.
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div class="warning">
|
|
104
|
+
<strong>Important:</strong> If you suspect someone else requested this password reset, please contact our support team immediately and consider changing your password through the app directly.
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<p>If the button doesn't work, you can also copy and paste the following link into your browser:</p>
|
|
108
|
+
<p style="word-break: break-all; color: #dc2626;">{{resetUrl}}</p>
|
|
109
|
+
|
|
110
|
+
<div class="divider"></div>
|
|
111
|
+
|
|
112
|
+
<div class="footer">
|
|
113
|
+
<p>Need help? Contact our support team or visit our <a href="{{frontendUrl}}">help center</a>.</p>
|
|
114
|
+
<p>© 2025 {{appName}}. All rights reserved.</p>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</body>
|
|
118
|
+
</html>
|