@stravigor/bastion 0.2.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/package.json +19 -0
- package/src/actions.ts +24 -0
- package/src/bastion_manager.ts +209 -0
- package/src/bastion_provider.ts +25 -0
- package/src/errors.ts +21 -0
- package/src/handlers/confirm_password.ts +32 -0
- package/src/handlers/forgot_password.ts +36 -0
- package/src/handlers/login.ts +65 -0
- package/src/handlers/logout.ts +23 -0
- package/src/handlers/register.ts +70 -0
- package/src/handlers/reset_password.ts +56 -0
- package/src/handlers/two_factor.ts +172 -0
- package/src/handlers/update_password.ts +40 -0
- package/src/handlers/update_profile.ts +17 -0
- package/src/handlers/verify_email.ts +83 -0
- package/src/helpers.ts +70 -0
- package/src/index.ts +51 -0
- package/src/middleware/confirmed.ts +28 -0
- package/src/middleware/two_factor_challenge.ts +32 -0
- package/src/middleware/verified.ts +24 -0
- package/src/tokens.ts +60 -0
- package/src/totp.ts +174 -0
- package/src/types.ts +135 -0
- package/stubs/actions/bastion.ts +83 -0
- package/stubs/config/bastion.ts +55 -0
- package/stubs/emails/reset-password.strav +26 -0
- package/stubs/emails/verify-email.strav +26 -0
- package/tsconfig.json +4 -0
package/src/totp.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOTP (Time-Based One-Time Password) implementation — RFC 6238.
|
|
3
|
+
* Pure Bun crypto, zero external dependencies.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { timingSafeEqual as nodeTimingSafeEqual } from 'node:crypto'
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Base32 encoding/decoding (RFC 4648)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
|
13
|
+
|
|
14
|
+
export function base32Encode(buffer: Uint8Array): string {
|
|
15
|
+
let result = ''
|
|
16
|
+
let bits = 0
|
|
17
|
+
let value = 0
|
|
18
|
+
|
|
19
|
+
for (const byte of buffer) {
|
|
20
|
+
value = (value << 8) | byte
|
|
21
|
+
bits += 8
|
|
22
|
+
while (bits >= 5) {
|
|
23
|
+
bits -= 5
|
|
24
|
+
result += BASE32_ALPHABET[(value >>> bits) & 0x1f]!
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (bits > 0) {
|
|
29
|
+
result += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f]!
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return result
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function base32Decode(encoded: string): Uint8Array {
|
|
36
|
+
const cleaned = encoded.replace(/=+$/, '').toUpperCase()
|
|
37
|
+
const bytes: number[] = []
|
|
38
|
+
let bits = 0
|
|
39
|
+
let value = 0
|
|
40
|
+
|
|
41
|
+
for (const char of cleaned) {
|
|
42
|
+
const index = BASE32_ALPHABET.indexOf(char)
|
|
43
|
+
if (index === -1) continue
|
|
44
|
+
value = (value << 5) | index
|
|
45
|
+
bits += 5
|
|
46
|
+
if (bits >= 8) {
|
|
47
|
+
bits -= 8
|
|
48
|
+
bytes.push((value >>> bits) & 0xff)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return new Uint8Array(bytes)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// HOTP — RFC 4226
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async function hotp(secret: Uint8Array, counter: bigint, digits: number): Promise<string> {
|
|
60
|
+
// Counter as 8-byte big-endian buffer
|
|
61
|
+
const counterBuffer = new ArrayBuffer(8)
|
|
62
|
+
const view = new DataView(counterBuffer)
|
|
63
|
+
view.setBigUint64(0, counter)
|
|
64
|
+
|
|
65
|
+
// HMAC-SHA1
|
|
66
|
+
const key = await crypto.subtle.importKey('raw', secret, { name: 'HMAC', hash: 'SHA-1' }, false, [
|
|
67
|
+
'sign',
|
|
68
|
+
])
|
|
69
|
+
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBuffer))
|
|
70
|
+
|
|
71
|
+
// Dynamic truncation
|
|
72
|
+
const offset = mac[19]! & 0x0f
|
|
73
|
+
const code =
|
|
74
|
+
((mac[offset]! & 0x7f) << 24) |
|
|
75
|
+
((mac[offset + 1]! & 0xff) << 16) |
|
|
76
|
+
((mac[offset + 2]! & 0xff) << 8) |
|
|
77
|
+
(mac[offset + 3]! & 0xff)
|
|
78
|
+
|
|
79
|
+
return String(code % 10 ** digits).padStart(digits, '0')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// TOTP — RFC 6238
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
export interface TotpOptions {
|
|
87
|
+
digits?: number
|
|
88
|
+
period?: number
|
|
89
|
+
/** Number of time steps to check before/after current (handles clock drift). */
|
|
90
|
+
window?: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Generate a TOTP code for the given secret at the current time. */
|
|
94
|
+
export async function generateTotp(
|
|
95
|
+
secret: Uint8Array,
|
|
96
|
+
options: TotpOptions = {}
|
|
97
|
+
): Promise<string> {
|
|
98
|
+
const { digits = 6, period = 30 } = options
|
|
99
|
+
const counter = BigInt(Math.floor(Date.now() / 1000 / period))
|
|
100
|
+
return hotp(secret, counter, digits)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Verify a TOTP code, allowing for clock drift within the window. */
|
|
104
|
+
export async function verifyTotp(
|
|
105
|
+
secret: Uint8Array,
|
|
106
|
+
code: string,
|
|
107
|
+
options: TotpOptions = {}
|
|
108
|
+
): Promise<boolean> {
|
|
109
|
+
const { digits = 6, period = 30, window = 1 } = options
|
|
110
|
+
const now = BigInt(Math.floor(Date.now() / 1000 / period))
|
|
111
|
+
|
|
112
|
+
for (let i = -window; i <= window; i++) {
|
|
113
|
+
const expected = await hotp(secret, now + BigInt(i), digits)
|
|
114
|
+
if (timingSafeEqual(code, expected)) return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Secret generation
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/** Generate a random TOTP secret (20 bytes, returned as base32). */
|
|
125
|
+
export function generateSecret(): { raw: Uint8Array; base32: string } {
|
|
126
|
+
const raw = crypto.getRandomValues(new Uint8Array(20))
|
|
127
|
+
return { raw, base32: base32Encode(raw) }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build an `otpauth://` URI for QR code generation.
|
|
132
|
+
* Compatible with Google Authenticator, Authy, 1Password, etc.
|
|
133
|
+
*/
|
|
134
|
+
export function totpUri(options: {
|
|
135
|
+
secret: string // base32
|
|
136
|
+
issuer: string
|
|
137
|
+
account: string
|
|
138
|
+
digits?: number
|
|
139
|
+
period?: number
|
|
140
|
+
}): string {
|
|
141
|
+
const { secret, issuer, account, digits = 6, period = 30 } = options
|
|
142
|
+
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`
|
|
143
|
+
const params = new URLSearchParams({
|
|
144
|
+
secret,
|
|
145
|
+
issuer,
|
|
146
|
+
algorithm: 'SHA1',
|
|
147
|
+
digits: String(digits),
|
|
148
|
+
period: String(period),
|
|
149
|
+
})
|
|
150
|
+
return `otpauth://totp/${label}?${params}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Recovery codes
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/** Generate a set of single-use recovery codes (8-char hex each). */
|
|
158
|
+
export function generateRecoveryCodes(count: number): string[] {
|
|
159
|
+
const codes: string[] = []
|
|
160
|
+
for (let i = 0; i < count; i++) {
|
|
161
|
+
const bytes = crypto.getRandomValues(new Uint8Array(4))
|
|
162
|
+
codes.push(Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''))
|
|
163
|
+
}
|
|
164
|
+
return codes
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Timing-safe string comparison
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
172
|
+
if (a.length !== b.length) return false
|
|
173
|
+
return nodeTimingSafeEqual(Buffer.from(a), Buffer.from(b))
|
|
174
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Feature flags
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export type Feature =
|
|
8
|
+
| 'registration'
|
|
9
|
+
| 'login'
|
|
10
|
+
| 'logout'
|
|
11
|
+
| 'password-reset'
|
|
12
|
+
| 'email-verification'
|
|
13
|
+
| 'two-factor'
|
|
14
|
+
| 'password-confirmation'
|
|
15
|
+
| 'update-password'
|
|
16
|
+
| 'update-profile'
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Actions — the user-provided contract
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface RegistrationData {
|
|
23
|
+
name: string
|
|
24
|
+
email: string
|
|
25
|
+
password: string
|
|
26
|
+
[key: string]: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BastionActions<TUser = unknown> {
|
|
30
|
+
/** Create and persist a new user. Password is raw — hash it yourself. */
|
|
31
|
+
createUser(data: RegistrationData): Promise<TUser>
|
|
32
|
+
|
|
33
|
+
/** Find a user by email address. Return null if not found. */
|
|
34
|
+
findByEmail(email: string): Promise<TUser | null>
|
|
35
|
+
|
|
36
|
+
/** Find a user by primary key. Return null if not found. */
|
|
37
|
+
findById(id: string | number): Promise<TUser | null>
|
|
38
|
+
|
|
39
|
+
/** Return the stored password hash for verification. */
|
|
40
|
+
passwordHashOf(user: TUser): string
|
|
41
|
+
|
|
42
|
+
/** Return the user's email address. */
|
|
43
|
+
emailOf(user: TUser): string
|
|
44
|
+
|
|
45
|
+
/** Persist a new password. Password is raw — hash it yourself. */
|
|
46
|
+
updatePassword(user: TUser, newPassword: string): Promise<void>
|
|
47
|
+
|
|
48
|
+
// ─── Email verification (required when feature is enabled) ───────────
|
|
49
|
+
|
|
50
|
+
/** Whether the user has verified their email. */
|
|
51
|
+
isEmailVerified?(user: TUser): boolean
|
|
52
|
+
|
|
53
|
+
/** Mark the user's email as verified. */
|
|
54
|
+
markEmailVerified?(user: TUser): Promise<void>
|
|
55
|
+
|
|
56
|
+
// ─── Two-factor authentication (required when feature is enabled) ────
|
|
57
|
+
|
|
58
|
+
/** Return the TOTP secret, or null if 2FA is not enabled. */
|
|
59
|
+
twoFactorSecretOf?(user: TUser): string | null
|
|
60
|
+
|
|
61
|
+
/** Persist the TOTP secret (null to clear). */
|
|
62
|
+
setTwoFactorSecret?(user: TUser, secret: string | null): Promise<void>
|
|
63
|
+
|
|
64
|
+
/** Return the user's recovery codes. */
|
|
65
|
+
recoveryCodesOf?(user: TUser): string[]
|
|
66
|
+
|
|
67
|
+
/** Persist new recovery codes. */
|
|
68
|
+
setRecoveryCodes?(user: TUser, codes: string[]): Promise<void>
|
|
69
|
+
|
|
70
|
+
// ─── Profile update (required when feature is enabled) ───────────────
|
|
71
|
+
|
|
72
|
+
/** Update the user's profile fields. */
|
|
73
|
+
updateProfile?(user: TUser, data: Record<string, unknown>): Promise<void>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Configuration
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
export interface RateLimitConfig {
|
|
81
|
+
/** Maximum requests in the window. */
|
|
82
|
+
max: number
|
|
83
|
+
/** Window duration in seconds. */
|
|
84
|
+
window: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface BastionConfig {
|
|
88
|
+
features: Feature[]
|
|
89
|
+
prefix: string
|
|
90
|
+
mode: 'session' | 'token'
|
|
91
|
+
rateLimit: {
|
|
92
|
+
login: RateLimitConfig
|
|
93
|
+
register: RateLimitConfig
|
|
94
|
+
forgotPassword: RateLimitConfig
|
|
95
|
+
verifyEmail: RateLimitConfig
|
|
96
|
+
twoFactor: RateLimitConfig
|
|
97
|
+
}
|
|
98
|
+
passwords: {
|
|
99
|
+
expiration: number // minutes
|
|
100
|
+
}
|
|
101
|
+
verification: {
|
|
102
|
+
expiration: number // minutes
|
|
103
|
+
}
|
|
104
|
+
confirmation: {
|
|
105
|
+
timeout: number // seconds
|
|
106
|
+
}
|
|
107
|
+
twoFactor: {
|
|
108
|
+
issuer: string
|
|
109
|
+
digits: number
|
|
110
|
+
period: number
|
|
111
|
+
recoveryCodes: number
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Events
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export interface BastionEvent<TUser = unknown> {
|
|
120
|
+
user: TUser
|
|
121
|
+
ctx: Context
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const BastionEvents = {
|
|
125
|
+
REGISTERED: 'bastion:registered',
|
|
126
|
+
LOGIN: 'bastion:login',
|
|
127
|
+
LOGOUT: 'bastion:logout',
|
|
128
|
+
PASSWORD_RESET: 'bastion:password-reset',
|
|
129
|
+
EMAIL_VERIFIED: 'bastion:email-verified',
|
|
130
|
+
TWO_FACTOR_ENABLED: 'bastion:two-factor-enabled',
|
|
131
|
+
TWO_FACTOR_DISABLED: 'bastion:two-factor-disabled',
|
|
132
|
+
PASSWORD_CONFIRMED: 'bastion:password-confirmed',
|
|
133
|
+
PASSWORD_UPDATED: 'bastion:password-updated',
|
|
134
|
+
PROFILE_UPDATED: 'bastion:profile-updated',
|
|
135
|
+
} as const
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { defineActions } from '@stravigor/bastion'
|
|
2
|
+
import { encrypt } from '@stravigor/core/encryption'
|
|
3
|
+
// import { User } from '../models/user'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bastion actions — tell Bastion how your User model works.
|
|
7
|
+
*
|
|
8
|
+
* These are the only functions you need to implement. Bastion handles
|
|
9
|
+
* the routing, token generation, rate limiting, and event emission.
|
|
10
|
+
*/
|
|
11
|
+
export default defineActions({
|
|
12
|
+
// ── Core (required) ──────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
async createUser(data) {
|
|
15
|
+
// return await User.create({
|
|
16
|
+
// name: data.name,
|
|
17
|
+
// email: data.email,
|
|
18
|
+
// password: await encrypt.hash(data.password),
|
|
19
|
+
// })
|
|
20
|
+
throw new Error('Implement createUser in actions/bastion.ts')
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
async findByEmail(email) {
|
|
24
|
+
// return await User.query().where('email', email).first()
|
|
25
|
+
throw new Error('Implement findByEmail in actions/bastion.ts')
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
async findById(id) {
|
|
29
|
+
// return await User.find(id)
|
|
30
|
+
throw new Error('Implement findById in actions/bastion.ts')
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
passwordHashOf(user: any) {
|
|
34
|
+
return user.password
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
emailOf(user: any) {
|
|
38
|
+
return user.email
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async updatePassword(user: any, newPassword) {
|
|
42
|
+
user.password = await encrypt.hash(newPassword)
|
|
43
|
+
await user.save()
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// ── Email verification (uncomment when feature is enabled) ───────────
|
|
47
|
+
|
|
48
|
+
// isEmailVerified(user: any) {
|
|
49
|
+
// return user.emailVerifiedAt !== null
|
|
50
|
+
// },
|
|
51
|
+
|
|
52
|
+
// async markEmailVerified(user: any) {
|
|
53
|
+
// user.emailVerifiedAt = new Date()
|
|
54
|
+
// await user.save()
|
|
55
|
+
// },
|
|
56
|
+
|
|
57
|
+
// ── Two-factor authentication (uncomment when feature is enabled) ────
|
|
58
|
+
|
|
59
|
+
// twoFactorSecretOf(user: any) {
|
|
60
|
+
// return user.twoFactorSecret ?? null
|
|
61
|
+
// },
|
|
62
|
+
|
|
63
|
+
// async setTwoFactorSecret(user: any, secret) {
|
|
64
|
+
// user.twoFactorSecret = secret
|
|
65
|
+
// await user.save()
|
|
66
|
+
// },
|
|
67
|
+
|
|
68
|
+
// recoveryCodesOf(user: any) {
|
|
69
|
+
// return user.recoveryCodes ?? []
|
|
70
|
+
// },
|
|
71
|
+
|
|
72
|
+
// async setRecoveryCodes(user: any, codes) {
|
|
73
|
+
// user.recoveryCodes = codes
|
|
74
|
+
// await user.save()
|
|
75
|
+
// },
|
|
76
|
+
|
|
77
|
+
// ── Profile update (uncomment when feature is enabled) ───────────────
|
|
78
|
+
|
|
79
|
+
// async updateProfile(user: any, data) {
|
|
80
|
+
// Object.assign(user, data)
|
|
81
|
+
// await user.save()
|
|
82
|
+
// },
|
|
83
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { env } from '@stravigor/core/helpers'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
// Toggle features on/off. Uncomment to enable.
|
|
5
|
+
features: [
|
|
6
|
+
'registration',
|
|
7
|
+
'login',
|
|
8
|
+
'logout',
|
|
9
|
+
'password-reset',
|
|
10
|
+
// 'email-verification',
|
|
11
|
+
// 'two-factor',
|
|
12
|
+
// 'password-confirmation',
|
|
13
|
+
// 'update-password',
|
|
14
|
+
// 'update-profile',
|
|
15
|
+
],
|
|
16
|
+
|
|
17
|
+
// Route prefix — '' means routes live at /login, /register, etc.
|
|
18
|
+
prefix: '',
|
|
19
|
+
|
|
20
|
+
// Auth mode: 'session' (cookie-based) or 'token' (access tokens)
|
|
21
|
+
mode: 'session',
|
|
22
|
+
|
|
23
|
+
// Rate limiting per flow (max attempts per window in seconds)
|
|
24
|
+
rateLimit: {
|
|
25
|
+
login: { max: 5, window: 60 },
|
|
26
|
+
register: { max: 3, window: 60 },
|
|
27
|
+
forgotPassword: { max: 3, window: 60 },
|
|
28
|
+
verifyEmail: { max: 3, window: 60 },
|
|
29
|
+
twoFactor: { max: 5, window: 60 },
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
// Password reset link lifetime (minutes)
|
|
33
|
+
passwords: {
|
|
34
|
+
expiration: 60,
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// Email verification link lifetime (minutes)
|
|
38
|
+
verification: {
|
|
39
|
+
expiration: 60,
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// Password confirmation timeout (seconds) — how long before the user
|
|
43
|
+
// must re-enter their password for sensitive operations
|
|
44
|
+
confirmation: {
|
|
45
|
+
timeout: 10_800, // 3 hours
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Two-factor authentication (TOTP)
|
|
49
|
+
twoFactor: {
|
|
50
|
+
issuer: env('APP_NAME', 'Strav'),
|
|
51
|
+
digits: 6,
|
|
52
|
+
period: 30,
|
|
53
|
+
recoveryCodes: 8,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{{-- Password Reset Email --}}
|
|
2
|
+
|
|
3
|
+
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
|
|
4
|
+
<h2 style="color: #1a1a1a; margin-bottom: 24px;">Reset Your Password</h2>
|
|
5
|
+
|
|
6
|
+
<p style="color: #4a4a4a; line-height: 1.6;">
|
|
7
|
+
You requested a password reset. Click the button below to choose a new password.
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<div style="text-align: center; margin: 32px 0;">
|
|
11
|
+
<a href="{{ resetUrl }}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 6px; font-weight: 600;">
|
|
12
|
+
Reset Password
|
|
13
|
+
</a>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
|
|
17
|
+
This link will expire in {{ expiration }} minutes. If you didn't request a password reset, you can safely ignore this email.
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 32px 0;" />
|
|
21
|
+
|
|
22
|
+
<p style="color: #9ca3af; font-size: 12px;">
|
|
23
|
+
If the button doesn't work, copy and paste this URL into your browser:<br />
|
|
24
|
+
<span style="color: #6b7280;">{{ resetUrl }}</span>
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{{-- Email Verification --}}
|
|
2
|
+
|
|
3
|
+
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
|
|
4
|
+
<h2 style="color: #1a1a1a; margin-bottom: 24px;">Verify Your Email</h2>
|
|
5
|
+
|
|
6
|
+
<p style="color: #4a4a4a; line-height: 1.6;">
|
|
7
|
+
Thanks for signing up! Please verify your email address by clicking the button below.
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<div style="text-align: center; margin: 32px 0;">
|
|
11
|
+
<a href="{{ verifyUrl }}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 6px; font-weight: 600;">
|
|
12
|
+
Verify Email
|
|
13
|
+
</a>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
|
|
17
|
+
This link will expire in {{ expiration }} minutes. If you didn't create an account, you can safely ignore this email.
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 32px 0;" />
|
|
21
|
+
|
|
22
|
+
<p style="color: #9ca3af; font-size: 12px;">
|
|
23
|
+
If the button doesn't work, copy and paste this URL into your browser:<br />
|
|
24
|
+
<span style="color: #6b7280;">{{ verifyUrl }}</span>
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
package/tsconfig.json
ADDED