@ma7moudsalama/falak-app 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/README.md +378 -0
- package/bin/falak.js +157 -0
- package/index.js +5 -0
- package/lib/scaffold.js +23 -0
- package/package.json +46 -0
- package/template/_env.example +34 -0
- package/template/_gitignore +8 -0
- package/template/firebase-rules.json +36 -0
- package/template/index.html +21 -0
- package/template/package.json +36 -0
- package/template/postcss.config.js +6 -0
- package/template/public/favicon.svg +5 -0
- package/template/src/App.vue +95 -0
- package/template/src/assets/main.css +100 -0
- package/template/src/components/layout/AppLayout.vue +163 -0
- package/template/src/composables/useAuth.js +393 -0
- package/template/src/composables/useCrypto.js +153 -0
- package/template/src/composables/useDatabase.js +341 -0
- package/template/src/composables/useGroq.js +237 -0
- package/template/src/composables/usePaymob.js +392 -0
- package/template/src/firebase/index.js +87 -0
- package/template/src/i18n/index.js +66 -0
- package/template/src/i18n/locales/ar.json +121 -0
- package/template/src/i18n/locales/en.json +121 -0
- package/template/src/main.js +59 -0
- package/template/src/router/index.js +127 -0
- package/template/src/stores/auth.js +14 -0
- package/template/src/views/AdminView.vue +67 -0
- package/template/src/views/DashboardView.vue +253 -0
- package/template/src/views/HomeView.vue +13 -0
- package/template/src/views/NotFoundView.vue +8 -0
- package/template/src/views/ProfileView.vue +134 -0
- package/template/src/views/auth/ForgotView.vue +57 -0
- package/template/src/views/auth/LoginView.vue +169 -0
- package/template/src/views/auth/RegisterView.vue +103 -0
- package/template/tailwind.config.js +41 -0
- package/template/vite.config.js +29 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAuth — Firebase Authentication Composable
|
|
3
|
+
* ──────────────────────────────────────────────
|
|
4
|
+
* Supports:
|
|
5
|
+
* • Email + Password (register / login / reset)
|
|
6
|
+
* • Google OAuth
|
|
7
|
+
* • Facebook OAuth
|
|
8
|
+
* • User roles stored in RTDB: /users/{uid}/role
|
|
9
|
+
* • Session data mirrored to RTDB: /sessions/{uid}
|
|
10
|
+
* • Reactive currentUser, userRole, isAuthenticated
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ref, computed, readonly } from 'vue'
|
|
14
|
+
import {
|
|
15
|
+
createUserWithEmailAndPassword,
|
|
16
|
+
signInWithEmailAndPassword,
|
|
17
|
+
signOut,
|
|
18
|
+
sendPasswordResetEmail,
|
|
19
|
+
sendEmailVerification,
|
|
20
|
+
updateProfile,
|
|
21
|
+
updatePassword,
|
|
22
|
+
reauthenticateWithCredential,
|
|
23
|
+
EmailAuthProvider,
|
|
24
|
+
GoogleAuthProvider,
|
|
25
|
+
FacebookAuthProvider,
|
|
26
|
+
signInWithPopup,
|
|
27
|
+
signInWithRedirect,
|
|
28
|
+
getRedirectResult,
|
|
29
|
+
onAuthStateChanged,
|
|
30
|
+
deleteUser
|
|
31
|
+
} from 'firebase/auth'
|
|
32
|
+
import {
|
|
33
|
+
ref as dbRef,
|
|
34
|
+
set,
|
|
35
|
+
get,
|
|
36
|
+
update,
|
|
37
|
+
onValue,
|
|
38
|
+
serverTimestamp,
|
|
39
|
+
onDisconnect,
|
|
40
|
+
remove
|
|
41
|
+
} from 'firebase/database'
|
|
42
|
+
import { auth, rtdb } from '@/firebase/index.js'
|
|
43
|
+
import { useCrypto } from '@/composables/useCrypto.js'
|
|
44
|
+
|
|
45
|
+
// ── Singleton state (shared across all composable calls) ──
|
|
46
|
+
const currentUser = ref(null)
|
|
47
|
+
const userProfile = ref(null)
|
|
48
|
+
const userRole = ref(null)
|
|
49
|
+
const isAuthReady = ref(false)
|
|
50
|
+
const authError = ref(null)
|
|
51
|
+
const isLoading = ref(false)
|
|
52
|
+
|
|
53
|
+
let roleListener = null
|
|
54
|
+
let sessionRef = null
|
|
55
|
+
|
|
56
|
+
// ── Roles ──────────────────────────────────────
|
|
57
|
+
export const ROLES = {
|
|
58
|
+
SUPER_ADMIN: 'super_admin',
|
|
59
|
+
ADMIN: 'admin',
|
|
60
|
+
USER: 'user',
|
|
61
|
+
GUEST: 'guest'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Role permissions map ───────────────────────
|
|
65
|
+
const ROLE_PERMISSIONS = {
|
|
66
|
+
super_admin: ['read', 'write', 'delete', 'manage_users', 'manage_roles'],
|
|
67
|
+
admin: ['read', 'write', 'delete', 'manage_users'],
|
|
68
|
+
user: ['read', 'write'],
|
|
69
|
+
guest: ['read']
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Initialize Auth State Listener ────────────
|
|
73
|
+
function initAuthListener() {
|
|
74
|
+
onAuthStateChanged(auth, async (user) => {
|
|
75
|
+
if (user) {
|
|
76
|
+
currentUser.value = user
|
|
77
|
+
await syncUserProfile(user)
|
|
78
|
+
listenToRole(user.uid)
|
|
79
|
+
await updateSession(user.uid, true)
|
|
80
|
+
} else {
|
|
81
|
+
if (sessionRef) {
|
|
82
|
+
await updateSession(currentUser.value?.uid, false).catch(() => {})
|
|
83
|
+
}
|
|
84
|
+
currentUser.value = null
|
|
85
|
+
userProfile.value = null
|
|
86
|
+
userRole.value = null
|
|
87
|
+
if (roleListener) { roleListener(); roleListener = null }
|
|
88
|
+
}
|
|
89
|
+
isAuthReady.value = true
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Sync user profile from RTDB ───────────────
|
|
94
|
+
async function syncUserProfile(user) {
|
|
95
|
+
const snap = await get(dbRef(rtdb, `users/${user.uid}`))
|
|
96
|
+
if (snap.exists()) {
|
|
97
|
+
userProfile.value = snap.val()
|
|
98
|
+
} else {
|
|
99
|
+
// First-time: create profile
|
|
100
|
+
const profile = {
|
|
101
|
+
uid: user.uid,
|
|
102
|
+
email: user.email,
|
|
103
|
+
displayName: user.displayName || '',
|
|
104
|
+
photoURL: user.photoURL || '',
|
|
105
|
+
role: ROLES.USER,
|
|
106
|
+
createdAt: serverTimestamp(),
|
|
107
|
+
provider: user.providerData[0]?.providerId || 'email'
|
|
108
|
+
}
|
|
109
|
+
await set(dbRef(rtdb, `users/${user.uid}`), profile)
|
|
110
|
+
userProfile.value = profile
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Listen to role changes in real-time ───────
|
|
115
|
+
function listenToRole(uid) {
|
|
116
|
+
if (roleListener) roleListener()
|
|
117
|
+
const roleDbRef = dbRef(rtdb, `users/${uid}/role`)
|
|
118
|
+
roleListener = onValue(roleDbRef, (snap) => {
|
|
119
|
+
userRole.value = snap.val() || ROLES.USER
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Session management in RTDB ─────────────────
|
|
124
|
+
async function updateSession(uid, online) {
|
|
125
|
+
if (!uid) return
|
|
126
|
+
sessionRef = dbRef(rtdb, `sessions/${uid}`)
|
|
127
|
+
const data = {
|
|
128
|
+
online,
|
|
129
|
+
lastSeen: serverTimestamp(),
|
|
130
|
+
userAgent: navigator.userAgent,
|
|
131
|
+
...(online ? { loginAt: serverTimestamp() } : {})
|
|
132
|
+
}
|
|
133
|
+
await set(sessionRef, data)
|
|
134
|
+
// Auto-mark offline on disconnect
|
|
135
|
+
if (online) {
|
|
136
|
+
onDisconnect(sessionRef).update({ online: false, lastSeen: serverTimestamp() })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Initialize on first import ─────────────────
|
|
141
|
+
initAuthListener()
|
|
142
|
+
|
|
143
|
+
// ── Composable ────────────────────────────────
|
|
144
|
+
export function useAuth() {
|
|
145
|
+
const { encrypt } = useCrypto()
|
|
146
|
+
|
|
147
|
+
// ── Computed ──────────────────────────────
|
|
148
|
+
const isAuthenticated = computed(() => !!currentUser.value)
|
|
149
|
+
const isAdmin = computed(() => [ROLES.ADMIN, ROLES.SUPER_ADMIN].includes(userRole.value))
|
|
150
|
+
const isSuperAdmin = computed(() => userRole.value === ROLES.SUPER_ADMIN)
|
|
151
|
+
const userPermissions = computed(() => ROLE_PERMISSIONS[userRole.value] || [])
|
|
152
|
+
|
|
153
|
+
function hasPermission(permission) {
|
|
154
|
+
return userPermissions.value.includes(permission)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function hasRole(...roles) {
|
|
158
|
+
return roles.includes(userRole.value)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Email / Password ──────────────────────
|
|
162
|
+
async function register({ email, password, displayName }) {
|
|
163
|
+
isLoading.value = true
|
|
164
|
+
authError.value = null
|
|
165
|
+
try {
|
|
166
|
+
const cred = await createUserWithEmailAndPassword(auth, email, password)
|
|
167
|
+
if (displayName) {
|
|
168
|
+
await updateProfile(cred.user, { displayName })
|
|
169
|
+
}
|
|
170
|
+
await sendEmailVerification(cred.user)
|
|
171
|
+
return { success: true, user: cred.user }
|
|
172
|
+
} catch (err) {
|
|
173
|
+
authError.value = formatAuthError(err)
|
|
174
|
+
return { success: false, error: authError.value }
|
|
175
|
+
} finally {
|
|
176
|
+
isLoading.value = false
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function login({ email, password }) {
|
|
181
|
+
isLoading.value = true
|
|
182
|
+
authError.value = null
|
|
183
|
+
try {
|
|
184
|
+
const cred = await signInWithEmailAndPassword(auth, email, password)
|
|
185
|
+
return { success: true, user: cred.user }
|
|
186
|
+
} catch (err) {
|
|
187
|
+
authError.value = formatAuthError(err)
|
|
188
|
+
return { success: false, error: authError.value }
|
|
189
|
+
} finally {
|
|
190
|
+
isLoading.value = false
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function resetPassword(email) {
|
|
195
|
+
isLoading.value = true
|
|
196
|
+
authError.value = null
|
|
197
|
+
try {
|
|
198
|
+
await sendPasswordResetEmail(auth, email)
|
|
199
|
+
return { success: true }
|
|
200
|
+
} catch (err) {
|
|
201
|
+
authError.value = formatAuthError(err)
|
|
202
|
+
return { success: false, error: authError.value }
|
|
203
|
+
} finally {
|
|
204
|
+
isLoading.value = false
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function changePassword({ currentPassword, newPassword }) {
|
|
209
|
+
isLoading.value = true
|
|
210
|
+
authError.value = null
|
|
211
|
+
try {
|
|
212
|
+
const credential = EmailAuthProvider.credential(
|
|
213
|
+
currentUser.value.email,
|
|
214
|
+
currentPassword
|
|
215
|
+
)
|
|
216
|
+
await reauthenticateWithCredential(currentUser.value, credential)
|
|
217
|
+
await updatePassword(currentUser.value, newPassword)
|
|
218
|
+
return { success: true }
|
|
219
|
+
} catch (err) {
|
|
220
|
+
authError.value = formatAuthError(err)
|
|
221
|
+
return { success: false, error: authError.value }
|
|
222
|
+
} finally {
|
|
223
|
+
isLoading.value = false
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Google OAuth ──────────────────────────
|
|
228
|
+
async function loginWithGoogle(useRedirect = false) {
|
|
229
|
+
isLoading.value = true
|
|
230
|
+
authError.value = null
|
|
231
|
+
const provider = new GoogleAuthProvider()
|
|
232
|
+
provider.addScope('profile')
|
|
233
|
+
provider.addScope('email')
|
|
234
|
+
try {
|
|
235
|
+
let cred
|
|
236
|
+
if (useRedirect) {
|
|
237
|
+
await signInWithRedirect(auth, provider)
|
|
238
|
+
cred = await getRedirectResult(auth)
|
|
239
|
+
} else {
|
|
240
|
+
cred = await signInWithPopup(auth, provider)
|
|
241
|
+
}
|
|
242
|
+
return { success: true, user: cred?.user }
|
|
243
|
+
} catch (err) {
|
|
244
|
+
authError.value = formatAuthError(err)
|
|
245
|
+
return { success: false, error: authError.value }
|
|
246
|
+
} finally {
|
|
247
|
+
isLoading.value = false
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Facebook OAuth ────────────────────────
|
|
252
|
+
async function loginWithFacebook(useRedirect = false) {
|
|
253
|
+
isLoading.value = true
|
|
254
|
+
authError.value = null
|
|
255
|
+
const provider = new FacebookAuthProvider()
|
|
256
|
+
provider.addScope('email')
|
|
257
|
+
provider.addScope('public_profile')
|
|
258
|
+
try {
|
|
259
|
+
let cred
|
|
260
|
+
if (useRedirect) {
|
|
261
|
+
await signInWithRedirect(auth, provider)
|
|
262
|
+
cred = await getRedirectResult(auth)
|
|
263
|
+
} else {
|
|
264
|
+
cred = await signInWithPopup(auth, provider)
|
|
265
|
+
}
|
|
266
|
+
return { success: true, user: cred?.user }
|
|
267
|
+
} catch (err) {
|
|
268
|
+
authError.value = formatAuthError(err)
|
|
269
|
+
return { success: false, error: authError.value }
|
|
270
|
+
} finally {
|
|
271
|
+
isLoading.value = false
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Sign Out ──────────────────────────────
|
|
276
|
+
async function logout() {
|
|
277
|
+
isLoading.value = true
|
|
278
|
+
try {
|
|
279
|
+
if (currentUser.value) {
|
|
280
|
+
await updateSession(currentUser.value.uid, false)
|
|
281
|
+
}
|
|
282
|
+
await signOut(auth)
|
|
283
|
+
return { success: true }
|
|
284
|
+
} catch (err) {
|
|
285
|
+
authError.value = formatAuthError(err)
|
|
286
|
+
return { success: false, error: authError.value }
|
|
287
|
+
} finally {
|
|
288
|
+
isLoading.value = false
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Update Profile ────────────────────────
|
|
293
|
+
async function updateUserProfile(data) {
|
|
294
|
+
isLoading.value = true
|
|
295
|
+
try {
|
|
296
|
+
if (data.displayName || data.photoURL) {
|
|
297
|
+
await updateProfile(currentUser.value, {
|
|
298
|
+
displayName: data.displayName,
|
|
299
|
+
photoURL: data.photoURL
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
// Persist extra fields to RTDB
|
|
303
|
+
await update(dbRef(rtdb, `users/${currentUser.value.uid}`), {
|
|
304
|
+
...data,
|
|
305
|
+
updatedAt: serverTimestamp()
|
|
306
|
+
})
|
|
307
|
+
userProfile.value = { ...userProfile.value, ...data }
|
|
308
|
+
return { success: true }
|
|
309
|
+
} catch (err) {
|
|
310
|
+
return { success: false, error: err.message }
|
|
311
|
+
} finally {
|
|
312
|
+
isLoading.value = false
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Admin: Set Role ───────────────────────
|
|
317
|
+
async function setUserRole(uid, role) {
|
|
318
|
+
if (!isSuperAdmin.value && !isAdmin.value) {
|
|
319
|
+
return { success: false, error: 'Insufficient permissions' }
|
|
320
|
+
}
|
|
321
|
+
if (!Object.values(ROLES).includes(role)) {
|
|
322
|
+
return { success: false, error: `Invalid role: ${role}` }
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
await update(dbRef(rtdb, `users/${uid}`), { role })
|
|
326
|
+
return { success: true }
|
|
327
|
+
} catch (err) {
|
|
328
|
+
return { success: false, error: err.message }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── Admin: Delete User ────────────────────
|
|
333
|
+
async function deleteAccount() {
|
|
334
|
+
try {
|
|
335
|
+
const uid = currentUser.value.uid
|
|
336
|
+
await remove(dbRef(rtdb, `users/${uid}`))
|
|
337
|
+
await remove(dbRef(rtdb, `sessions/${uid}`))
|
|
338
|
+
await deleteUser(currentUser.value)
|
|
339
|
+
return { success: true }
|
|
340
|
+
} catch (err) {
|
|
341
|
+
return { success: false, error: err.message }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Auth Error Formatter ──────────────────
|
|
346
|
+
function formatAuthError(err) {
|
|
347
|
+
const map = {
|
|
348
|
+
'auth/user-not-found': 'No account found with this email.',
|
|
349
|
+
'auth/wrong-password': 'Incorrect password.',
|
|
350
|
+
'auth/email-already-in-use': 'This email is already registered.',
|
|
351
|
+
'auth/weak-password': 'Password must be at least 6 characters.',
|
|
352
|
+
'auth/invalid-email': 'Invalid email address.',
|
|
353
|
+
'auth/popup-closed-by-user': 'Sign-in popup was closed.',
|
|
354
|
+
'auth/account-exists-with-different-credential':
|
|
355
|
+
'An account already exists with this email. Try another login method.',
|
|
356
|
+
'auth/too-many-requests': 'Too many attempts. Please try again later.',
|
|
357
|
+
'auth/network-request-failed': 'Network error. Check your connection.',
|
|
358
|
+
'auth/requires-recent-login': 'Please log in again to perform this action.'
|
|
359
|
+
}
|
|
360
|
+
return map[err.code] || err.message
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
// State (readonly to prevent external mutation)
|
|
365
|
+
currentUser: readonly(currentUser),
|
|
366
|
+
userProfile: readonly(userProfile),
|
|
367
|
+
userRole: readonly(userRole),
|
|
368
|
+
isAuthReady: readonly(isAuthReady),
|
|
369
|
+
authError: readonly(authError),
|
|
370
|
+
isLoading: readonly(isLoading),
|
|
371
|
+
|
|
372
|
+
// Computed
|
|
373
|
+
isAuthenticated,
|
|
374
|
+
isAdmin,
|
|
375
|
+
isSuperAdmin,
|
|
376
|
+
userPermissions,
|
|
377
|
+
|
|
378
|
+
// Methods
|
|
379
|
+
register,
|
|
380
|
+
login,
|
|
381
|
+
logout,
|
|
382
|
+
resetPassword,
|
|
383
|
+
changePassword,
|
|
384
|
+
loginWithGoogle,
|
|
385
|
+
loginWithFacebook,
|
|
386
|
+
updateUserProfile,
|
|
387
|
+
setUserRole,
|
|
388
|
+
deleteAccount,
|
|
389
|
+
hasPermission,
|
|
390
|
+
hasRole,
|
|
391
|
+
ROLES
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCrypto — Encryption & Decryption Composable
|
|
3
|
+
* ────────────────────────────────────────────────
|
|
4
|
+
* Uses AES-256 via crypto-js.
|
|
5
|
+
* Encrypt sensitive data before storing in Firebase or localStorage.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const { encrypt, decrypt, encryptObject, decryptObject, hash } = useCrypto()
|
|
9
|
+
* const cipher = encrypt('hello world')
|
|
10
|
+
* const plain = decrypt(cipher) // → 'hello world'
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import CryptoJS from 'crypto-js'
|
|
14
|
+
|
|
15
|
+
const SECRET = import.meta.env.VITE_ENCRYPTION_KEY || 'fallback_dev_key_change_me'
|
|
16
|
+
|
|
17
|
+
// ── Derive a key from the secret (PBKDF2) ─────
|
|
18
|
+
function deriveKey(salt = 'falak_salt') {
|
|
19
|
+
return CryptoJS.PBKDF2(SECRET, salt, { keySize: 256 / 32, iterations: 1000 })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useCrypto() {
|
|
23
|
+
/**
|
|
24
|
+
* Encrypt a string value.
|
|
25
|
+
* Returns a base64-encoded string prefixed with the IV.
|
|
26
|
+
*/
|
|
27
|
+
function encrypt(plaintext, customKey = null) {
|
|
28
|
+
if (plaintext === null || plaintext === undefined) return plaintext
|
|
29
|
+
try {
|
|
30
|
+
const key = customKey ? CryptoJS.enc.Utf8.parse(customKey) : deriveKey()
|
|
31
|
+
const iv = CryptoJS.lib.WordArray.random(128 / 8)
|
|
32
|
+
const encrypted = CryptoJS.AES.encrypt(String(plaintext), key, {
|
|
33
|
+
iv,
|
|
34
|
+
mode: CryptoJS.mode.CBC,
|
|
35
|
+
padding: CryptoJS.pad.Pkcs7
|
|
36
|
+
})
|
|
37
|
+
// Prepend IV so decrypt can use it
|
|
38
|
+
const combined = iv.toString(CryptoJS.enc.Hex) + ':' + encrypted.toString()
|
|
39
|
+
return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(combined))
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error('[useCrypto] encrypt error:', err)
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Decrypt a value encrypted with encrypt().
|
|
48
|
+
*/
|
|
49
|
+
function decrypt(ciphertext, customKey = null) {
|
|
50
|
+
if (!ciphertext) return ciphertext
|
|
51
|
+
try {
|
|
52
|
+
const decoded = CryptoJS.enc.Base64.parse(ciphertext).toString(CryptoJS.enc.Utf8)
|
|
53
|
+
const [ivHex, encryptedStr] = decoded.split(':')
|
|
54
|
+
const iv = CryptoJS.enc.Hex.parse(ivHex)
|
|
55
|
+
const key = customKey ? CryptoJS.enc.Utf8.parse(customKey) : deriveKey()
|
|
56
|
+
const decrypted = CryptoJS.AES.decrypt(encryptedStr, key, {
|
|
57
|
+
iv,
|
|
58
|
+
mode: CryptoJS.mode.CBC,
|
|
59
|
+
padding: CryptoJS.pad.Pkcs7
|
|
60
|
+
})
|
|
61
|
+
return decrypted.toString(CryptoJS.enc.Utf8)
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error('[useCrypto] decrypt error:', err)
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Encrypt a plain JS object → JSON string → encrypted.
|
|
70
|
+
*/
|
|
71
|
+
function encryptObject(obj, customKey = null) {
|
|
72
|
+
if (!obj) return null
|
|
73
|
+
return encrypt(JSON.stringify(obj), customKey)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Decrypt back to a JS object.
|
|
78
|
+
*/
|
|
79
|
+
function decryptObject(ciphertext, customKey = null) {
|
|
80
|
+
const str = decrypt(ciphertext, customKey)
|
|
81
|
+
if (!str) return null
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(str)
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Encrypt only specified fields of an object, leave others plain.
|
|
91
|
+
* Useful for partial encryption of Firebase records.
|
|
92
|
+
*
|
|
93
|
+
* @param {Object} obj - source object
|
|
94
|
+
* @param {string[]} fields - keys to encrypt
|
|
95
|
+
*/
|
|
96
|
+
function encryptFields(obj, fields = []) {
|
|
97
|
+
if (!obj) return obj
|
|
98
|
+
const result = { ...obj }
|
|
99
|
+
for (const field of fields) {
|
|
100
|
+
if (result[field] !== undefined) {
|
|
101
|
+
result[field] = encrypt(String(result[field]))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Decrypt only specified fields.
|
|
109
|
+
*/
|
|
110
|
+
function decryptFields(obj, fields = []) {
|
|
111
|
+
if (!obj) return obj
|
|
112
|
+
const result = { ...obj }
|
|
113
|
+
for (const field of fields) {
|
|
114
|
+
if (result[field]) {
|
|
115
|
+
result[field] = decrypt(result[field])
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return result
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One-way SHA256 hash (for passwords, tokens, etc.)
|
|
123
|
+
*/
|
|
124
|
+
function hash(value) {
|
|
125
|
+
return CryptoJS.SHA256(String(value)).toString(CryptoJS.enc.Hex)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* HMAC-SHA256 signature (used by Paymob, etc.)
|
|
130
|
+
*/
|
|
131
|
+
function hmac(message, secret = SECRET) {
|
|
132
|
+
return CryptoJS.HmacSHA256(String(message), secret).toString(CryptoJS.enc.Hex)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Generate a random secure token (hex string).
|
|
137
|
+
*/
|
|
138
|
+
function randomToken(bytes = 32) {
|
|
139
|
+
return CryptoJS.lib.WordArray.random(bytes).toString(CryptoJS.enc.Hex)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
encrypt,
|
|
144
|
+
decrypt,
|
|
145
|
+
encryptObject,
|
|
146
|
+
decryptObject,
|
|
147
|
+
encryptFields,
|
|
148
|
+
decryptFields,
|
|
149
|
+
hash,
|
|
150
|
+
hmac,
|
|
151
|
+
randomToken
|
|
152
|
+
}
|
|
153
|
+
}
|