@kyro-cms/admin 0.1.2
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/.astro/content.d.ts +154 -0
- package/.astro/settings.json +5 -0
- package/.astro/types.d.ts +2 -0
- package/astro.config.mjs +28 -0
- package/bun.lock +1374 -0
- package/dist/client/_astro/AdminLayout.DkDpng53.css +1 -0
- package/dist/client/_astro/AutoForm.3eJCmCJp.js +1 -0
- package/dist/client/_astro/client.DyczpTbx.js +9 -0
- package/dist/client/_astro/index.B02hbnpo.js +1 -0
- package/dist/client/fonts/Serotiva-Black.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Bold.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Medium.woff2 +0 -0
- package/dist/client/fonts/Serotiva-Regular.woff2 +0 -0
- package/dist/client/fonts/Serotiva-SemiBold.woff2 +0 -0
- package/dist/server/chunks/AdminLayout_D-_JeUqC.mjs +26 -0
- package/dist/server/chunks/_id__BzI_o0qT.mjs +50 -0
- package/dist/server/chunks/_id__Cd-jOuY3.mjs +238 -0
- package/dist/server/chunks/_id__DvbD--iR.mjs +992 -0
- package/dist/server/chunks/_id__vpVaEo16.mjs +128 -0
- package/dist/server/chunks/_virtual_astro_server-island-manifest_CQQ1F5PF.mjs +7 -0
- package/dist/server/chunks/_virtual_astro_session-driver_Bk3Q189E.mjs +4 -0
- package/dist/server/chunks/astro-component_Dbx3T2Nh.mjs +37 -0
- package/dist/server/chunks/audit-logs_DrnUMRvY.mjs +74 -0
- package/dist/server/chunks/config_CPXslElD.mjs +4221 -0
- package/dist/server/chunks/dataStore_Dl7cA2Qp.mjs +89 -0
- package/dist/server/chunks/index_CVqOkerS.mjs +2960 -0
- package/dist/server/chunks/index_CX8SQ4BF.mjs +55 -0
- package/dist/server/chunks/index_CYofDU51.mjs +58 -0
- package/dist/server/chunks/index_DdNRhuaM.mjs +55 -0
- package/dist/server/chunks/index_DupPvtIF.mjs +42 -0
- package/dist/server/chunks/index_YTS_M-B9.mjs +263 -0
- package/dist/server/chunks/index_YeCzuVps.mjs +53 -0
- package/dist/server/chunks/login_DLyqMRO8.mjs +93 -0
- package/dist/server/chunks/logout_CSbt5wea.mjs +50 -0
- package/dist/server/chunks/me_C04jlYhH.mjs +41 -0
- package/dist/server/chunks/new_BbQ9b55M.mjs +92 -0
- package/dist/server/chunks/node_9bvTewss.mjs +1014 -0
- package/dist/server/chunks/noop-entrypoint_BOlrdqWF.mjs +3 -0
- package/dist/server/chunks/sequence_9cl7AJy-.mjs +2503 -0
- package/dist/server/chunks/server_peBx9VXG.mjs +8117 -0
- package/dist/server/chunks/sharp_pmJ7nHES.mjs +142 -0
- package/dist/server/chunks/users_Dzddy_YR.mjs +137 -0
- package/dist/server/entry.mjs +5 -0
- package/dist/server/virtual_astro_middleware.mjs +48 -0
- package/package.json +33 -0
- package/public/fonts/Serotiva-Black.woff2 +0 -0
- package/public/fonts/Serotiva-Bold.woff2 +0 -0
- package/public/fonts/Serotiva-Medium.woff2 +0 -0
- package/public/fonts/Serotiva-Regular.woff2 +0 -0
- package/public/fonts/Serotiva-SemiBold.woff2 +0 -0
- package/src/collections/auth/index.ts +155 -0
- package/src/components/ActionBar.tsx +215 -0
- package/src/components/Admin.tsx +214 -0
- package/src/components/AutoForm.tsx +1123 -0
- package/src/components/BulkActionsBar.tsx +80 -0
- package/src/components/CreateView.tsx +99 -0
- package/src/components/DetailView.tsx +329 -0
- package/src/components/Icons.tsx +23 -0
- package/src/components/ListView.tsx +192 -0
- package/src/components/StatusBadge.tsx +76 -0
- package/src/components/ThemeProvider.tsx +155 -0
- package/src/components/VersionHistoryPanel.tsx +205 -0
- package/src/components/fields/CheckboxField.tsx +37 -0
- package/src/components/fields/DateField.tsx +42 -0
- package/src/components/fields/NumberField.tsx +44 -0
- package/src/components/fields/RelationshipField.tsx +87 -0
- package/src/components/fields/SelectField.tsx +56 -0
- package/src/components/fields/TextField.tsx +49 -0
- package/src/components/index.ts +30 -0
- package/src/components/layout/Breadcrumbs.tsx +36 -0
- package/src/components/layout/Header.tsx +37 -0
- package/src/components/layout/Layout.tsx +25 -0
- package/src/components/layout/Sidebar.tsx +462 -0
- package/src/components/ui/Badge.tsx +14 -0
- package/src/components/ui/Button.tsx +41 -0
- package/src/components/ui/Dropdown.tsx +82 -0
- package/src/components/ui/Modal.tsx +135 -0
- package/src/components/ui/SlidePanel.tsx +73 -0
- package/src/components/ui/Spinner.tsx +24 -0
- package/src/components/ui/Toast.tsx +78 -0
- package/src/layouts/AdminLayout.astro +197 -0
- package/src/lib/config.ts +68 -0
- package/src/lib/dataStore.ts +111 -0
- package/src/middleware.ts +48 -0
- package/src/pages/[collection]/[id].astro +176 -0
- package/src/pages/[collection]/index.astro +180 -0
- package/src/pages/api/[collection]/[id].ts +258 -0
- package/src/pages/api/[collection]/index.ts +289 -0
- package/src/pages/api/auth/[id].ts +142 -0
- package/src/pages/api/auth/audit-logs.ts +80 -0
- package/src/pages/api/auth/login.ts +101 -0
- package/src/pages/api/auth/logout.ts +48 -0
- package/src/pages/api/auth/me.ts +36 -0
- package/src/pages/api/auth/users.ts +150 -0
- package/src/pages/audit/index.astro +110 -0
- package/src/pages/index.astro +225 -0
- package/src/pages/roles/index.astro +114 -0
- package/src/pages/users/[id].astro +174 -0
- package/src/pages/users/index.astro +142 -0
- package/src/pages/users/new.astro +91 -0
- package/src/styles/main.css +1449 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,4221 @@
|
|
|
1
|
+
import 'ws';
|
|
2
|
+
import 'hono';
|
|
3
|
+
import bcrypt from 'bcryptjs';
|
|
4
|
+
import { pgTable, timestamp, jsonb, integer, boolean, uuid, varchar, uniqueIndex, index, text } from 'drizzle-orm/pg-core';
|
|
5
|
+
import 'postgres';
|
|
6
|
+
import 'bcrypt';
|
|
7
|
+
import jwt from 'jsonwebtoken';
|
|
8
|
+
import Redis2 from 'ioredis';
|
|
9
|
+
import nodemailer from 'nodemailer';
|
|
10
|
+
import { randomBytes } from 'crypto';
|
|
11
|
+
|
|
12
|
+
const users = pgTable(
|
|
13
|
+
"users",
|
|
14
|
+
{
|
|
15
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
16
|
+
email: varchar("email", { length: 255 }).notNull(),
|
|
17
|
+
passwordHash: varchar("password_hash", { length: 255 }),
|
|
18
|
+
role: varchar("role", { length: 50 }).notNull().default("customer"),
|
|
19
|
+
tenantId: uuid("tenant_id"),
|
|
20
|
+
emailVerified: boolean("email_verified").default(false),
|
|
21
|
+
locked: boolean("locked").default(false),
|
|
22
|
+
lastLogin: timestamp("last_login"),
|
|
23
|
+
failedLoginAttempts: integer("failed_login_attempts").default(0),
|
|
24
|
+
metadata: jsonb("metadata").$type(),
|
|
25
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
26
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
27
|
+
},
|
|
28
|
+
(table) => [
|
|
29
|
+
uniqueIndex("users_email_idx").on(table.email),
|
|
30
|
+
index("users_tenant_idx").on(table.tenantId),
|
|
31
|
+
index("users_role_idx").on(table.role)
|
|
32
|
+
]
|
|
33
|
+
);
|
|
34
|
+
const roles = pgTable(
|
|
35
|
+
"roles",
|
|
36
|
+
{
|
|
37
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
38
|
+
name: varchar("name", { length: 100 }).notNull().unique(),
|
|
39
|
+
level: integer("level").notNull().default(0),
|
|
40
|
+
inherits: text("inherits").array(),
|
|
41
|
+
description: text("description"),
|
|
42
|
+
permissions: jsonb("permissions").$type().default([]),
|
|
43
|
+
isSystem: boolean("is_system").default(false),
|
|
44
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
45
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
46
|
+
},
|
|
47
|
+
(table) => [index("roles_level_idx").on(table.level)]
|
|
48
|
+
);
|
|
49
|
+
pgTable(
|
|
50
|
+
"permissions",
|
|
51
|
+
{
|
|
52
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
53
|
+
roleId: uuid("role_id").references(() => roles.id, { onDelete: "cascade" }),
|
|
54
|
+
resource: varchar("resource", { length: 100 }).notNull(),
|
|
55
|
+
action: varchar("action", { length: 50 }).notNull(),
|
|
56
|
+
conditions: jsonb("conditions").$type(),
|
|
57
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
58
|
+
},
|
|
59
|
+
(table) => [
|
|
60
|
+
index("permissions_role_idx").on(table.roleId),
|
|
61
|
+
index("permissions_resource_idx").on(table.resource)
|
|
62
|
+
]
|
|
63
|
+
);
|
|
64
|
+
pgTable(
|
|
65
|
+
"sessions",
|
|
66
|
+
{
|
|
67
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
68
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
69
|
+
token: varchar("token", { length: 512 }).notNull().unique(),
|
|
70
|
+
refreshToken: varchar("refresh_token", { length: 512 }),
|
|
71
|
+
ipAddress: varchar("ip_address", { length: 45 }),
|
|
72
|
+
userAgent: text("user_agent"),
|
|
73
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
74
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
75
|
+
},
|
|
76
|
+
(table) => [
|
|
77
|
+
index("sessions_user_idx").on(table.userId),
|
|
78
|
+
index("sessions_token_idx").on(table.token),
|
|
79
|
+
index("sessions_expires_idx").on(table.expiresAt)
|
|
80
|
+
]
|
|
81
|
+
);
|
|
82
|
+
pgTable(
|
|
83
|
+
"audit_logs",
|
|
84
|
+
{
|
|
85
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
86
|
+
action: varchar("action", { length: 100 }).notNull(),
|
|
87
|
+
userId: uuid("user_id").references(() => users.id, {
|
|
88
|
+
onDelete: "set null"
|
|
89
|
+
}),
|
|
90
|
+
userEmail: varchar("user_email", { length: 255 }),
|
|
91
|
+
role: varchar("role", { length: 50 }),
|
|
92
|
+
resource: varchar("resource", { length: 100 }).notNull(),
|
|
93
|
+
resourceId: uuid("resource_id"),
|
|
94
|
+
changes: jsonb("changes").$type(),
|
|
95
|
+
ipAddress: varchar("ip_address", { length: 45 }),
|
|
96
|
+
userAgent: text("user_agent"),
|
|
97
|
+
success: boolean("success").notNull().default(true),
|
|
98
|
+
error: text("error"),
|
|
99
|
+
metadata: jsonb("metadata").$type(),
|
|
100
|
+
timestamp: timestamp("timestamp").defaultNow().notNull()
|
|
101
|
+
},
|
|
102
|
+
(table) => [
|
|
103
|
+
index("audit_logs_user_idx").on(table.userId),
|
|
104
|
+
index("audit_logs_action_idx").on(table.action),
|
|
105
|
+
index("audit_logs_resource_idx").on(table.resource),
|
|
106
|
+
index("audit_logs_timestamp_idx").on(table.timestamp)
|
|
107
|
+
]
|
|
108
|
+
);
|
|
109
|
+
pgTable(
|
|
110
|
+
"tenants",
|
|
111
|
+
{
|
|
112
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
113
|
+
name: varchar("name", { length: 255 }).notNull(),
|
|
114
|
+
slug: varchar("slug", { length: 100 }).notNull().unique(),
|
|
115
|
+
settings: jsonb("settings").$type().default({}),
|
|
116
|
+
isActive: boolean("is_active").default(true),
|
|
117
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
118
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
119
|
+
},
|
|
120
|
+
(table) => [uniqueIndex("tenants_slug_idx").on(table.slug)]
|
|
121
|
+
);
|
|
122
|
+
pgTable(
|
|
123
|
+
"api_keys",
|
|
124
|
+
{
|
|
125
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
126
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
127
|
+
name: varchar("name", { length: 255 }).notNull(),
|
|
128
|
+
key: varchar("key", { length: 64 }).notNull().unique(),
|
|
129
|
+
keyPrefix: varchar("key_prefix", { length: 8 }).notNull(),
|
|
130
|
+
permissions: jsonb("permissions").$type().default([]),
|
|
131
|
+
lastUsedAt: timestamp("last_used_at"),
|
|
132
|
+
expiresAt: timestamp("expires_at"),
|
|
133
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
134
|
+
},
|
|
135
|
+
(table) => [
|
|
136
|
+
index("api_keys_user_idx").on(table.userId),
|
|
137
|
+
index("api_keys_key_idx").on(table.key)
|
|
138
|
+
]
|
|
139
|
+
);
|
|
140
|
+
pgTable(
|
|
141
|
+
"email_verifications",
|
|
142
|
+
{
|
|
143
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
144
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
145
|
+
token: varchar("token", { length: 64 }).notNull().unique(),
|
|
146
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
147
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
148
|
+
},
|
|
149
|
+
(table) => [
|
|
150
|
+
index("email_verifications_token_idx").on(table.token),
|
|
151
|
+
index("email_verifications_user_idx").on(table.userId)
|
|
152
|
+
]
|
|
153
|
+
);
|
|
154
|
+
pgTable(
|
|
155
|
+
"password_resets",
|
|
156
|
+
{
|
|
157
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
158
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
159
|
+
token: varchar("token", { length: 64 }).notNull().unique(),
|
|
160
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
161
|
+
usedAt: timestamp("used_at"),
|
|
162
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
163
|
+
},
|
|
164
|
+
(table) => [
|
|
165
|
+
index("password_resets_token_idx").on(table.token),
|
|
166
|
+
index("password_resets_user_idx").on(table.userId)
|
|
167
|
+
]
|
|
168
|
+
);
|
|
169
|
+
pgTable(
|
|
170
|
+
"password_history",
|
|
171
|
+
{
|
|
172
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
173
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
174
|
+
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
|
|
175
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
176
|
+
},
|
|
177
|
+
(table) => [index("password_history_user_idx").on(table.userId)]
|
|
178
|
+
);
|
|
179
|
+
pgTable(
|
|
180
|
+
"lockouts",
|
|
181
|
+
{
|
|
182
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
183
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
184
|
+
ipAddress: varchar("ip_address", { length: 45 }),
|
|
185
|
+
reason: varchar("reason", { length: 255 }),
|
|
186
|
+
lockedUntil: timestamp("locked_until").notNull(),
|
|
187
|
+
releasedAt: timestamp("released_at"),
|
|
188
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
189
|
+
},
|
|
190
|
+
(table) => [
|
|
191
|
+
index("lockouts_user_idx").on(table.userId),
|
|
192
|
+
index("lockouts_ip_idx").on(table.ipAddress),
|
|
193
|
+
index("lockouts_locked_until_idx").on(table.lockedUntil)
|
|
194
|
+
]
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const DEFAULT_PREFIX = "kyro:auth:";
|
|
198
|
+
const DEFAULT_TOKEN_EXPIRATION = 86400;
|
|
199
|
+
const DEFAULT_REFRESH_EXPIRATION = 604800;
|
|
200
|
+
class RedisAuthAdapter {
|
|
201
|
+
redis;
|
|
202
|
+
prefix;
|
|
203
|
+
tokenExpiration;
|
|
204
|
+
refreshExpiration;
|
|
205
|
+
constructor(options = {}) {
|
|
206
|
+
const url = options.url || `redis://${options.host || "localhost"}:${options.port || 6379}`;
|
|
207
|
+
this.redis = new Redis2(url, {
|
|
208
|
+
password: options.password,
|
|
209
|
+
db: options.db,
|
|
210
|
+
lazyConnect: true,
|
|
211
|
+
tls: options.tls ? {} : void 0
|
|
212
|
+
});
|
|
213
|
+
this.prefix = options.keyPrefix || DEFAULT_PREFIX;
|
|
214
|
+
this.tokenExpiration = options.tokenExpiration || DEFAULT_TOKEN_EXPIRATION;
|
|
215
|
+
this.refreshExpiration = options.refreshTokenExpiration || DEFAULT_REFRESH_EXPIRATION;
|
|
216
|
+
}
|
|
217
|
+
async connect() {
|
|
218
|
+
await this.redis.connect();
|
|
219
|
+
}
|
|
220
|
+
async disconnect() {
|
|
221
|
+
await this.redis.quit();
|
|
222
|
+
}
|
|
223
|
+
userKey(userId) {
|
|
224
|
+
return `${this.prefix}users:${userId}`;
|
|
225
|
+
}
|
|
226
|
+
sessionKey(sessionId) {
|
|
227
|
+
return `${this.prefix}sessions:${sessionId}`;
|
|
228
|
+
}
|
|
229
|
+
refreshKey(token) {
|
|
230
|
+
return `${this.prefix}refresh:${token}`;
|
|
231
|
+
}
|
|
232
|
+
userByEmailKey(email) {
|
|
233
|
+
return `${this.prefix}users:email:${email.toLowerCase()}`;
|
|
234
|
+
}
|
|
235
|
+
passwordHistoryKey(userId) {
|
|
236
|
+
return `${this.prefix}users:${userId}:password_history`;
|
|
237
|
+
}
|
|
238
|
+
async createUser(data) {
|
|
239
|
+
const userId = randomBytes(16).toString("hex");
|
|
240
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
241
|
+
const user = {
|
|
242
|
+
id: userId,
|
|
243
|
+
email: data.email.toLowerCase(),
|
|
244
|
+
passwordHash: data.passwordHash,
|
|
245
|
+
role: data.role || "customer",
|
|
246
|
+
tenantId: data.tenantId,
|
|
247
|
+
createdAt: now,
|
|
248
|
+
updatedAt: now
|
|
249
|
+
};
|
|
250
|
+
const pipeline = this.redis.pipeline();
|
|
251
|
+
pipeline.hset(this.userKey(userId), this.userToHash(user));
|
|
252
|
+
pipeline.set(this.userByEmailKey(data.email), userId);
|
|
253
|
+
await pipeline.exec();
|
|
254
|
+
return user;
|
|
255
|
+
}
|
|
256
|
+
async findUserByEmail(email) {
|
|
257
|
+
const userId = await this.redis.get(
|
|
258
|
+
this.userByEmailKey(email.toLowerCase())
|
|
259
|
+
);
|
|
260
|
+
if (!userId) return null;
|
|
261
|
+
return this.findUserById(userId);
|
|
262
|
+
}
|
|
263
|
+
async findUserById(userId) {
|
|
264
|
+
const data = await this.redis.hgetall(this.userKey(userId));
|
|
265
|
+
if (!data || Object.keys(data).length === 0) return null;
|
|
266
|
+
return this.hashToUser(data);
|
|
267
|
+
}
|
|
268
|
+
async updateUser(userId, data) {
|
|
269
|
+
const existing = await this.findUserById(userId);
|
|
270
|
+
if (!existing) return null;
|
|
271
|
+
const updated = {
|
|
272
|
+
...existing,
|
|
273
|
+
...data,
|
|
274
|
+
id: userId,
|
|
275
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
276
|
+
};
|
|
277
|
+
if (data.email && data.email !== existing.email) {
|
|
278
|
+
const pipeline = this.redis.pipeline();
|
|
279
|
+
pipeline.del(this.userByEmailKey(existing.email));
|
|
280
|
+
pipeline.set(this.userByEmailKey(data.email), userId);
|
|
281
|
+
await pipeline.exec();
|
|
282
|
+
}
|
|
283
|
+
await this.redis.hset(this.userKey(userId), this.userToHash(updated));
|
|
284
|
+
return updated;
|
|
285
|
+
}
|
|
286
|
+
async deleteUser(userId) {
|
|
287
|
+
const user = await this.findUserById(userId);
|
|
288
|
+
if (!user) return false;
|
|
289
|
+
const pipeline = this.redis.pipeline();
|
|
290
|
+
pipeline.del(this.userKey(userId));
|
|
291
|
+
pipeline.del(this.userByEmailKey(user.email));
|
|
292
|
+
pipeline.del(this.passwordHistoryKey(userId));
|
|
293
|
+
await pipeline.exec();
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
async hashPassword(password) {
|
|
297
|
+
return bcrypt.hash(password, 12);
|
|
298
|
+
}
|
|
299
|
+
async verifyPassword(password, hash) {
|
|
300
|
+
return bcrypt.compare(password, hash);
|
|
301
|
+
}
|
|
302
|
+
async createSession(userId, data = {}) {
|
|
303
|
+
const sessionId = randomBytes(32).toString("hex");
|
|
304
|
+
const token = randomBytes(32).toString("base64url");
|
|
305
|
+
const refreshToken = randomBytes(32).toString("base64url");
|
|
306
|
+
const now = /* @__PURE__ */ new Date();
|
|
307
|
+
const session = {
|
|
308
|
+
id: sessionId,
|
|
309
|
+
userId,
|
|
310
|
+
token,
|
|
311
|
+
refreshToken,
|
|
312
|
+
expiresAt: new Date(
|
|
313
|
+
now.getTime() + this.tokenExpiration * 1e3
|
|
314
|
+
).toISOString(),
|
|
315
|
+
createdAt: now.toISOString(),
|
|
316
|
+
ipAddress: data.ipAddress,
|
|
317
|
+
userAgent: data.userAgent
|
|
318
|
+
};
|
|
319
|
+
const pipeline = this.redis.pipeline();
|
|
320
|
+
pipeline.hset(this.sessionKey(sessionId), this.sessionToHash(session));
|
|
321
|
+
pipeline.setex(
|
|
322
|
+
this.refreshKey(refreshToken),
|
|
323
|
+
this.refreshExpiration,
|
|
324
|
+
sessionId
|
|
325
|
+
);
|
|
326
|
+
await pipeline.exec();
|
|
327
|
+
return session;
|
|
328
|
+
}
|
|
329
|
+
async findSessionByToken(token) {
|
|
330
|
+
const data = await this.redis.hgetall(this.sessionKey(token));
|
|
331
|
+
if (!data || Object.keys(data).length === 0) return null;
|
|
332
|
+
return this.hashToSession(data);
|
|
333
|
+
}
|
|
334
|
+
async deleteSession(sessionId) {
|
|
335
|
+
const session = await this.redis.hgetall(this.sessionKey(sessionId));
|
|
336
|
+
if (!session || Object.keys(session).length === 0) return false;
|
|
337
|
+
const pipeline = this.redis.pipeline();
|
|
338
|
+
pipeline.del(this.sessionKey(sessionId));
|
|
339
|
+
if (session.refreshToken) {
|
|
340
|
+
pipeline.del(this.refreshKey(session.refreshToken));
|
|
341
|
+
}
|
|
342
|
+
await pipeline.exec();
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
async deleteUserSessions(userId) {
|
|
346
|
+
const pattern = `${this.prefix}sessions:*`;
|
|
347
|
+
let cursor = "0";
|
|
348
|
+
let deleted = 0;
|
|
349
|
+
do {
|
|
350
|
+
const [nextCursor, keys] = await this.redis.scan(
|
|
351
|
+
cursor,
|
|
352
|
+
"MATCH",
|
|
353
|
+
pattern,
|
|
354
|
+
"COUNT",
|
|
355
|
+
100
|
|
356
|
+
);
|
|
357
|
+
cursor = nextCursor;
|
|
358
|
+
for (const key of keys) {
|
|
359
|
+
const sessionData = await this.redis.hgetall(key);
|
|
360
|
+
if (sessionData.userId === userId) {
|
|
361
|
+
const sessionId = key.replace(`${this.prefix}sessions:`, "");
|
|
362
|
+
await this.deleteSession(sessionId);
|
|
363
|
+
deleted++;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} while (cursor !== "0");
|
|
367
|
+
return deleted;
|
|
368
|
+
}
|
|
369
|
+
async addPasswordToHistory(userId, passwordHash) {
|
|
370
|
+
await this.redis.lpush(this.passwordHistoryKey(userId), passwordHash);
|
|
371
|
+
await this.redis.ltrim(this.passwordHistoryKey(userId), 0, 4);
|
|
372
|
+
}
|
|
373
|
+
async getPasswordHistory(userId, count = 5) {
|
|
374
|
+
return this.redis.lrange(this.passwordHistoryKey(userId), 0, count - 1);
|
|
375
|
+
}
|
|
376
|
+
async isPasswordInHistory(password, userId, historyCount = 5) {
|
|
377
|
+
const history = await this.getPasswordHistory(userId, historyCount);
|
|
378
|
+
for (const hash of history) {
|
|
379
|
+
if (await this.verifyPassword(password, hash)) {
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
userToHash(user) {
|
|
386
|
+
const hash = {
|
|
387
|
+
id: user.id,
|
|
388
|
+
email: user.email,
|
|
389
|
+
passwordHash: user.passwordHash || "",
|
|
390
|
+
role: user.role,
|
|
391
|
+
createdAt: user.createdAt,
|
|
392
|
+
updatedAt: user.updatedAt
|
|
393
|
+
};
|
|
394
|
+
if (user.tenantId) hash.tenantId = user.tenantId;
|
|
395
|
+
if (user.emailVerified !== void 0)
|
|
396
|
+
hash.emailVerified = String(user.emailVerified);
|
|
397
|
+
if (user.locked !== void 0) hash.locked = String(user.locked);
|
|
398
|
+
if (user.lastLogin) hash.lastLogin = user.lastLogin;
|
|
399
|
+
if (user.failedLoginAttempts !== void 0)
|
|
400
|
+
hash.failedLoginAttempts = String(user.failedLoginAttempts);
|
|
401
|
+
return hash;
|
|
402
|
+
}
|
|
403
|
+
hashToUser(hash) {
|
|
404
|
+
return {
|
|
405
|
+
id: hash.id,
|
|
406
|
+
email: hash.email,
|
|
407
|
+
passwordHash: hash.passwordHash,
|
|
408
|
+
role: hash.role,
|
|
409
|
+
tenantId: hash.tenantId,
|
|
410
|
+
createdAt: hash.createdAt,
|
|
411
|
+
updatedAt: hash.updatedAt,
|
|
412
|
+
emailVerified: hash.emailVerified === "true",
|
|
413
|
+
locked: hash.locked === "true",
|
|
414
|
+
lastLogin: hash.lastLogin,
|
|
415
|
+
failedLoginAttempts: hash.failedLoginAttempts ? parseInt(hash.failedLoginAttempts, 10) : 0
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
sessionToHash(session) {
|
|
419
|
+
const hash = {
|
|
420
|
+
id: session.id,
|
|
421
|
+
userId: session.userId,
|
|
422
|
+
token: session.token,
|
|
423
|
+
expiresAt: session.expiresAt,
|
|
424
|
+
createdAt: session.createdAt
|
|
425
|
+
};
|
|
426
|
+
if (session.refreshToken) hash.refreshToken = session.refreshToken;
|
|
427
|
+
if (session.ipAddress) hash.ipAddress = session.ipAddress;
|
|
428
|
+
if (session.userAgent) hash.userAgent = session.userAgent;
|
|
429
|
+
return hash;
|
|
430
|
+
}
|
|
431
|
+
hashToSession(hash) {
|
|
432
|
+
return {
|
|
433
|
+
id: hash.id,
|
|
434
|
+
userId: hash.userId,
|
|
435
|
+
token: hash.token,
|
|
436
|
+
refreshToken: hash.refreshToken,
|
|
437
|
+
expiresAt: hash.expiresAt,
|
|
438
|
+
createdAt: hash.createdAt,
|
|
439
|
+
ipAddress: hash.ipAddress,
|
|
440
|
+
userAgent: hash.userAgent
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const defaultTemplates = {
|
|
446
|
+
verifyEmail: (link, userName = "User") => ({
|
|
447
|
+
subject: "Verify your email address",
|
|
448
|
+
html: `
|
|
449
|
+
<!DOCTYPE html>
|
|
450
|
+
<html>
|
|
451
|
+
<head>
|
|
452
|
+
<meta charset="utf-8">
|
|
453
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
454
|
+
<title>Verify Email</title>
|
|
455
|
+
<style>
|
|
456
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
|
457
|
+
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
458
|
+
.button { display: inline-block; padding: 12px 24px; background: #0b1222; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; }
|
|
459
|
+
.footer { margin-top: 30px; font-size: 12px; color: #666; }
|
|
460
|
+
</style>
|
|
461
|
+
</head>
|
|
462
|
+
<body>
|
|
463
|
+
<div class="container">
|
|
464
|
+
<h1>Welcome, ${userName}!</h1>
|
|
465
|
+
<p>Please verify your email address by clicking the button below:</p>
|
|
466
|
+
<p style="text-align: center; margin: 30px 0;">
|
|
467
|
+
<a href="${link}" class="button">Verify Email</a>
|
|
468
|
+
</p>
|
|
469
|
+
<p>Or copy and paste this link into your browser:</p>
|
|
470
|
+
<p style="word-break: break-all; color: #666;">${link}</p>
|
|
471
|
+
<p>This link will expire in 24 hours.</p>
|
|
472
|
+
<div class="footer">
|
|
473
|
+
<p>If you didn't create an account, you can safely ignore this email.</p>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
</body>
|
|
477
|
+
</html>
|
|
478
|
+
`,
|
|
479
|
+
text: `Welcome ${userName}!
|
|
480
|
+
|
|
481
|
+
Please verify your email by clicking this link: ${link}
|
|
482
|
+
|
|
483
|
+
This link will expire in 24 hours.
|
|
484
|
+
|
|
485
|
+
If you didn't create an account, you can safely ignore this email.`
|
|
486
|
+
}),
|
|
487
|
+
resetPassword: (link, userName = "User") => ({
|
|
488
|
+
subject: "Reset your password",
|
|
489
|
+
html: `
|
|
490
|
+
<!DOCTYPE html>
|
|
491
|
+
<html>
|
|
492
|
+
<head>
|
|
493
|
+
<meta charset="utf-8">
|
|
494
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
495
|
+
<title>Reset Password</title>
|
|
496
|
+
<style>
|
|
497
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
|
498
|
+
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
499
|
+
.button { display: inline-block; padding: 12px 24px; background: #dc2626; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; }
|
|
500
|
+
.warning { background: #fef3c7; border: 1px solid #f59e0b; padding: 12px; border-radius: 6px; margin: 20px 0; }
|
|
501
|
+
.footer { margin-top: 30px; font-size: 12px; color: #666; }
|
|
502
|
+
</style>
|
|
503
|
+
</head>
|
|
504
|
+
<body>
|
|
505
|
+
<div class="container">
|
|
506
|
+
<h1>Password Reset Request</h1>
|
|
507
|
+
<p>Hello ${userName},</p>
|
|
508
|
+
<p>We received a request to reset your password. Click the button below to create a new password:</p>
|
|
509
|
+
<p style="text-align: center; margin: 30px 0;">
|
|
510
|
+
<a href="${link}" class="button">Reset Password</a>
|
|
511
|
+
</p>
|
|
512
|
+
<p>Or copy and paste this link into your browser:</p>
|
|
513
|
+
<p style="word-break: break-all; color: #666;">${link}</p>
|
|
514
|
+
<div class="warning">
|
|
515
|
+
<strong>⚠️ Important:</strong> This link will expire in 1 hour. If you didn't request a password reset, please ignore this email or contact support if you have concerns.
|
|
516
|
+
</div>
|
|
517
|
+
<div class="footer">
|
|
518
|
+
<p>For security reasons, please don't share this email with anyone.</p>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
</body>
|
|
522
|
+
</html>
|
|
523
|
+
`,
|
|
524
|
+
text: `Password Reset Request
|
|
525
|
+
|
|
526
|
+
Hello ${userName},
|
|
527
|
+
|
|
528
|
+
We received a request to reset your password. Click this link to create a new password: ${link}
|
|
529
|
+
|
|
530
|
+
This link will expire in 1 hour.
|
|
531
|
+
|
|
532
|
+
If you didn't request a password reset, please ignore this email.`
|
|
533
|
+
}),
|
|
534
|
+
welcome: (userName = "User") => ({
|
|
535
|
+
subject: "Welcome to Kyro CMS",
|
|
536
|
+
html: `
|
|
537
|
+
<!DOCTYPE html>
|
|
538
|
+
<html>
|
|
539
|
+
<head>
|
|
540
|
+
<meta charset="utf-8">
|
|
541
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
542
|
+
<title>Welcome</title>
|
|
543
|
+
<style>
|
|
544
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
|
545
|
+
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
546
|
+
.button { display: inline-block; padding: 12px 24px; background: #0b1222; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; }
|
|
547
|
+
</style>
|
|
548
|
+
</head>
|
|
549
|
+
<body>
|
|
550
|
+
<div class="container">
|
|
551
|
+
<h1>Welcome to Kyro CMS, ${userName}!</h1>
|
|
552
|
+
<p>Your account has been created successfully.</p>
|
|
553
|
+
<p>You can now:</p>
|
|
554
|
+
<ul>
|
|
555
|
+
<li>Manage your content collections</li>
|
|
556
|
+
<li>Upload and organize media</li>
|
|
557
|
+
<li>Configure settings</li>
|
|
558
|
+
<li>And much more...</li>
|
|
559
|
+
</ul>
|
|
560
|
+
<p style="text-align: center; margin: 30px 0;">
|
|
561
|
+
<a href="#" class="button">Get Started</a>
|
|
562
|
+
</p>
|
|
563
|
+
<p>If you have any questions, feel free to reach out to our support team.</p>
|
|
564
|
+
</div>
|
|
565
|
+
</body>
|
|
566
|
+
</html>
|
|
567
|
+
`,
|
|
568
|
+
text: `Welcome to Kyro CMS, ${userName}!
|
|
569
|
+
|
|
570
|
+
Your account has been created successfully.
|
|
571
|
+
|
|
572
|
+
You can now:
|
|
573
|
+
- Manage your content collections
|
|
574
|
+
- Upload and organize media
|
|
575
|
+
- Configure settings
|
|
576
|
+
- And much more...
|
|
577
|
+
|
|
578
|
+
Get started by logging into your dashboard.`
|
|
579
|
+
}),
|
|
580
|
+
accountLocked: (attempts, duration, userName = "User") => ({
|
|
581
|
+
subject: "Account Security Alert - Account Locked",
|
|
582
|
+
html: `
|
|
583
|
+
<!DOCTYPE html>
|
|
584
|
+
<html>
|
|
585
|
+
<head>
|
|
586
|
+
<meta charset="utf-8">
|
|
587
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
588
|
+
<title>Account Locked</title>
|
|
589
|
+
<style>
|
|
590
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
|
591
|
+
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
592
|
+
.alert { background: #fef2f2; border: 1px solid #ef4444; padding: 16px; border-radius: 8px; margin: 20px 0; }
|
|
593
|
+
.footer { margin-top: 30px; font-size: 12px; color: #666; }
|
|
594
|
+
</style>
|
|
595
|
+
</head>
|
|
596
|
+
<body>
|
|
597
|
+
<div class="container">
|
|
598
|
+
<h1>Account Security Alert</h1>
|
|
599
|
+
<p>Hello ${userName},</p>
|
|
600
|
+
<div class="alert">
|
|
601
|
+
<p><strong>⚠️ Your account has been temporarily locked due to multiple failed login attempts.</strong></p>
|
|
602
|
+
<p>Failed attempts: ${attempts}</p>
|
|
603
|
+
<p>Lockout duration: ${Math.round(duration / 6e4)} minutes</p>
|
|
604
|
+
</div>
|
|
605
|
+
<p>Your account will automatically unlock after the lockout period expires.</p>
|
|
606
|
+
<p>If this wasn't you, we recommend:</p>
|
|
607
|
+
<ul>
|
|
608
|
+
<li>Using a strong, unique password</li>
|
|
609
|
+
<li>Enabling two-factor authentication (coming soon)</li>
|
|
610
|
+
<li>Reviewing your recent account activity</li>
|
|
611
|
+
</ul>
|
|
612
|
+
<div class="footer">
|
|
613
|
+
<p>If you need immediate assistance, please contact support.</p>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
</body>
|
|
617
|
+
</html>
|
|
618
|
+
`,
|
|
619
|
+
text: `Account Security Alert
|
|
620
|
+
|
|
621
|
+
Hello ${userName},
|
|
622
|
+
|
|
623
|
+
Your account has been temporarily locked due to multiple failed login attempts (${attempts}).
|
|
624
|
+
|
|
625
|
+
Lockout duration: ${Math.round(duration / 6e4)} minutes
|
|
626
|
+
|
|
627
|
+
Your account will automatically unlock after this period.
|
|
628
|
+
|
|
629
|
+
If this wasn't you, we recommend using a strong, unique password.`
|
|
630
|
+
}),
|
|
631
|
+
passwordChanged: (userName = "User") => ({
|
|
632
|
+
subject: "Your password has been changed",
|
|
633
|
+
html: `
|
|
634
|
+
<!DOCTYPE html>
|
|
635
|
+
<html>
|
|
636
|
+
<head>
|
|
637
|
+
<meta charset="utf-8">
|
|
638
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
639
|
+
<title>Password Changed</title>
|
|
640
|
+
<style>
|
|
641
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
|
642
|
+
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
643
|
+
.info { background: #f0fdf4; border: 1px solid #22c55e; padding: 12px; border-radius: 6px; margin: 20px 0; }
|
|
644
|
+
</style>
|
|
645
|
+
</head>
|
|
646
|
+
<body>
|
|
647
|
+
<div class="container">
|
|
648
|
+
<h1>Password Changed</h1>
|
|
649
|
+
<p>Hello ${userName},</p>
|
|
650
|
+
<div class="info">
|
|
651
|
+
<p>Your password was recently changed.</p>
|
|
652
|
+
</div>
|
|
653
|
+
<p>If you did this, you can safely ignore this email.</p>
|
|
654
|
+
<p><strong>If you didn't change your password</strong>, please contact our support team immediately as your account may have been compromised.</p>
|
|
655
|
+
</div>
|
|
656
|
+
</body>
|
|
657
|
+
</html>
|
|
658
|
+
`,
|
|
659
|
+
text: `Password Changed
|
|
660
|
+
|
|
661
|
+
Hello ${userName},
|
|
662
|
+
|
|
663
|
+
Your password was recently changed.
|
|
664
|
+
|
|
665
|
+
If you did this, you can safely ignore this email.
|
|
666
|
+
|
|
667
|
+
If you didn't change your password, please contact support immediately.`
|
|
668
|
+
}),
|
|
669
|
+
newLogin: (location, time, userName = "User") => ({
|
|
670
|
+
subject: "New login to your account",
|
|
671
|
+
html: `
|
|
672
|
+
<!DOCTYPE html>
|
|
673
|
+
<html>
|
|
674
|
+
<head>
|
|
675
|
+
<meta charset="utf-8">
|
|
676
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
677
|
+
<title>New Login</title>
|
|
678
|
+
<style>
|
|
679
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
|
680
|
+
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
681
|
+
.info-box { background: #f8fafc; border: 1px solid #e2e8f0; padding: 16px; border-radius: 8px; margin: 20px 0; }
|
|
682
|
+
.footer { margin-top: 30px; font-size: 12px; color: #666; }
|
|
683
|
+
</style>
|
|
684
|
+
</head>
|
|
685
|
+
<body>
|
|
686
|
+
<div class="container">
|
|
687
|
+
<h1>New Login Detected</h1>
|
|
688
|
+
<p>Hello ${userName},</p>
|
|
689
|
+
<p>We detected a new login to your account:</p>
|
|
690
|
+
<div class="info-box">
|
|
691
|
+
<p><strong>Location:</strong> ${location}</p>
|
|
692
|
+
<p><strong>Time:</strong> ${time}</p>
|
|
693
|
+
</div>
|
|
694
|
+
<p><strong>If this was you</strong>, no action is needed.</p>
|
|
695
|
+
<p><strong>If this wasn't you</strong>, your account may be compromised. Please:</p>
|
|
696
|
+
<ol>
|
|
697
|
+
<li>Change your password immediately</li>
|
|
698
|
+
<li>Review your recent account activity</li>
|
|
699
|
+
<li>Contact support if needed</li>
|
|
700
|
+
</ol>
|
|
701
|
+
<div class="footer">
|
|
702
|
+
<p>This is an automated security notification.</p>
|
|
703
|
+
</div>
|
|
704
|
+
</div>
|
|
705
|
+
</body>
|
|
706
|
+
</html>
|
|
707
|
+
`,
|
|
708
|
+
text: `New Login Detected
|
|
709
|
+
|
|
710
|
+
Hello ${userName},
|
|
711
|
+
|
|
712
|
+
We detected a new login to your account:
|
|
713
|
+
|
|
714
|
+
Location: ${location}
|
|
715
|
+
Time: ${time}
|
|
716
|
+
|
|
717
|
+
If this wasn't you, please change your password immediately and contact support.`
|
|
718
|
+
})
|
|
719
|
+
};
|
|
720
|
+
class EmailTransport {
|
|
721
|
+
transporter;
|
|
722
|
+
from;
|
|
723
|
+
fromName;
|
|
724
|
+
templates;
|
|
725
|
+
constructor(config, templates) {
|
|
726
|
+
this.transporter = nodemailer.createTransport({
|
|
727
|
+
host: config.host,
|
|
728
|
+
port: config.port,
|
|
729
|
+
secure: config.secure,
|
|
730
|
+
auth: config.auth
|
|
731
|
+
});
|
|
732
|
+
this.from = config.from;
|
|
733
|
+
this.fromName = config.fromName || "Kyro CMS";
|
|
734
|
+
this.templates = { ...defaultTemplates, ...templates };
|
|
735
|
+
}
|
|
736
|
+
async send(options) {
|
|
737
|
+
return this.transporter.sendMail({
|
|
738
|
+
from: `"${this.fromName}" <${this.from}>`,
|
|
739
|
+
to: Array.isArray(options.to) ? options.to.join(", ") : options.to,
|
|
740
|
+
subject: options.subject,
|
|
741
|
+
html: options.html,
|
|
742
|
+
text: options.text
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
getTemplates() {
|
|
746
|
+
return this.templates;
|
|
747
|
+
}
|
|
748
|
+
async verifyConnection() {
|
|
749
|
+
try {
|
|
750
|
+
await this.transporter.verify();
|
|
751
|
+
return true;
|
|
752
|
+
} catch {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
static fromEnv() {
|
|
757
|
+
const host = process.env.SMTP_HOST;
|
|
758
|
+
const port = parseInt(process.env.SMTP_PORT || "587", 10);
|
|
759
|
+
const secure = process.env.SMTP_SECURE === "true";
|
|
760
|
+
const user = process.env.SMTP_USER;
|
|
761
|
+
const pass = process.env.SMTP_PASS;
|
|
762
|
+
const from = process.env.SMTP_FROM || process.env.DEFAULT_FROM || "noreply@example.com";
|
|
763
|
+
const fromName = process.env.SMTP_FROM_NAME || "Kyro CMS";
|
|
764
|
+
if (!host || !user || !pass) {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
return new EmailTransport({
|
|
768
|
+
host,
|
|
769
|
+
port,
|
|
770
|
+
secure,
|
|
771
|
+
auth: { user, pass },
|
|
772
|
+
from,
|
|
773
|
+
fromName
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const DEFAULT_PASSWORD_POLICY = {
|
|
779
|
+
minLength: 12,
|
|
780
|
+
requireUppercase: true,
|
|
781
|
+
requireLowercase: true,
|
|
782
|
+
requireNumbers: true,
|
|
783
|
+
requireSpecialChars: true,
|
|
784
|
+
preventReuse: 5,
|
|
785
|
+
maxLength: 128
|
|
786
|
+
};
|
|
787
|
+
class PasswordPolicy {
|
|
788
|
+
config;
|
|
789
|
+
constructor(config = {}) {
|
|
790
|
+
this.config = { ...DEFAULT_PASSWORD_POLICY, ...config };
|
|
791
|
+
}
|
|
792
|
+
validate(password) {
|
|
793
|
+
const errors = [];
|
|
794
|
+
if (this.config.maxLength && password.length > this.config.maxLength) {
|
|
795
|
+
errors.push(
|
|
796
|
+
`Password must not exceed ${this.config.maxLength} characters`
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
if (password.length < this.config.minLength) {
|
|
800
|
+
errors.push(
|
|
801
|
+
`Password must be at least ${this.config.minLength} characters`
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
if (this.config.requireUppercase && !/[A-Z]/.test(password)) {
|
|
805
|
+
errors.push("Password must contain at least one uppercase letter");
|
|
806
|
+
}
|
|
807
|
+
if (this.config.requireLowercase && !/[a-z]/.test(password)) {
|
|
808
|
+
errors.push("Password must contain at least one lowercase letter");
|
|
809
|
+
}
|
|
810
|
+
if (this.config.requireNumbers && !/[0-9]/.test(password)) {
|
|
811
|
+
errors.push("Password must contain at least one number");
|
|
812
|
+
}
|
|
813
|
+
if (this.config.requireSpecialChars && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
|
814
|
+
errors.push("Password must contain at least one special character");
|
|
815
|
+
}
|
|
816
|
+
const commonPasswords = [
|
|
817
|
+
"password",
|
|
818
|
+
"123456",
|
|
819
|
+
"12345678",
|
|
820
|
+
"qwerty",
|
|
821
|
+
"abc123",
|
|
822
|
+
"monkey",
|
|
823
|
+
"1234567",
|
|
824
|
+
"letmein",
|
|
825
|
+
"trustno1",
|
|
826
|
+
"dragon",
|
|
827
|
+
"baseball",
|
|
828
|
+
"iloveyou",
|
|
829
|
+
"master",
|
|
830
|
+
"sunshine",
|
|
831
|
+
"ashley",
|
|
832
|
+
"football",
|
|
833
|
+
"password1",
|
|
834
|
+
"shadow",
|
|
835
|
+
"123123",
|
|
836
|
+
"654321"
|
|
837
|
+
];
|
|
838
|
+
if (commonPasswords.includes(password.toLowerCase())) {
|
|
839
|
+
errors.push(
|
|
840
|
+
"This password is too common. Please choose a more secure password"
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
if (/^[a-zA-Z]+$/.test(password) || /^[0-9]+$/.test(password)) {
|
|
844
|
+
errors.push(
|
|
845
|
+
"Password must contain a mix of letters, numbers, and/or special characters"
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
if (/(.)\1{2,}/.test(password)) {
|
|
849
|
+
errors.push(
|
|
850
|
+
"Password must not contain more than 2 consecutive identical characters"
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
if (/^(012|123|234|345|456|567|678|789|890|098|987|876|765|654|543|432|321|210)+$/i.test(
|
|
854
|
+
password
|
|
855
|
+
)) {
|
|
856
|
+
errors.push("Password must not contain sequential numbers or letters");
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
valid: errors.length === 0,
|
|
860
|
+
errors
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
async checkReuse(passwordHash, history, verifyFn) {
|
|
864
|
+
return {
|
|
865
|
+
valid: true,
|
|
866
|
+
errors: []
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
async isInHistory(password, history, verifyFn) {
|
|
870
|
+
for (const hash of history) {
|
|
871
|
+
if (await verifyFn(password, hash)) {
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
generatePassword(length = 16) {
|
|
878
|
+
const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
879
|
+
const lowercase = "abcdefghijklmnopqrstuvwxyz";
|
|
880
|
+
const numbers = "0123456789";
|
|
881
|
+
const special = "!@#$%^&*()_+-=[]{}|;:,.<>?";
|
|
882
|
+
let password = "";
|
|
883
|
+
password += uppercase[Math.floor(Math.random() * uppercase.length)];
|
|
884
|
+
password += lowercase[Math.floor(Math.random() * lowercase.length)];
|
|
885
|
+
password += numbers[Math.floor(Math.random() * numbers.length)];
|
|
886
|
+
password += special[Math.floor(Math.random() * special.length)];
|
|
887
|
+
const allChars = uppercase + lowercase + numbers + special;
|
|
888
|
+
for (let i = password.length; i < length; i++) {
|
|
889
|
+
password += allChars[Math.floor(Math.random() * allChars.length)];
|
|
890
|
+
}
|
|
891
|
+
return password.split("").sort(() => Math.random() - 0.5).join("");
|
|
892
|
+
}
|
|
893
|
+
getStrength(password) {
|
|
894
|
+
let score = 0;
|
|
895
|
+
const feedback = [];
|
|
896
|
+
if (password.length >= 8) score += 1;
|
|
897
|
+
if (password.length >= 12) score += 1;
|
|
898
|
+
if (password.length >= 16) score += 1;
|
|
899
|
+
if (/[a-z]/.test(password)) score += 1;
|
|
900
|
+
if (/[A-Z]/.test(password)) score += 1;
|
|
901
|
+
if (/[0-9]/.test(password)) score += 1;
|
|
902
|
+
if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) score += 1;
|
|
903
|
+
if (password.length > 8) score += 1;
|
|
904
|
+
if (password.length > 12) score += 1;
|
|
905
|
+
const uniqueChars = new Set(password).size;
|
|
906
|
+
if (uniqueChars > 6) score += 1;
|
|
907
|
+
if (uniqueChars > 10) score += 1;
|
|
908
|
+
let label;
|
|
909
|
+
if (score <= 3) {
|
|
910
|
+
label = "Weak";
|
|
911
|
+
feedback.push("Add more characters");
|
|
912
|
+
feedback.push("Include uppercase and lowercase letters");
|
|
913
|
+
} else if (score <= 5) {
|
|
914
|
+
label = "Fair";
|
|
915
|
+
feedback.push("Add special characters");
|
|
916
|
+
feedback.push("Consider making it longer");
|
|
917
|
+
} else if (score <= 7) {
|
|
918
|
+
label = "Good";
|
|
919
|
+
feedback.push("Consider making it longer for extra security");
|
|
920
|
+
} else {
|
|
921
|
+
label = "Strong";
|
|
922
|
+
}
|
|
923
|
+
return { score, label, feedback };
|
|
924
|
+
}
|
|
925
|
+
setConfig(config) {
|
|
926
|
+
this.config = { ...this.config, ...config };
|
|
927
|
+
}
|
|
928
|
+
getConfig() {
|
|
929
|
+
return { ...this.config };
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const DEFAULT_LOCKOUT_CONFIG = {
|
|
934
|
+
maxAttempts: 5,
|
|
935
|
+
lockDuration: 9e5,
|
|
936
|
+
notifyUser: true,
|
|
937
|
+
notifyAdmin: true,
|
|
938
|
+
adminNotifyAfter: 3
|
|
939
|
+
};
|
|
940
|
+
class AccountLockout {
|
|
941
|
+
redis;
|
|
942
|
+
prefix;
|
|
943
|
+
config;
|
|
944
|
+
constructor(redis, config = {}, prefix = "kyro:lockout:") {
|
|
945
|
+
this.redis = redis;
|
|
946
|
+
this.prefix = prefix;
|
|
947
|
+
this.config = { ...DEFAULT_LOCKOUT_CONFIG, ...config };
|
|
948
|
+
}
|
|
949
|
+
lockKey(userId) {
|
|
950
|
+
return `${this.prefix}${userId}`;
|
|
951
|
+
}
|
|
952
|
+
historyKey(userId) {
|
|
953
|
+
return `${this.prefix}${userId}:history`;
|
|
954
|
+
}
|
|
955
|
+
async checkLockout(userId) {
|
|
956
|
+
const key = this.lockKey(userId);
|
|
957
|
+
const data = await this.redis.hgetall(key);
|
|
958
|
+
if (!data || Object.keys(data).length === 0) {
|
|
959
|
+
return {
|
|
960
|
+
locked: false,
|
|
961
|
+
attemptsRemaining: this.config.maxAttempts,
|
|
962
|
+
totalAttempts: 0
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
const attempts = parseInt(data.attempts, 10);
|
|
966
|
+
const lockedUntil = data.lockedUntil ? new Date(parseInt(data.lockedUntil, 10)) : void 0;
|
|
967
|
+
if (lockedUntil && lockedUntil > /* @__PURE__ */ new Date()) {
|
|
968
|
+
return {
|
|
969
|
+
locked: true,
|
|
970
|
+
attemptsRemaining: 0,
|
|
971
|
+
lockedUntil,
|
|
972
|
+
totalAttempts: attempts
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
if (lockedUntil && lockedUntil <= /* @__PURE__ */ new Date()) {
|
|
976
|
+
await this.unlockAccount(userId);
|
|
977
|
+
return {
|
|
978
|
+
locked: false,
|
|
979
|
+
attemptsRemaining: this.config.maxAttempts,
|
|
980
|
+
totalAttempts: 0
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
return {
|
|
984
|
+
locked: false,
|
|
985
|
+
attemptsRemaining: Math.max(0, this.config.maxAttempts - attempts),
|
|
986
|
+
totalAttempts: attempts
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
async recordFailedAttempt(userId) {
|
|
990
|
+
const key = this.lockKey(userId);
|
|
991
|
+
const historyKey = this.historyKey(userId);
|
|
992
|
+
const now = Date.now();
|
|
993
|
+
const current = await this.redis.hincrby(key, "attempts", 1);
|
|
994
|
+
await this.redis.hset(key, "lastAttempt", now.toString());
|
|
995
|
+
await this.redis.lpush(historyKey, now.toString());
|
|
996
|
+
await this.redis.ltrim(historyKey, 0, 99);
|
|
997
|
+
if (current >= this.config.maxAttempts) {
|
|
998
|
+
const lockedUntil = new Date(now + this.config.lockDuration);
|
|
999
|
+
await this.redis.hset(key, {
|
|
1000
|
+
lockedAt: now.toString(),
|
|
1001
|
+
lockedUntil: lockedUntil.getTime().toString()
|
|
1002
|
+
});
|
|
1003
|
+
await this.redis.expire(
|
|
1004
|
+
key,
|
|
1005
|
+
Math.ceil(this.config.lockDuration / 1e3) + 3600
|
|
1006
|
+
);
|
|
1007
|
+
return {
|
|
1008
|
+
locked: true,
|
|
1009
|
+
attemptsRemaining: 0,
|
|
1010
|
+
lockedUntil,
|
|
1011
|
+
totalAttempts: current
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
return {
|
|
1015
|
+
locked: false,
|
|
1016
|
+
attemptsRemaining: Math.max(0, this.config.maxAttempts - current),
|
|
1017
|
+
totalAttempts: current
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
async lockAccount(userId, duration) {
|
|
1021
|
+
const key = this.lockKey(userId);
|
|
1022
|
+
const now = Date.now();
|
|
1023
|
+
const lockDuration = duration || this.config.lockDuration;
|
|
1024
|
+
const lockedUntil = new Date(now + lockDuration);
|
|
1025
|
+
const pipeline = this.redis.pipeline();
|
|
1026
|
+
pipeline.hset(key, {
|
|
1027
|
+
attempts: this.config.maxAttempts.toString(),
|
|
1028
|
+
lockedAt: now.toString(),
|
|
1029
|
+
lockedUntil: lockedUntil.getTime().toString()
|
|
1030
|
+
});
|
|
1031
|
+
pipeline.expire(key, Math.ceil(lockDuration / 1e3) + 3600);
|
|
1032
|
+
await pipeline.exec();
|
|
1033
|
+
}
|
|
1034
|
+
async unlockAccount(userId) {
|
|
1035
|
+
const key = this.lockKey(userId);
|
|
1036
|
+
await this.redis.del(key);
|
|
1037
|
+
}
|
|
1038
|
+
async resetAttempts(userId) {
|
|
1039
|
+
const key = this.lockKey(userId);
|
|
1040
|
+
const data = await this.redis.hgetall(key);
|
|
1041
|
+
if (data.lockedAt) {
|
|
1042
|
+
await this.redis.hset(key, {
|
|
1043
|
+
attempts: "0",
|
|
1044
|
+
lockedAt: "",
|
|
1045
|
+
lockedUntil: ""
|
|
1046
|
+
});
|
|
1047
|
+
} else {
|
|
1048
|
+
await this.redis.del(key);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
async getLockoutHistory(userId, limit = 10) {
|
|
1052
|
+
const historyKey = this.historyKey(userId);
|
|
1053
|
+
const timestamps = await this.redis.lrange(historyKey, 0, limit - 1);
|
|
1054
|
+
return timestamps.map((ts) => new Date(parseInt(ts, 10)));
|
|
1055
|
+
}
|
|
1056
|
+
async getLockoutStats(userId) {
|
|
1057
|
+
const historyKey = this.historyKey(userId);
|
|
1058
|
+
const timestamps = await this.redis.lrange(historyKey, 0, -1);
|
|
1059
|
+
const lockouts = timestamps.filter((_, i) => {
|
|
1060
|
+
const attemptNum = i + 1;
|
|
1061
|
+
return attemptNum % this.config.maxAttempts === 0;
|
|
1062
|
+
}).length;
|
|
1063
|
+
const lastLockoutData = await this.redis.hget(
|
|
1064
|
+
this.lockKey(userId),
|
|
1065
|
+
"lockedAt"
|
|
1066
|
+
);
|
|
1067
|
+
return {
|
|
1068
|
+
totalFailedAttempts: timestamps.length,
|
|
1069
|
+
lockoutCount: lockouts,
|
|
1070
|
+
lastLockout: lastLockoutData ? new Date(parseInt(lastLockoutData, 10)) : null,
|
|
1071
|
+
averageAttemptsBeforeLockout: lockouts > 0 ? this.config.maxAttempts : 0
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
shouldNotifyAdmin(currentAttempts) {
|
|
1075
|
+
return this.config.notifyAdmin && currentAttempts >= this.config.adminNotifyAfter;
|
|
1076
|
+
}
|
|
1077
|
+
getConfig() {
|
|
1078
|
+
return { ...this.config };
|
|
1079
|
+
}
|
|
1080
|
+
setConfig(config) {
|
|
1081
|
+
this.config = { ...this.config, ...config };
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const DEFAULT_RATE_LIMITS = {
|
|
1086
|
+
"auth:login": { window: 9e5, max: 5 },
|
|
1087
|
+
"auth:register": { window: 36e5, max: 3 },
|
|
1088
|
+
"auth:forgot": { window: 36e5, max: 3 },
|
|
1089
|
+
"auth:reset": { window: 36e5, max: 5 },
|
|
1090
|
+
"auth:verify": { window: 36e5, max: 5 },
|
|
1091
|
+
"api:general": { window: 6e4, max: 100 },
|
|
1092
|
+
"api:authenticated": { window: 6e4, max: 200 }
|
|
1093
|
+
};
|
|
1094
|
+
class RateLimiter {
|
|
1095
|
+
redis;
|
|
1096
|
+
prefix;
|
|
1097
|
+
limits;
|
|
1098
|
+
userLimits;
|
|
1099
|
+
constructor(redis, limits, userLimits, prefix = "kyro:ratelimit:") {
|
|
1100
|
+
this.redis = redis;
|
|
1101
|
+
this.prefix = prefix;
|
|
1102
|
+
this.limits = { ...DEFAULT_RATE_LIMITS, ...limits };
|
|
1103
|
+
this.userLimits = userLimits || {
|
|
1104
|
+
"user:api": { window: 6e4, max: 500 },
|
|
1105
|
+
"user:write": { window: 36e5, max: 100 }
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
getKey(type, identifier) {
|
|
1109
|
+
return `${this.prefix}${type}:${identifier}`;
|
|
1110
|
+
}
|
|
1111
|
+
async check(type, identifier) {
|
|
1112
|
+
const config = this.limits[type] || this.limits["api:general"];
|
|
1113
|
+
const key = this.getKey(type, identifier);
|
|
1114
|
+
const now = Date.now();
|
|
1115
|
+
const windowStart = now - config.window;
|
|
1116
|
+
const pipeline = this.redis.pipeline();
|
|
1117
|
+
pipeline.zremrangebyscore(key, 0, windowStart);
|
|
1118
|
+
pipeline.zcard(key);
|
|
1119
|
+
pipeline.zadd(key, now, `${now}:${Math.random()}`);
|
|
1120
|
+
pipeline.expire(key, Math.ceil(config.window / 1e3) + 1);
|
|
1121
|
+
const results = await pipeline.exec();
|
|
1122
|
+
const count = results?.[1]?.[1] || 0;
|
|
1123
|
+
if (count >= config.max) {
|
|
1124
|
+
const oldestTimestamp = await this.redis.zrange(key, 0, 0, "WITHSCORES");
|
|
1125
|
+
const resetAt = oldestTimestamp.length > 1 ? parseInt(oldestTimestamp[1], 10) + config.window : now + config.window;
|
|
1126
|
+
return {
|
|
1127
|
+
allowed: false,
|
|
1128
|
+
remaining: 0,
|
|
1129
|
+
resetAt,
|
|
1130
|
+
retryAfter: Math.ceil((resetAt - now) / 1e3)
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
return {
|
|
1134
|
+
allowed: true,
|
|
1135
|
+
remaining: config.max - count - 1,
|
|
1136
|
+
resetAt: now + config.window
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
async checkUser(type, userId, identifier) {
|
|
1140
|
+
const config = this.userLimits[type] || this.userLimits["user:api"];
|
|
1141
|
+
const key = this.getKey(`user:${type}:${userId}`, identifier);
|
|
1142
|
+
const now = Date.now();
|
|
1143
|
+
const windowStart = now - config.window;
|
|
1144
|
+
const pipeline = this.redis.pipeline();
|
|
1145
|
+
pipeline.zremrangebyscore(key, 0, windowStart);
|
|
1146
|
+
pipeline.zcard(key);
|
|
1147
|
+
pipeline.zadd(key, now, `${now}:${Math.random()}`);
|
|
1148
|
+
pipeline.expire(key, Math.ceil(config.window / 1e3) + 1);
|
|
1149
|
+
const results = await pipeline.exec();
|
|
1150
|
+
const count = results?.[1]?.[1] || 0;
|
|
1151
|
+
if (count >= config.max) {
|
|
1152
|
+
const oldestTimestamp = await this.redis.zrange(key, 0, 0, "WITHSCORES");
|
|
1153
|
+
const resetAt = oldestTimestamp.length > 1 ? parseInt(oldestTimestamp[1], 10) + config.window : now + config.window;
|
|
1154
|
+
return {
|
|
1155
|
+
allowed: false,
|
|
1156
|
+
remaining: 0,
|
|
1157
|
+
resetAt,
|
|
1158
|
+
retryAfter: Math.ceil((resetAt - now) / 1e3)
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
return {
|
|
1162
|
+
allowed: true,
|
|
1163
|
+
remaining: config.max - count - 1,
|
|
1164
|
+
resetAt: now + config.window
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
async reset(type, identifier) {
|
|
1168
|
+
const key = this.getKey(type, identifier);
|
|
1169
|
+
await this.redis.del(key);
|
|
1170
|
+
}
|
|
1171
|
+
async resetUser(type, userId, identifier) {
|
|
1172
|
+
const key = this.getKey(`user:${type}:${userId}`, identifier);
|
|
1173
|
+
await this.redis.del(key);
|
|
1174
|
+
}
|
|
1175
|
+
async getStatus(type, identifier) {
|
|
1176
|
+
const config = this.limits[type] || this.limits["api:general"];
|
|
1177
|
+
const key = this.getKey(type, identifier);
|
|
1178
|
+
const now = Date.now();
|
|
1179
|
+
const windowStart = now - config.window;
|
|
1180
|
+
await this.redis.zremrangebyscore(key, 0, windowStart);
|
|
1181
|
+
const count = await this.redis.zcard(key);
|
|
1182
|
+
return {
|
|
1183
|
+
count,
|
|
1184
|
+
limit: config.max,
|
|
1185
|
+
remaining: Math.max(0, config.max - count),
|
|
1186
|
+
resetAt: now + config.window
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
setLimit(type, config) {
|
|
1190
|
+
this.limits[type] = config;
|
|
1191
|
+
}
|
|
1192
|
+
setUserLimit(type, config) {
|
|
1193
|
+
this.userLimits[type] = config;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
class AuditLogger {
|
|
1198
|
+
redis;
|
|
1199
|
+
prefix;
|
|
1200
|
+
retentionDays;
|
|
1201
|
+
constructor(redis, retentionDays = 30, prefix = "kyro:audit:") {
|
|
1202
|
+
this.redis = redis;
|
|
1203
|
+
this.prefix = prefix;
|
|
1204
|
+
this.retentionDays = retentionDays;
|
|
1205
|
+
}
|
|
1206
|
+
async log(data) {
|
|
1207
|
+
const id = randomBytes(16).toString("hex");
|
|
1208
|
+
const timestamp = /* @__PURE__ */ new Date();
|
|
1209
|
+
const log = {
|
|
1210
|
+
...data,
|
|
1211
|
+
id,
|
|
1212
|
+
timestamp
|
|
1213
|
+
};
|
|
1214
|
+
const key = this.getKeyForDate(timestamp);
|
|
1215
|
+
const hashKey = `${this.prefix}log:${id}`;
|
|
1216
|
+
await this.redis.hset(hashKey, this.serializeLog(log));
|
|
1217
|
+
await this.redis.expire(hashKey, this.retentionDays * 24 * 60 * 60 + 3600);
|
|
1218
|
+
await this.redis.zadd(key, timestamp.getTime(), id);
|
|
1219
|
+
await this.redis.expire(key, this.retentionDays * 24 * 60 * 60 + 3600);
|
|
1220
|
+
const userIndex = data.userId ? `${this.prefix}user:${data.userId}` : null;
|
|
1221
|
+
if (userIndex) {
|
|
1222
|
+
await this.redis.zadd(userIndex, timestamp.getTime(), id);
|
|
1223
|
+
await this.redis.expire(
|
|
1224
|
+
userIndex,
|
|
1225
|
+
this.retentionDays * 24 * 60 * 60 + 3600
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
return id;
|
|
1229
|
+
}
|
|
1230
|
+
async get(id) {
|
|
1231
|
+
const hashKey = `${this.prefix}log:${id}`;
|
|
1232
|
+
const data = await this.redis.hgetall(hashKey);
|
|
1233
|
+
if (!data || Object.keys(data).length === 0) {
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
return this.deserializeLog(data);
|
|
1237
|
+
}
|
|
1238
|
+
async query(filter = {}) {
|
|
1239
|
+
const { limit = 50, offset = 0 } = filter;
|
|
1240
|
+
let keys = [];
|
|
1241
|
+
if (filter.userId) {
|
|
1242
|
+
keys.push(`${this.prefix}user:${filter.userId}`);
|
|
1243
|
+
} else if (filter.startDate || filter.endDate) {
|
|
1244
|
+
keys = this.getKeysForDateRange(filter.startDate, filter.endDate);
|
|
1245
|
+
} else {
|
|
1246
|
+
const now = /* @__PURE__ */ new Date();
|
|
1247
|
+
keys = this.getKeysForDateRange(
|
|
1248
|
+
new Date(now.getTime() - this.retentionDays * 24 * 60 * 60 * 1e3),
|
|
1249
|
+
now
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
let idScores = [];
|
|
1253
|
+
for (const key of keys) {
|
|
1254
|
+
const items = await this.redis.zrange(key, 0, -1, "WITHSCORES");
|
|
1255
|
+
for (let i = 0; i < items.length; i += 2) {
|
|
1256
|
+
idScores.push([items[i], parseInt(items[i + 1], 10)]);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
idScores.sort((a, b) => b[1] - a[1]);
|
|
1260
|
+
const total = idScores.length;
|
|
1261
|
+
idScores = idScores.slice(offset, offset + limit);
|
|
1262
|
+
const logs = [];
|
|
1263
|
+
for (const [id] of idScores) {
|
|
1264
|
+
const log = await this.get(id);
|
|
1265
|
+
if (log) {
|
|
1266
|
+
if (this.matchesFilter(log, filter)) {
|
|
1267
|
+
logs.push(log);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return { logs, total };
|
|
1272
|
+
}
|
|
1273
|
+
async getRecent(limit = 50) {
|
|
1274
|
+
const logs = [];
|
|
1275
|
+
const now = /* @__PURE__ */ new Date();
|
|
1276
|
+
const keys = this.getKeysForDateRange(
|
|
1277
|
+
new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3),
|
|
1278
|
+
now
|
|
1279
|
+
);
|
|
1280
|
+
let allIds = [];
|
|
1281
|
+
for (const key of keys) {
|
|
1282
|
+
const items = await this.redis.zrange(key, 0, -1, "WITHSCORES");
|
|
1283
|
+
for (let i = 0; i < items.length; i += 2) {
|
|
1284
|
+
allIds.push([items[i], parseInt(items[i + 1], 10)]);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
allIds.sort((a, b) => b[1] - a[1]);
|
|
1288
|
+
for (const [id] of allIds.slice(0, limit)) {
|
|
1289
|
+
const log = await this.get(id);
|
|
1290
|
+
if (log) logs.push(log);
|
|
1291
|
+
}
|
|
1292
|
+
return logs;
|
|
1293
|
+
}
|
|
1294
|
+
async getUserActivity(userId, limit = 50) {
|
|
1295
|
+
const key = `${this.prefix}user:${userId}`;
|
|
1296
|
+
const ids = await this.redis.zrange(key, 0, limit - 1);
|
|
1297
|
+
const logs = [];
|
|
1298
|
+
for (const id of ids) {
|
|
1299
|
+
const log = await this.get(id);
|
|
1300
|
+
if (log) logs.push(log);
|
|
1301
|
+
}
|
|
1302
|
+
return logs;
|
|
1303
|
+
}
|
|
1304
|
+
async getStats(startDate, endDate) {
|
|
1305
|
+
const keys = this.getKeysForDateRange(
|
|
1306
|
+
startDate || new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3),
|
|
1307
|
+
endDate || /* @__PURE__ */ new Date()
|
|
1308
|
+
);
|
|
1309
|
+
const byAction = {};
|
|
1310
|
+
let totalEvents = 0;
|
|
1311
|
+
let failedLogins = 0;
|
|
1312
|
+
let successCount = 0;
|
|
1313
|
+
const uniqueUsers = /* @__PURE__ */ new Set();
|
|
1314
|
+
for (const key of keys) {
|
|
1315
|
+
const ids = await this.redis.zrange(key, 0, -1);
|
|
1316
|
+
for (const id of ids) {
|
|
1317
|
+
const log = await this.get(id);
|
|
1318
|
+
if (log) {
|
|
1319
|
+
totalEvents++;
|
|
1320
|
+
byAction[log.action] = (byAction[log.action] || 0) + 1;
|
|
1321
|
+
if (log.success) {
|
|
1322
|
+
successCount++;
|
|
1323
|
+
}
|
|
1324
|
+
if (log.action === "login_failed") {
|
|
1325
|
+
failedLogins++;
|
|
1326
|
+
}
|
|
1327
|
+
if (log.userId) {
|
|
1328
|
+
uniqueUsers.add(log.userId);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
return {
|
|
1334
|
+
totalEvents,
|
|
1335
|
+
byAction,
|
|
1336
|
+
successRate: totalEvents > 0 ? successCount / totalEvents : 1,
|
|
1337
|
+
failedLogins,
|
|
1338
|
+
uniqueUsers
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
async cleanup() {
|
|
1342
|
+
const cutoff = Date.now() - this.retentionDays * 24 * 60 * 60 * 1e3;
|
|
1343
|
+
const keys = await this.redis.keys(`${this.prefix}date:*`);
|
|
1344
|
+
let deleted = 0;
|
|
1345
|
+
for (const key of keys) {
|
|
1346
|
+
const timestamp = await this.redis.zrangebyscore(key, 0, cutoff);
|
|
1347
|
+
for (const id of timestamp) {
|
|
1348
|
+
await this.redis.del(`${this.prefix}log:${id}`);
|
|
1349
|
+
deleted++;
|
|
1350
|
+
}
|
|
1351
|
+
await this.redis.zremrangebyscore(key, 0, cutoff);
|
|
1352
|
+
}
|
|
1353
|
+
return deleted;
|
|
1354
|
+
}
|
|
1355
|
+
getKeyForDate(date) {
|
|
1356
|
+
const year = date.getFullYear();
|
|
1357
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
1358
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
1359
|
+
return `${this.prefix}date:${year}-${month}-${day}`;
|
|
1360
|
+
}
|
|
1361
|
+
getKeysForDateRange(start, end) {
|
|
1362
|
+
const keys = [];
|
|
1363
|
+
const startDate = start || new Date(Date.now() - this.retentionDays * 24 * 60 * 60 * 1e3);
|
|
1364
|
+
const endDate = end || /* @__PURE__ */ new Date();
|
|
1365
|
+
const current = new Date(startDate);
|
|
1366
|
+
while (current <= endDate) {
|
|
1367
|
+
keys.push(this.getKeyForDate(current));
|
|
1368
|
+
current.setDate(current.getDate() + 1);
|
|
1369
|
+
}
|
|
1370
|
+
return keys;
|
|
1371
|
+
}
|
|
1372
|
+
matchesFilter(log, filter) {
|
|
1373
|
+
if (filter.action) {
|
|
1374
|
+
const actions = Array.isArray(filter.action) ? filter.action : [filter.action];
|
|
1375
|
+
if (!actions.includes(log.action)) return false;
|
|
1376
|
+
}
|
|
1377
|
+
if (filter.resource && log.resource !== filter.resource) return false;
|
|
1378
|
+
if (filter.resourceId && log.resourceId !== filter.resourceId) return false;
|
|
1379
|
+
if (filter.success !== void 0 && log.success !== filter.success)
|
|
1380
|
+
return false;
|
|
1381
|
+
return true;
|
|
1382
|
+
}
|
|
1383
|
+
serializeLog(log) {
|
|
1384
|
+
const result = {
|
|
1385
|
+
id: log.id,
|
|
1386
|
+
timestamp: log.timestamp.toISOString(),
|
|
1387
|
+
action: log.action,
|
|
1388
|
+
resource: log.resource,
|
|
1389
|
+
success: log.success ? "1" : "0"
|
|
1390
|
+
};
|
|
1391
|
+
if (log.userId) result.userId = log.userId;
|
|
1392
|
+
if (log.userEmail) result.userEmail = log.userEmail;
|
|
1393
|
+
if (log.role) result.role = log.role;
|
|
1394
|
+
if (log.resourceId) result.resourceId = log.resourceId;
|
|
1395
|
+
if (log.ipAddress) result.ipAddress = log.ipAddress;
|
|
1396
|
+
if (log.userAgent) result.userAgent = log.userAgent;
|
|
1397
|
+
if (log.error) result.error = log.error;
|
|
1398
|
+
if (log.changes) result.changes = JSON.stringify(log.changes);
|
|
1399
|
+
if (log.metadata) result.metadata = JSON.stringify(log.metadata);
|
|
1400
|
+
return result;
|
|
1401
|
+
}
|
|
1402
|
+
deserializeLog(data) {
|
|
1403
|
+
return {
|
|
1404
|
+
id: data.id,
|
|
1405
|
+
timestamp: new Date(data.timestamp),
|
|
1406
|
+
action: data.action,
|
|
1407
|
+
userId: data.userId,
|
|
1408
|
+
userEmail: data.userEmail,
|
|
1409
|
+
role: data.role,
|
|
1410
|
+
resource: data.resource,
|
|
1411
|
+
resourceId: data.resourceId,
|
|
1412
|
+
ipAddress: data.ipAddress,
|
|
1413
|
+
userAgent: data.userAgent,
|
|
1414
|
+
success: data.success === "1",
|
|
1415
|
+
error: data.error,
|
|
1416
|
+
changes: data.changes ? JSON.parse(data.changes) : void 0,
|
|
1417
|
+
metadata: data.metadata ? JSON.parse(data.metadata) : void 0
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
function createAuditContext(req) {
|
|
1422
|
+
return {
|
|
1423
|
+
ipAddress: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown",
|
|
1424
|
+
userAgent: req.headers.get("user-agent") || "unknown"
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function defaultExtractToken(req) {
|
|
1429
|
+
const authHeader = req.headers.get("Authorization");
|
|
1430
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
1431
|
+
return authHeader.slice(7);
|
|
1432
|
+
}
|
|
1433
|
+
const cookieHeader = req.headers.get("Cookie");
|
|
1434
|
+
if (cookieHeader) {
|
|
1435
|
+
const cookies = Object.fromEntries(
|
|
1436
|
+
cookieHeader.split("; ").map((c) => {
|
|
1437
|
+
const [key, ...val] = c.split("=");
|
|
1438
|
+
return [key.trim(), val.join("=")];
|
|
1439
|
+
})
|
|
1440
|
+
);
|
|
1441
|
+
return cookies["auth_token"] || null;
|
|
1442
|
+
}
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
function generateToken(payload, secret, options = {}) {
|
|
1446
|
+
return jwt.sign(payload, secret, {
|
|
1447
|
+
expiresIn: options.expiresIn || "24h",
|
|
1448
|
+
issuer: options.issuer,
|
|
1449
|
+
audience: options.audience
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
class AuthRoutes {
|
|
1454
|
+
redis;
|
|
1455
|
+
email;
|
|
1456
|
+
jwtSecret;
|
|
1457
|
+
jwtExpiresIn;
|
|
1458
|
+
jwtIssuer;
|
|
1459
|
+
jwtAudience;
|
|
1460
|
+
passwordPolicy;
|
|
1461
|
+
lockout;
|
|
1462
|
+
rateLimiter;
|
|
1463
|
+
auditLogger;
|
|
1464
|
+
baseUrl;
|
|
1465
|
+
emailVerificationRequired;
|
|
1466
|
+
constructor(config) {
|
|
1467
|
+
this.redis = config.redis;
|
|
1468
|
+
this.email = config.email;
|
|
1469
|
+
this.jwtSecret = config.jwtSecret;
|
|
1470
|
+
this.jwtExpiresIn = config.jwtExpiresIn || "24h";
|
|
1471
|
+
this.jwtIssuer = config.jwtIssuer;
|
|
1472
|
+
this.jwtAudience = config.jwtAudience;
|
|
1473
|
+
this.passwordPolicy = config.passwordPolicy || new PasswordPolicy();
|
|
1474
|
+
this.lockout = config.lockout;
|
|
1475
|
+
this.rateLimiter = config.rateLimiter;
|
|
1476
|
+
this.auditLogger = config.auditLogger;
|
|
1477
|
+
this.baseUrl = config.baseUrl || "http://localhost:3000";
|
|
1478
|
+
this.emailVerificationRequired = config.emailVerificationRequired ?? true;
|
|
1479
|
+
}
|
|
1480
|
+
async register(req) {
|
|
1481
|
+
const { ipAddress, userAgent } = createAuditContext(req);
|
|
1482
|
+
if (this.rateLimiter) {
|
|
1483
|
+
const limit = await this.rateLimiter.check("auth:register", ipAddress);
|
|
1484
|
+
if (!limit.allowed) {
|
|
1485
|
+
return this.rateLimitResponse(limit);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
try {
|
|
1489
|
+
const body = await req.json();
|
|
1490
|
+
if (!body.email || !body.password) {
|
|
1491
|
+
return this.errorResponse("Email and password are required", 400);
|
|
1492
|
+
}
|
|
1493
|
+
if (body.password !== body.confirmPassword) {
|
|
1494
|
+
return this.errorResponse("Passwords do not match", 400);
|
|
1495
|
+
}
|
|
1496
|
+
const passwordValidation = this.passwordPolicy.validate(body.password);
|
|
1497
|
+
if (!passwordValidation.valid) {
|
|
1498
|
+
return this.errorResponse(passwordValidation.errors.join(". "), 400);
|
|
1499
|
+
}
|
|
1500
|
+
const existingUser = await this.redis.findUserByEmail(body.email);
|
|
1501
|
+
if (existingUser) {
|
|
1502
|
+
return this.errorResponse("Email already registered", 400);
|
|
1503
|
+
}
|
|
1504
|
+
const passwordHash = await this.redis.hashPassword(body.password);
|
|
1505
|
+
const user = await this.redis.createUser({
|
|
1506
|
+
email: body.email,
|
|
1507
|
+
passwordHash,
|
|
1508
|
+
role: body.role || "customer",
|
|
1509
|
+
tenantId: body.tenantId
|
|
1510
|
+
});
|
|
1511
|
+
if (this.emailVerificationRequired && this.email) {
|
|
1512
|
+
const verificationToken = randomBytes(32).toString("hex");
|
|
1513
|
+
const verificationUrl = `${this.baseUrl}/api/auth/verify?token=${verificationToken}`;
|
|
1514
|
+
await this.redis.createSession(user.id, { ipAddress, userAgent });
|
|
1515
|
+
const template = this.email.getTemplates().verifyEmail(verificationUrl, body.email);
|
|
1516
|
+
await this.email.send({ to: body.email, ...template });
|
|
1517
|
+
}
|
|
1518
|
+
if (this.auditLogger) {
|
|
1519
|
+
await this.auditLogger.log({
|
|
1520
|
+
action: "register",
|
|
1521
|
+
userId: user.id,
|
|
1522
|
+
userEmail: user.email,
|
|
1523
|
+
resource: "auth",
|
|
1524
|
+
ipAddress,
|
|
1525
|
+
userAgent,
|
|
1526
|
+
success: true
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
return this.jsonResponse(
|
|
1530
|
+
{
|
|
1531
|
+
success: true,
|
|
1532
|
+
message: "Registration successful",
|
|
1533
|
+
user: this.sanitizeUser(user),
|
|
1534
|
+
requiresVerification: this.emailVerificationRequired && !!this.email
|
|
1535
|
+
},
|
|
1536
|
+
201
|
|
1537
|
+
);
|
|
1538
|
+
} catch (error) {
|
|
1539
|
+
return this.errorResponse("Registration failed", 500);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
async login(req) {
|
|
1543
|
+
const { ipAddress, userAgent } = createAuditContext(req);
|
|
1544
|
+
if (this.rateLimiter) {
|
|
1545
|
+
const limit = await this.rateLimiter.check("auth:login", ipAddress);
|
|
1546
|
+
if (!limit.allowed) {
|
|
1547
|
+
return this.rateLimitResponse(limit);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
try {
|
|
1551
|
+
const body = await req.json();
|
|
1552
|
+
if (!body.email || !body.password) {
|
|
1553
|
+
return this.errorResponse("Email and password are required", 400);
|
|
1554
|
+
}
|
|
1555
|
+
const user = await this.redis.findUserByEmail(body.email);
|
|
1556
|
+
if (!user) {
|
|
1557
|
+
await this.recordFailedLogin(ipAddress, userAgent);
|
|
1558
|
+
return this.errorResponse("Invalid credentials", 401);
|
|
1559
|
+
}
|
|
1560
|
+
if (this.lockout) {
|
|
1561
|
+
const lockoutStatus = await this.lockout.checkLockout(user.id);
|
|
1562
|
+
if (lockoutStatus.locked) {
|
|
1563
|
+
if (this.auditLogger) {
|
|
1564
|
+
await this.auditLogger.log({
|
|
1565
|
+
action: "login_failed",
|
|
1566
|
+
userId: user.id,
|
|
1567
|
+
userEmail: user.email,
|
|
1568
|
+
resource: "auth",
|
|
1569
|
+
ipAddress,
|
|
1570
|
+
userAgent,
|
|
1571
|
+
success: false,
|
|
1572
|
+
error: "Account locked"
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
return this.errorResponse(
|
|
1576
|
+
`Account locked. Try again in ${Math.ceil((lockoutStatus.lockedUntil.getTime() - Date.now()) / 6e4)} minutes`,
|
|
1577
|
+
423
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
const validPassword = user.passwordHash ? await this.redis.verifyPassword(body.password, user.passwordHash) : false;
|
|
1582
|
+
if (!validPassword) {
|
|
1583
|
+
await this.recordFailedLogin(ipAddress, userAgent, user.id, user.email);
|
|
1584
|
+
return this.errorResponse("Invalid credentials", 401);
|
|
1585
|
+
}
|
|
1586
|
+
if (this.lockout) {
|
|
1587
|
+
await this.lockout.resetAttempts(user.id);
|
|
1588
|
+
}
|
|
1589
|
+
const session = await this.redis.createSession(user.id, {
|
|
1590
|
+
ipAddress,
|
|
1591
|
+
userAgent
|
|
1592
|
+
});
|
|
1593
|
+
const payload = {
|
|
1594
|
+
sub: user.id,
|
|
1595
|
+
email: user.email,
|
|
1596
|
+
role: user.role,
|
|
1597
|
+
tenantId: user.tenantId,
|
|
1598
|
+
iat: Math.floor(Date.now() / 1e3),
|
|
1599
|
+
exp: Math.floor(Date.now() / 1e3) + 86400
|
|
1600
|
+
};
|
|
1601
|
+
const accessToken = generateToken(payload, this.jwtSecret, {
|
|
1602
|
+
expiresIn: this.jwtExpiresIn,
|
|
1603
|
+
issuer: this.jwtIssuer,
|
|
1604
|
+
audience: this.jwtAudience
|
|
1605
|
+
});
|
|
1606
|
+
if (this.auditLogger) {
|
|
1607
|
+
await this.auditLogger.log({
|
|
1608
|
+
action: "login",
|
|
1609
|
+
userId: user.id,
|
|
1610
|
+
userEmail: user.email,
|
|
1611
|
+
role: user.role,
|
|
1612
|
+
resource: "auth",
|
|
1613
|
+
ipAddress,
|
|
1614
|
+
userAgent,
|
|
1615
|
+
success: true
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
await this.redis.updateUser(user.id, {
|
|
1619
|
+
lastLogin: (/* @__PURE__ */ new Date()).toISOString()
|
|
1620
|
+
});
|
|
1621
|
+
return this.jsonResponse({
|
|
1622
|
+
success: true,
|
|
1623
|
+
user: this.sanitizeUser(user),
|
|
1624
|
+
accessToken,
|
|
1625
|
+
refreshToken: session.refreshToken,
|
|
1626
|
+
expiresIn: this.jwtExpiresIn
|
|
1627
|
+
});
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
return this.errorResponse("Login failed", 500);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
async logout(req) {
|
|
1633
|
+
const token = defaultExtractToken(req);
|
|
1634
|
+
if (!token) {
|
|
1635
|
+
return this.errorResponse("No session to logout", 401);
|
|
1636
|
+
}
|
|
1637
|
+
const { ipAddress, userAgent } = createAuditContext(req);
|
|
1638
|
+
try {
|
|
1639
|
+
const payload = jwt.decode(token);
|
|
1640
|
+
if (payload && payload.sub) {
|
|
1641
|
+
await this.redis.deleteUserSessions(payload.sub);
|
|
1642
|
+
if (this.auditLogger) {
|
|
1643
|
+
await this.auditLogger.log({
|
|
1644
|
+
action: "logout",
|
|
1645
|
+
userId: payload.sub,
|
|
1646
|
+
userEmail: payload.email,
|
|
1647
|
+
resource: "auth",
|
|
1648
|
+
ipAddress,
|
|
1649
|
+
userAgent,
|
|
1650
|
+
success: true
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
return this.jsonResponse({
|
|
1655
|
+
success: true,
|
|
1656
|
+
message: "Logged out successfully"
|
|
1657
|
+
});
|
|
1658
|
+
} catch (error) {
|
|
1659
|
+
return this.errorResponse("Logout failed", 500);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
async refresh(req) {
|
|
1663
|
+
try {
|
|
1664
|
+
const body = await req.json();
|
|
1665
|
+
const { refreshToken } = body;
|
|
1666
|
+
if (!refreshToken) {
|
|
1667
|
+
return this.errorResponse("Refresh token required", 400);
|
|
1668
|
+
}
|
|
1669
|
+
return this.jsonResponse({ success: true, accessToken: "" });
|
|
1670
|
+
} catch (error) {
|
|
1671
|
+
return this.errorResponse("Token refresh failed", 500);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
async me(req) {
|
|
1675
|
+
const token = defaultExtractToken(req);
|
|
1676
|
+
if (!token) {
|
|
1677
|
+
return this.errorResponse("Not authenticated", 401);
|
|
1678
|
+
}
|
|
1679
|
+
try {
|
|
1680
|
+
const payload = jwt.verify(token, this.jwtSecret, {
|
|
1681
|
+
issuer: this.jwtIssuer,
|
|
1682
|
+
audience: this.jwtAudience
|
|
1683
|
+
});
|
|
1684
|
+
const user = await this.redis.findUserById(payload.sub);
|
|
1685
|
+
if (!user) {
|
|
1686
|
+
return this.errorResponse("User not found", 404);
|
|
1687
|
+
}
|
|
1688
|
+
return this.jsonResponse({
|
|
1689
|
+
success: true,
|
|
1690
|
+
user: this.sanitizeUser(user)
|
|
1691
|
+
});
|
|
1692
|
+
} catch (error) {
|
|
1693
|
+
return this.errorResponse("Authentication failed", 401);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
async changePassword(req) {
|
|
1697
|
+
const token = defaultExtractToken(req);
|
|
1698
|
+
if (!token) {
|
|
1699
|
+
return this.errorResponse("Not authenticated", 401);
|
|
1700
|
+
}
|
|
1701
|
+
const { ipAddress, userAgent } = createAuditContext(req);
|
|
1702
|
+
try {
|
|
1703
|
+
const payload = jwt.verify(token, this.jwtSecret);
|
|
1704
|
+
const body = await req.json();
|
|
1705
|
+
const { currentPassword, newPassword, confirmPassword } = body;
|
|
1706
|
+
if (!currentPassword || !newPassword) {
|
|
1707
|
+
return this.errorResponse("Current and new password required", 400);
|
|
1708
|
+
}
|
|
1709
|
+
if (newPassword !== confirmPassword) {
|
|
1710
|
+
return this.errorResponse("Passwords do not match", 400);
|
|
1711
|
+
}
|
|
1712
|
+
const passwordValidation = this.passwordPolicy.validate(newPassword);
|
|
1713
|
+
if (!passwordValidation.valid) {
|
|
1714
|
+
return this.errorResponse(passwordValidation.errors.join(". "), 400);
|
|
1715
|
+
}
|
|
1716
|
+
const user = await this.redis.findUserById(payload.sub);
|
|
1717
|
+
if (!user) {
|
|
1718
|
+
return this.errorResponse("User not found", 404);
|
|
1719
|
+
}
|
|
1720
|
+
const validPassword = user.passwordHash ? await this.redis.verifyPassword(currentPassword, user.passwordHash) : false;
|
|
1721
|
+
if (!validPassword) {
|
|
1722
|
+
return this.errorResponse("Current password is incorrect", 401);
|
|
1723
|
+
}
|
|
1724
|
+
const passwordHistory = await this.redis.getPasswordHistory(user.id, 5);
|
|
1725
|
+
const isReused = await this.redis.isPasswordInHistory(
|
|
1726
|
+
newPassword,
|
|
1727
|
+
user.id,
|
|
1728
|
+
5
|
|
1729
|
+
);
|
|
1730
|
+
if (isReused) {
|
|
1731
|
+
return this.errorResponse(
|
|
1732
|
+
"Password was recently used. Please choose a different password",
|
|
1733
|
+
400
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
const newPasswordHash = await this.redis.hashPassword(newPassword);
|
|
1737
|
+
if (user.passwordHash) {
|
|
1738
|
+
await this.redis.addPasswordToHistory(user.id, user.passwordHash);
|
|
1739
|
+
}
|
|
1740
|
+
await this.redis.updateUser(user.id, { passwordHash: newPasswordHash });
|
|
1741
|
+
await this.redis.deleteUserSessions(user.id);
|
|
1742
|
+
if (this.email && this.email.getTemplates) {
|
|
1743
|
+
const template = this.email.getTemplates().passwordChanged(user.email);
|
|
1744
|
+
await this.email.send({ to: user.email, ...template });
|
|
1745
|
+
}
|
|
1746
|
+
if (this.auditLogger) {
|
|
1747
|
+
await this.auditLogger.log({
|
|
1748
|
+
action: "password_change",
|
|
1749
|
+
userId: user.id,
|
|
1750
|
+
userEmail: user.email,
|
|
1751
|
+
resource: "auth",
|
|
1752
|
+
ipAddress,
|
|
1753
|
+
userAgent,
|
|
1754
|
+
success: true
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
return this.jsonResponse({
|
|
1758
|
+
success: true,
|
|
1759
|
+
message: "Password changed successfully"
|
|
1760
|
+
});
|
|
1761
|
+
} catch (error) {
|
|
1762
|
+
return this.errorResponse("Password change failed", 500);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
async forgotPassword(req) {
|
|
1766
|
+
const { ipAddress, userAgent } = createAuditContext(req);
|
|
1767
|
+
if (this.rateLimiter) {
|
|
1768
|
+
const limit = await this.rateLimiter.check("auth:forgot", ipAddress);
|
|
1769
|
+
if (!limit.allowed) {
|
|
1770
|
+
return this.rateLimitResponse(limit);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
try {
|
|
1774
|
+
const body = await req.json();
|
|
1775
|
+
const { email } = body;
|
|
1776
|
+
if (!email) {
|
|
1777
|
+
return this.errorResponse("Email required", 400);
|
|
1778
|
+
}
|
|
1779
|
+
const user = await this.redis.findUserByEmail(email);
|
|
1780
|
+
if (!user) {
|
|
1781
|
+
return this.jsonResponse({
|
|
1782
|
+
success: true,
|
|
1783
|
+
message: "If the email exists, a reset link has been sent"
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
if (this.email) {
|
|
1787
|
+
const resetToken = randomBytes(32).toString("hex");
|
|
1788
|
+
const resetUrl = `${this.baseUrl}/api/auth/reset-password?token=${resetToken}`;
|
|
1789
|
+
const template = this.email.getTemplates().resetPassword(resetUrl, user.email);
|
|
1790
|
+
await this.email.send({ to: user.email, ...template });
|
|
1791
|
+
}
|
|
1792
|
+
if (this.auditLogger) {
|
|
1793
|
+
await this.auditLogger.log({
|
|
1794
|
+
action: "password_reset_request",
|
|
1795
|
+
userId: user.id,
|
|
1796
|
+
userEmail: user.email,
|
|
1797
|
+
resource: "auth",
|
|
1798
|
+
ipAddress,
|
|
1799
|
+
userAgent,
|
|
1800
|
+
success: true
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
return this.jsonResponse({
|
|
1804
|
+
success: true,
|
|
1805
|
+
message: "If the email exists, a reset link has been sent"
|
|
1806
|
+
});
|
|
1807
|
+
} catch (error) {
|
|
1808
|
+
return this.errorResponse("Password reset request failed", 500);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
async verifyEmail(req) {
|
|
1812
|
+
const url = new URL(req.url);
|
|
1813
|
+
const token = url.searchParams.get("token");
|
|
1814
|
+
if (!token) {
|
|
1815
|
+
return this.errorResponse("Verification token required", 400);
|
|
1816
|
+
}
|
|
1817
|
+
try {
|
|
1818
|
+
return this.jsonResponse({ success: true, message: "Email verified" });
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
return this.errorResponse("Email verification failed", 500);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
async recordFailedLogin(ipAddress, userAgent, userId, userEmail) {
|
|
1824
|
+
if (this.lockout) {
|
|
1825
|
+
await this.lockout.recordFailedAttempt(userId || ipAddress);
|
|
1826
|
+
}
|
|
1827
|
+
if (this.auditLogger) {
|
|
1828
|
+
await this.auditLogger.log({
|
|
1829
|
+
action: "login_failed",
|
|
1830
|
+
userId,
|
|
1831
|
+
userEmail,
|
|
1832
|
+
resource: "auth",
|
|
1833
|
+
ipAddress,
|
|
1834
|
+
userAgent,
|
|
1835
|
+
success: false,
|
|
1836
|
+
error: "Invalid credentials"
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
sanitizeUser(user) {
|
|
1841
|
+
const { passwordHash, ...sanitized } = user;
|
|
1842
|
+
return sanitized;
|
|
1843
|
+
}
|
|
1844
|
+
jsonResponse(data, status = 200) {
|
|
1845
|
+
return new Response(JSON.stringify(data), {
|
|
1846
|
+
status,
|
|
1847
|
+
headers: {
|
|
1848
|
+
"Content-Type": "application/json"
|
|
1849
|
+
}
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
errorResponse(message, status) {
|
|
1853
|
+
return new Response(JSON.stringify({ success: false, error: message }), {
|
|
1854
|
+
status,
|
|
1855
|
+
headers: {
|
|
1856
|
+
"Content-Type": "application/json"
|
|
1857
|
+
}
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
rateLimitResponse(limit) {
|
|
1861
|
+
return new Response(
|
|
1862
|
+
JSON.stringify({
|
|
1863
|
+
success: false,
|
|
1864
|
+
error: "Too many requests",
|
|
1865
|
+
retryAfter: limit.retryAfter
|
|
1866
|
+
}),
|
|
1867
|
+
{
|
|
1868
|
+
status: 429,
|
|
1869
|
+
headers: {
|
|
1870
|
+
"Content-Type": "application/json",
|
|
1871
|
+
"Retry-After": String(limit.retryAfter || 60)
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function getEnv(key, fallback = "") {
|
|
1879
|
+
return process.env[key] || fallback;
|
|
1880
|
+
}
|
|
1881
|
+
function getEnvBool(key, fallback = false) {
|
|
1882
|
+
const val = process.env[key];
|
|
1883
|
+
if (!val) return fallback;
|
|
1884
|
+
return val.toLowerCase() === "true";
|
|
1885
|
+
}
|
|
1886
|
+
function getEnvNum(key, fallback = 0) {
|
|
1887
|
+
const val = process.env[key];
|
|
1888
|
+
if (!val) return fallback;
|
|
1889
|
+
return parseInt(val, 10);
|
|
1890
|
+
}
|
|
1891
|
+
async function createAuthConfig() {
|
|
1892
|
+
const redisUrl = getEnv("REDIS_URL", "redis://localhost:6379");
|
|
1893
|
+
const redisKeyPrefix = getEnv("REDIS_KEY_PREFIX", "kyro:auth:");
|
|
1894
|
+
const redisSessionTTL = getEnvNum("REDIS_SESSION_TTL", 86400);
|
|
1895
|
+
const redisRefreshTTL = getEnvNum("REDIS_REFRESH_TOKEN_TTL", 604800);
|
|
1896
|
+
const redisAdapter = new RedisAuthAdapter({
|
|
1897
|
+
url: redisUrl,
|
|
1898
|
+
keyPrefix: redisKeyPrefix,
|
|
1899
|
+
tokenExpiration: redisSessionTTL,
|
|
1900
|
+
refreshTokenExpiration: redisRefreshTTL,
|
|
1901
|
+
tls: getEnvBool("REDIS_TLS", false)
|
|
1902
|
+
});
|
|
1903
|
+
await redisAdapter.connect();
|
|
1904
|
+
const redisClient = redisAdapter.redis;
|
|
1905
|
+
const emailConfig = getEmailConfig();
|
|
1906
|
+
const email = emailConfig ? new EmailTransport(emailConfig) : void 0;
|
|
1907
|
+
const passwordPolicy = new PasswordPolicy({
|
|
1908
|
+
minLength: getEnvNum("PASSWORD_MIN_LENGTH", 12),
|
|
1909
|
+
requireUppercase: getEnvBool("PASSWORD_REQUIRE_UPPERCASE", true),
|
|
1910
|
+
requireLowercase: getEnvBool("PASSWORD_REQUIRE_LOWERCASE", true),
|
|
1911
|
+
requireNumbers: getEnvBool("PASSWORD_REQUIRE_NUMBERS", true),
|
|
1912
|
+
requireSpecialChars: getEnvBool("PASSWORD_REQUIRE_SPECIAL", true),
|
|
1913
|
+
preventReuse: getEnvNum("PASSWORD_PREVENT_REUSE", 5),
|
|
1914
|
+
maxLength: getEnvNum("PASSWORD_MAX_LENGTH", 128)
|
|
1915
|
+
});
|
|
1916
|
+
let lockout;
|
|
1917
|
+
if (getEnvBool("LOCKOUT_ENABLED", true)) {
|
|
1918
|
+
lockout = new AccountLockout(redisClient, {
|
|
1919
|
+
maxAttempts: getEnvNum("LOCKOUT_MAX_ATTEMPTS", 5),
|
|
1920
|
+
lockDuration: getEnvNum("LOCKOUT_DURATION_MINUTES", 15) * 60 * 1e3
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
let rateLimiter;
|
|
1924
|
+
if (getEnvBool("RATE_LIMIT_ENABLED", true)) {
|
|
1925
|
+
rateLimiter = new RateLimiter(redisClient, {
|
|
1926
|
+
"auth:login": {
|
|
1927
|
+
window: getEnvNum("RATE_LIMIT_AUTH_WINDOW_MS", 9e5),
|
|
1928
|
+
max: getEnvNum("RATE_LIMIT_AUTH_MAX_REQUESTS", 10)
|
|
1929
|
+
},
|
|
1930
|
+
"api:general": {
|
|
1931
|
+
window: getEnvNum("RATE_LIMIT_WINDOW_MS", 6e4),
|
|
1932
|
+
max: getEnvNum("RATE_LIMIT_MAX_REQUESTS", 100)
|
|
1933
|
+
}
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
let auditLogger;
|
|
1937
|
+
if (getEnvBool("AUDIT_LOG_ENABLED", true)) {
|
|
1938
|
+
auditLogger = new AuditLogger(
|
|
1939
|
+
redisClient,
|
|
1940
|
+
getEnvNum("AUDIT_LOG_RETENTION_DAYS", 30)
|
|
1941
|
+
);
|
|
1942
|
+
}
|
|
1943
|
+
const routes = new AuthRoutes({
|
|
1944
|
+
redis: redisAdapter,
|
|
1945
|
+
email,
|
|
1946
|
+
jwtSecret: getEnv("JWT_SECRET", "change-me"),
|
|
1947
|
+
jwtExpiresIn: getEnv("JWT_EXPIRES_IN", "24h"),
|
|
1948
|
+
jwtIssuer: getEnv("JWT_ISSUER", "kyro-cms"),
|
|
1949
|
+
jwtAudience: getEnv("JWT_AUDIENCE", "kyro-cms-client"),
|
|
1950
|
+
passwordPolicy,
|
|
1951
|
+
lockout,
|
|
1952
|
+
rateLimiter,
|
|
1953
|
+
auditLogger,
|
|
1954
|
+
baseUrl: getEnv("EMAIL_BASE_URL", "http://localhost:4321"),
|
|
1955
|
+
emailVerificationRequired: getEnvBool("EMAIL_VERIFICATION_REQUIRED", true)
|
|
1956
|
+
});
|
|
1957
|
+
return {
|
|
1958
|
+
redis: redisAdapter,
|
|
1959
|
+
redisClient,
|
|
1960
|
+
email,
|
|
1961
|
+
passwordPolicy,
|
|
1962
|
+
lockout,
|
|
1963
|
+
rateLimiter,
|
|
1964
|
+
auditLogger,
|
|
1965
|
+
routes
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
function getEmailConfig() {
|
|
1969
|
+
const host = getEnv("SMTP_HOST");
|
|
1970
|
+
if (!host) return void 0;
|
|
1971
|
+
return {
|
|
1972
|
+
host,
|
|
1973
|
+
port: getEnvNum("SMTP_PORT", 587),
|
|
1974
|
+
secure: getEnvBool("SMTP_SECURE", false),
|
|
1975
|
+
auth: {
|
|
1976
|
+
user: getEnv("SMTP_USER"),
|
|
1977
|
+
pass: getEnv("SMTP_PASS")
|
|
1978
|
+
},
|
|
1979
|
+
from: getEnv("SMTP_FROM", "noreply@example.com"),
|
|
1980
|
+
fromName: getEnv("SMTP_FROM_NAME", "Kyro CMS")
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
createAuthConfig();
|
|
1984
|
+
|
|
1985
|
+
const minimalCollections = {
|
|
1986
|
+
posts: {
|
|
1987
|
+
slug: "posts",
|
|
1988
|
+
label: "Posts",
|
|
1989
|
+
labelPlural: "Posts",
|
|
1990
|
+
singularLabel: "Post",
|
|
1991
|
+
admin: {
|
|
1992
|
+
useAsTitle: "title",
|
|
1993
|
+
defaultColumns: ["title", "status", "createdAt"],
|
|
1994
|
+
description: "Blog posts and articles"
|
|
1995
|
+
},
|
|
1996
|
+
fields: [
|
|
1997
|
+
{
|
|
1998
|
+
name: "title",
|
|
1999
|
+
type: "text",
|
|
2000
|
+
required: true,
|
|
2001
|
+
label: "Title",
|
|
2002
|
+
admin: { description: "The post title" }
|
|
2003
|
+
},
|
|
2004
|
+
{
|
|
2005
|
+
name: "slug",
|
|
2006
|
+
type: "text",
|
|
2007
|
+
required: true,
|
|
2008
|
+
label: "Slug",
|
|
2009
|
+
admin: { description: "URL-friendly identifier" }
|
|
2010
|
+
},
|
|
2011
|
+
{
|
|
2012
|
+
name: "content",
|
|
2013
|
+
type: "richtext",
|
|
2014
|
+
label: "Content"
|
|
2015
|
+
},
|
|
2016
|
+
{
|
|
2017
|
+
name: "status",
|
|
2018
|
+
type: "select",
|
|
2019
|
+
label: "Status",
|
|
2020
|
+
options: [
|
|
2021
|
+
{ label: "Draft", value: "draft" },
|
|
2022
|
+
{ label: "Published", value: "published" }
|
|
2023
|
+
],
|
|
2024
|
+
defaultValue: "draft",
|
|
2025
|
+
admin: {
|
|
2026
|
+
description: "Publication status"
|
|
2027
|
+
}
|
|
2028
|
+
},
|
|
2029
|
+
{
|
|
2030
|
+
name: "publishedAt",
|
|
2031
|
+
type: "date",
|
|
2032
|
+
label: "Published At",
|
|
2033
|
+
admin: { description: "When to publish this post" }
|
|
2034
|
+
}
|
|
2035
|
+
],
|
|
2036
|
+
timestamps: true
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
const blogCollections = {
|
|
2041
|
+
posts: {
|
|
2042
|
+
slug: "posts",
|
|
2043
|
+
label: "Posts",
|
|
2044
|
+
labelPlural: "Posts",
|
|
2045
|
+
singularLabel: "Post",
|
|
2046
|
+
admin: {
|
|
2047
|
+
useAsTitle: "title",
|
|
2048
|
+
defaultColumns: ["title", "category", "status", "createdAt"],
|
|
2049
|
+
description: "Blog posts and articles"
|
|
2050
|
+
},
|
|
2051
|
+
fields: [
|
|
2052
|
+
{
|
|
2053
|
+
name: "title",
|
|
2054
|
+
type: "text",
|
|
2055
|
+
required: true,
|
|
2056
|
+
label: "Title",
|
|
2057
|
+
admin: {
|
|
2058
|
+
description: "The main title of the post as it will appear publicly."
|
|
2059
|
+
}
|
|
2060
|
+
},
|
|
2061
|
+
{
|
|
2062
|
+
name: "slug",
|
|
2063
|
+
type: "text",
|
|
2064
|
+
required: true,
|
|
2065
|
+
label: "Slug",
|
|
2066
|
+
admin: { description: "The URL-friendly identifier for the post." }
|
|
2067
|
+
},
|
|
2068
|
+
{
|
|
2069
|
+
name: "excerpt",
|
|
2070
|
+
type: "textarea",
|
|
2071
|
+
label: "Excerpt",
|
|
2072
|
+
admin: {
|
|
2073
|
+
description: "A brief summary of the post used in listings and SEO meta tags."
|
|
2074
|
+
}
|
|
2075
|
+
},
|
|
2076
|
+
{
|
|
2077
|
+
name: "content",
|
|
2078
|
+
type: "richtext",
|
|
2079
|
+
label: "Content",
|
|
2080
|
+
admin: {
|
|
2081
|
+
description: "The comprehensive body content of the blog post."
|
|
2082
|
+
}
|
|
2083
|
+
},
|
|
2084
|
+
{
|
|
2085
|
+
name: "featuredImage",
|
|
2086
|
+
type: "upload",
|
|
2087
|
+
label: "Featured Image",
|
|
2088
|
+
relationTo: "media",
|
|
2089
|
+
admin: {
|
|
2090
|
+
description: "The primary visual used to represent this post."
|
|
2091
|
+
}
|
|
2092
|
+
},
|
|
2093
|
+
{
|
|
2094
|
+
name: "category",
|
|
2095
|
+
type: "relationship",
|
|
2096
|
+
label: "Category",
|
|
2097
|
+
relationTo: "categories",
|
|
2098
|
+
admin: {
|
|
2099
|
+
description: "Select the primary category this post belongs to."
|
|
2100
|
+
}
|
|
2101
|
+
},
|
|
2102
|
+
{
|
|
2103
|
+
name: "tags",
|
|
2104
|
+
type: "array",
|
|
2105
|
+
label: "Tags",
|
|
2106
|
+
fields: [{ name: "tag", type: "text" }]
|
|
2107
|
+
},
|
|
2108
|
+
{
|
|
2109
|
+
name: "status",
|
|
2110
|
+
type: "select",
|
|
2111
|
+
label: "Status",
|
|
2112
|
+
options: [
|
|
2113
|
+
{ label: "Draft", value: "draft" },
|
|
2114
|
+
{ label: "Published", value: "published" }
|
|
2115
|
+
],
|
|
2116
|
+
defaultValue: "draft"
|
|
2117
|
+
},
|
|
2118
|
+
{
|
|
2119
|
+
name: "publishedAt",
|
|
2120
|
+
type: "date",
|
|
2121
|
+
label: "Published At"
|
|
2122
|
+
}
|
|
2123
|
+
],
|
|
2124
|
+
timestamps: true
|
|
2125
|
+
},
|
|
2126
|
+
categories: {
|
|
2127
|
+
slug: "categories",
|
|
2128
|
+
label: "Categories",
|
|
2129
|
+
labelPlural: "Categories",
|
|
2130
|
+
singularLabel: "Category",
|
|
2131
|
+
admin: {
|
|
2132
|
+
useAsTitle: "name",
|
|
2133
|
+
defaultColumns: ["name", "slug"],
|
|
2134
|
+
description: "Post categories"
|
|
2135
|
+
},
|
|
2136
|
+
fields: [
|
|
2137
|
+
{
|
|
2138
|
+
name: "name",
|
|
2139
|
+
type: "text",
|
|
2140
|
+
required: true,
|
|
2141
|
+
label: "Name"
|
|
2142
|
+
},
|
|
2143
|
+
{
|
|
2144
|
+
name: "slug",
|
|
2145
|
+
type: "text",
|
|
2146
|
+
required: true,
|
|
2147
|
+
label: "Slug"
|
|
2148
|
+
},
|
|
2149
|
+
{
|
|
2150
|
+
name: "description",
|
|
2151
|
+
type: "textarea",
|
|
2152
|
+
label: "Description"
|
|
2153
|
+
},
|
|
2154
|
+
{
|
|
2155
|
+
name: "parent",
|
|
2156
|
+
type: "relationship",
|
|
2157
|
+
label: "Parent Category",
|
|
2158
|
+
relationTo: "categories"
|
|
2159
|
+
}
|
|
2160
|
+
],
|
|
2161
|
+
timestamps: true
|
|
2162
|
+
}
|
|
2163
|
+
};
|
|
2164
|
+
|
|
2165
|
+
const ecommerceCollections = {
|
|
2166
|
+
products: {
|
|
2167
|
+
slug: "products",
|
|
2168
|
+
label: "Products",
|
|
2169
|
+
labelPlural: "Products",
|
|
2170
|
+
singularLabel: "Product",
|
|
2171
|
+
admin: {
|
|
2172
|
+
useAsTitle: "title",
|
|
2173
|
+
defaultColumns: ["title", "price", "status", "inventory"],
|
|
2174
|
+
description: "Product catalog"
|
|
2175
|
+
},
|
|
2176
|
+
fields: [
|
|
2177
|
+
{
|
|
2178
|
+
name: "title",
|
|
2179
|
+
type: "text",
|
|
2180
|
+
required: true,
|
|
2181
|
+
label: "Title"
|
|
2182
|
+
},
|
|
2183
|
+
{
|
|
2184
|
+
name: "slug",
|
|
2185
|
+
type: "text",
|
|
2186
|
+
required: true,
|
|
2187
|
+
label: "Slug"
|
|
2188
|
+
},
|
|
2189
|
+
{
|
|
2190
|
+
name: "description",
|
|
2191
|
+
type: "richtext",
|
|
2192
|
+
label: "Description"
|
|
2193
|
+
},
|
|
2194
|
+
{
|
|
2195
|
+
name: "price",
|
|
2196
|
+
type: "number",
|
|
2197
|
+
required: true,
|
|
2198
|
+
label: "Price"
|
|
2199
|
+
},
|
|
2200
|
+
{
|
|
2201
|
+
name: "compareAtPrice",
|
|
2202
|
+
type: "number",
|
|
2203
|
+
label: "Compare at Price",
|
|
2204
|
+
admin: { description: "Original price for sale display" }
|
|
2205
|
+
},
|
|
2206
|
+
{
|
|
2207
|
+
name: "costPrice",
|
|
2208
|
+
type: "number",
|
|
2209
|
+
label: "Cost Price",
|
|
2210
|
+
admin: { description: "For profit calculation" }
|
|
2211
|
+
},
|
|
2212
|
+
{
|
|
2213
|
+
name: "sku",
|
|
2214
|
+
type: "text",
|
|
2215
|
+
required: true,
|
|
2216
|
+
label: "SKU"
|
|
2217
|
+
},
|
|
2218
|
+
{
|
|
2219
|
+
name: "barcode",
|
|
2220
|
+
type: "text",
|
|
2221
|
+
label: "Barcode"
|
|
2222
|
+
},
|
|
2223
|
+
{
|
|
2224
|
+
name: "status",
|
|
2225
|
+
type: "select",
|
|
2226
|
+
label: "Status",
|
|
2227
|
+
options: [
|
|
2228
|
+
{ label: "Draft", value: "draft" },
|
|
2229
|
+
{ label: "Active", value: "active" },
|
|
2230
|
+
{ label: "Archived", value: "archived" }
|
|
2231
|
+
],
|
|
2232
|
+
defaultValue: "draft"
|
|
2233
|
+
},
|
|
2234
|
+
{
|
|
2235
|
+
name: "images",
|
|
2236
|
+
type: "array",
|
|
2237
|
+
label: "Images",
|
|
2238
|
+
fields: [
|
|
2239
|
+
{ name: "url", type: "text", label: "URL" },
|
|
2240
|
+
{ name: "alt", type: "text", label: "Alt Text" }
|
|
2241
|
+
]
|
|
2242
|
+
},
|
|
2243
|
+
{
|
|
2244
|
+
name: "category",
|
|
2245
|
+
type: "relationship",
|
|
2246
|
+
label: "Category",
|
|
2247
|
+
relationTo: "categories"
|
|
2248
|
+
},
|
|
2249
|
+
{
|
|
2250
|
+
name: "inventory",
|
|
2251
|
+
type: "number",
|
|
2252
|
+
label: "Inventory",
|
|
2253
|
+
defaultValue: 0
|
|
2254
|
+
}
|
|
2255
|
+
],
|
|
2256
|
+
timestamps: true
|
|
2257
|
+
},
|
|
2258
|
+
categories: {
|
|
2259
|
+
slug: "categories",
|
|
2260
|
+
label: "Categories",
|
|
2261
|
+
labelPlural: "Categories",
|
|
2262
|
+
singularLabel: "Category",
|
|
2263
|
+
admin: {
|
|
2264
|
+
useAsTitle: "name",
|
|
2265
|
+
defaultColumns: ["name", "slug", "productCount"],
|
|
2266
|
+
description: "Product categories"
|
|
2267
|
+
},
|
|
2268
|
+
fields: [
|
|
2269
|
+
{ name: "name", type: "text", required: true, label: "Name" },
|
|
2270
|
+
{ name: "slug", type: "text", required: true, label: "Slug" },
|
|
2271
|
+
{ name: "description", type: "textarea", label: "Description" },
|
|
2272
|
+
{ name: "image", type: "text", label: "Image URL" },
|
|
2273
|
+
{
|
|
2274
|
+
name: "parent",
|
|
2275
|
+
type: "relationship",
|
|
2276
|
+
label: "Parent Category",
|
|
2277
|
+
relationTo: "categories"
|
|
2278
|
+
}
|
|
2279
|
+
],
|
|
2280
|
+
timestamps: true
|
|
2281
|
+
},
|
|
2282
|
+
customers: {
|
|
2283
|
+
slug: "customers",
|
|
2284
|
+
label: "Customers",
|
|
2285
|
+
labelPlural: "Customers",
|
|
2286
|
+
singularLabel: "Customer",
|
|
2287
|
+
admin: {
|
|
2288
|
+
useAsTitle: "email",
|
|
2289
|
+
defaultColumns: [
|
|
2290
|
+
"email",
|
|
2291
|
+
"firstName",
|
|
2292
|
+
"lastName",
|
|
2293
|
+
"orderCount",
|
|
2294
|
+
"createdAt"
|
|
2295
|
+
],
|
|
2296
|
+
description: "Customer accounts"
|
|
2297
|
+
},
|
|
2298
|
+
fields: [
|
|
2299
|
+
{ name: "email", type: "email", required: true, label: "Email" },
|
|
2300
|
+
{ name: "firstName", type: "text", label: "First Name" },
|
|
2301
|
+
{ name: "lastName", type: "text", label: "Last Name" },
|
|
2302
|
+
{ name: "phone", type: "text", label: "Phone" },
|
|
2303
|
+
{
|
|
2304
|
+
name: "addresses",
|
|
2305
|
+
type: "array",
|
|
2306
|
+
label: "Addresses",
|
|
2307
|
+
fields: [
|
|
2308
|
+
{ name: "type", type: "text", label: "Type" },
|
|
2309
|
+
{ name: "line1", type: "text", label: "Address Line 1" },
|
|
2310
|
+
{ name: "line2", type: "text", label: "Address Line 2" },
|
|
2311
|
+
{ name: "city", type: "text", label: "City" },
|
|
2312
|
+
{ name: "state", type: "text", label: "State" },
|
|
2313
|
+
{ name: "postalCode", type: "text", label: "Postal Code" },
|
|
2314
|
+
{ name: "country", type: "text", label: "Country" }
|
|
2315
|
+
]
|
|
2316
|
+
},
|
|
2317
|
+
{
|
|
2318
|
+
name: "status",
|
|
2319
|
+
type: "select",
|
|
2320
|
+
label: "Status",
|
|
2321
|
+
options: [
|
|
2322
|
+
{ label: "Active", value: "active" },
|
|
2323
|
+
{ label: "Inactive", value: "inactive" },
|
|
2324
|
+
{ label: "Banned", value: "banned" }
|
|
2325
|
+
],
|
|
2326
|
+
defaultValue: "active"
|
|
2327
|
+
}
|
|
2328
|
+
],
|
|
2329
|
+
timestamps: true
|
|
2330
|
+
},
|
|
2331
|
+
orders: {
|
|
2332
|
+
slug: "orders",
|
|
2333
|
+
label: "Orders",
|
|
2334
|
+
labelPlural: "Orders",
|
|
2335
|
+
singularLabel: "Order",
|
|
2336
|
+
admin: {
|
|
2337
|
+
useAsTitle: "orderNumber",
|
|
2338
|
+
defaultColumns: [
|
|
2339
|
+
"orderNumber",
|
|
2340
|
+
"customer",
|
|
2341
|
+
"status",
|
|
2342
|
+
"total",
|
|
2343
|
+
"createdAt"
|
|
2344
|
+
],
|
|
2345
|
+
description: "Customer orders"
|
|
2346
|
+
},
|
|
2347
|
+
fields: [
|
|
2348
|
+
{
|
|
2349
|
+
name: "orderNumber",
|
|
2350
|
+
type: "text",
|
|
2351
|
+
required: true,
|
|
2352
|
+
label: "Order Number"
|
|
2353
|
+
},
|
|
2354
|
+
{
|
|
2355
|
+
name: "customer",
|
|
2356
|
+
type: "relationship",
|
|
2357
|
+
required: true,
|
|
2358
|
+
label: "Customer",
|
|
2359
|
+
relationTo: "customers"
|
|
2360
|
+
},
|
|
2361
|
+
{
|
|
2362
|
+
name: "status",
|
|
2363
|
+
type: "select",
|
|
2364
|
+
label: "Status",
|
|
2365
|
+
options: [
|
|
2366
|
+
{ label: "Pending", value: "pending" },
|
|
2367
|
+
{ label: "Confirmed", value: "confirmed" },
|
|
2368
|
+
{ label: "Processing", value: "processing" },
|
|
2369
|
+
{ label: "Shipped", value: "shipped" },
|
|
2370
|
+
{ label: "Delivered", value: "delivered" },
|
|
2371
|
+
{ label: "Cancelled", value: "cancelled" },
|
|
2372
|
+
{ label: "Refunded", value: "refunded" }
|
|
2373
|
+
],
|
|
2374
|
+
defaultValue: "pending"
|
|
2375
|
+
},
|
|
2376
|
+
{
|
|
2377
|
+
name: "paymentStatus",
|
|
2378
|
+
type: "select",
|
|
2379
|
+
label: "Payment Status",
|
|
2380
|
+
options: [
|
|
2381
|
+
{ label: "Pending", value: "pending" },
|
|
2382
|
+
{ label: "Paid", value: "paid" },
|
|
2383
|
+
{ label: "Failed", value: "failed" },
|
|
2384
|
+
{ label: "Refunded", value: "refunded" }
|
|
2385
|
+
],
|
|
2386
|
+
defaultValue: "pending"
|
|
2387
|
+
},
|
|
2388
|
+
{
|
|
2389
|
+
name: "items",
|
|
2390
|
+
type: "array",
|
|
2391
|
+
label: "Items",
|
|
2392
|
+
fields: [
|
|
2393
|
+
{ name: "product", type: "text", label: "Product" },
|
|
2394
|
+
{ name: "quantity", type: "number", label: "Quantity" },
|
|
2395
|
+
{ name: "unitPrice", type: "number", label: "Unit Price" },
|
|
2396
|
+
{ name: "total", type: "number", label: "Total" }
|
|
2397
|
+
]
|
|
2398
|
+
},
|
|
2399
|
+
{ name: "subtotal", type: "number", required: true, label: "Subtotal" },
|
|
2400
|
+
{ name: "tax", type: "number", label: "Tax" },
|
|
2401
|
+
{ name: "shipping", type: "number", label: "Shipping" },
|
|
2402
|
+
{ name: "discount", type: "number", label: "Discount" },
|
|
2403
|
+
{ name: "total", type: "number", required: true, label: "Total" },
|
|
2404
|
+
{ name: "notes", type: "textarea", label: "Notes" }
|
|
2405
|
+
],
|
|
2406
|
+
timestamps: true
|
|
2407
|
+
},
|
|
2408
|
+
coupons: {
|
|
2409
|
+
slug: "coupons",
|
|
2410
|
+
label: "Coupons",
|
|
2411
|
+
labelPlural: "Coupons",
|
|
2412
|
+
singularLabel: "Coupon",
|
|
2413
|
+
admin: {
|
|
2414
|
+
useAsTitle: "code",
|
|
2415
|
+
defaultColumns: ["code", "type", "value", "active", "expiresAt"],
|
|
2416
|
+
description: "Discount codes and promotions"
|
|
2417
|
+
},
|
|
2418
|
+
fields: [
|
|
2419
|
+
{ name: "code", type: "text", required: true, label: "Code" },
|
|
2420
|
+
{
|
|
2421
|
+
name: "type",
|
|
2422
|
+
type: "select",
|
|
2423
|
+
required: true,
|
|
2424
|
+
label: "Type",
|
|
2425
|
+
options: [
|
|
2426
|
+
{ label: "Percentage", value: "percentage" },
|
|
2427
|
+
{ label: "Fixed Amount", value: "fixed" },
|
|
2428
|
+
{ label: "Free Shipping", value: "freeShipping" }
|
|
2429
|
+
]
|
|
2430
|
+
},
|
|
2431
|
+
{ name: "value", type: "number", label: "Value" },
|
|
2432
|
+
{ name: "minPurchase", type: "number", label: "Minimum Purchase" },
|
|
2433
|
+
{ name: "maxDiscount", type: "number", label: "Max Discount" },
|
|
2434
|
+
{ name: "usageLimit", type: "number", label: "Usage Limit" },
|
|
2435
|
+
{
|
|
2436
|
+
name: "usedCount",
|
|
2437
|
+
type: "number",
|
|
2438
|
+
defaultValue: 0,
|
|
2439
|
+
label: "Used Count"
|
|
2440
|
+
},
|
|
2441
|
+
{ name: "startsAt", type: "date", label: "Starts At" },
|
|
2442
|
+
{ name: "expiresAt", type: "date", label: "Expires At" },
|
|
2443
|
+
{ name: "active", type: "checkbox", defaultValue: true, label: "Active" }
|
|
2444
|
+
],
|
|
2445
|
+
timestamps: true
|
|
2446
|
+
}
|
|
2447
|
+
};
|
|
2448
|
+
|
|
2449
|
+
const kitchenSinkCollections = {
|
|
2450
|
+
pages: {
|
|
2451
|
+
slug: "pages",
|
|
2452
|
+
label: "Pages",
|
|
2453
|
+
labelPlural: "Pages",
|
|
2454
|
+
singularLabel: "Page",
|
|
2455
|
+
admin: {
|
|
2456
|
+
useAsTitle: "title",
|
|
2457
|
+
defaultColumns: ["title", "slug", "status", "updatedAt"],
|
|
2458
|
+
description: "Static pages with custom layouts"
|
|
2459
|
+
},
|
|
2460
|
+
fields: [
|
|
2461
|
+
{
|
|
2462
|
+
name: "title",
|
|
2463
|
+
type: "text",
|
|
2464
|
+
required: true,
|
|
2465
|
+
label: "Title"
|
|
2466
|
+
},
|
|
2467
|
+
{
|
|
2468
|
+
name: "slug",
|
|
2469
|
+
type: "text",
|
|
2470
|
+
required: true,
|
|
2471
|
+
label: "Slug",
|
|
2472
|
+
admin: { description: "URL-friendly identifier" }
|
|
2473
|
+
},
|
|
2474
|
+
{
|
|
2475
|
+
name: "content",
|
|
2476
|
+
type: "blocks",
|
|
2477
|
+
label: "Content",
|
|
2478
|
+
blocks: [
|
|
2479
|
+
{
|
|
2480
|
+
slug: "hero",
|
|
2481
|
+
label: "Hero Section",
|
|
2482
|
+
fields: [
|
|
2483
|
+
{ name: "heading", type: "text", label: "Heading" },
|
|
2484
|
+
{ name: "subheading", type: "textarea", label: "Subheading" },
|
|
2485
|
+
{ name: "ctaText", type: "text", label: "CTA Button Text" },
|
|
2486
|
+
{ name: "ctaUrl", type: "text", label: "CTA Button URL" }
|
|
2487
|
+
]
|
|
2488
|
+
},
|
|
2489
|
+
{
|
|
2490
|
+
slug: "text",
|
|
2491
|
+
label: "Text Block",
|
|
2492
|
+
fields: [{ name: "content", type: "richtext", label: "Content" }]
|
|
2493
|
+
},
|
|
2494
|
+
{
|
|
2495
|
+
slug: "image",
|
|
2496
|
+
label: "Image",
|
|
2497
|
+
fields: [
|
|
2498
|
+
{
|
|
2499
|
+
name: "image",
|
|
2500
|
+
type: "upload",
|
|
2501
|
+
label: "Image",
|
|
2502
|
+
relationTo: "media"
|
|
2503
|
+
},
|
|
2504
|
+
{ name: "caption", type: "text", label: "Caption" },
|
|
2505
|
+
{ name: "alt", type: "text", label: "Alt Text" }
|
|
2506
|
+
]
|
|
2507
|
+
},
|
|
2508
|
+
{
|
|
2509
|
+
slug: "cta",
|
|
2510
|
+
label: "Call to Action",
|
|
2511
|
+
fields: [
|
|
2512
|
+
{ name: "heading", type: "text", label: "Heading" },
|
|
2513
|
+
{ name: "description", type: "textarea", label: "Description" },
|
|
2514
|
+
{ name: "buttonText", type: "text", label: "Button Text" },
|
|
2515
|
+
{ name: "buttonUrl", type: "text", label: "Button URL" }
|
|
2516
|
+
]
|
|
2517
|
+
}
|
|
2518
|
+
]
|
|
2519
|
+
},
|
|
2520
|
+
{
|
|
2521
|
+
name: "status",
|
|
2522
|
+
type: "select",
|
|
2523
|
+
label: "Status",
|
|
2524
|
+
options: [
|
|
2525
|
+
{ label: "Draft", value: "draft" },
|
|
2526
|
+
{ label: "Published", value: "published" }
|
|
2527
|
+
],
|
|
2528
|
+
defaultValue: "draft"
|
|
2529
|
+
},
|
|
2530
|
+
{
|
|
2531
|
+
name: "publishedAt",
|
|
2532
|
+
type: "date",
|
|
2533
|
+
label: "Published At"
|
|
2534
|
+
}
|
|
2535
|
+
],
|
|
2536
|
+
timestamps: true
|
|
2537
|
+
},
|
|
2538
|
+
navigation: {
|
|
2539
|
+
slug: "navigation",
|
|
2540
|
+
label: "Navigation",
|
|
2541
|
+
labelPlural: "Navigation",
|
|
2542
|
+
singularLabel: "Navigation Item",
|
|
2543
|
+
admin: {
|
|
2544
|
+
useAsTitle: "label",
|
|
2545
|
+
defaultColumns: ["label", "url", "location"],
|
|
2546
|
+
description: "Site navigation menus"
|
|
2547
|
+
},
|
|
2548
|
+
fields: [
|
|
2549
|
+
{ name: "label", type: "text", required: true, label: "Label" },
|
|
2550
|
+
{ name: "url", type: "text", required: true, label: "URL" },
|
|
2551
|
+
{
|
|
2552
|
+
name: "location",
|
|
2553
|
+
type: "select",
|
|
2554
|
+
label: "Location",
|
|
2555
|
+
options: [
|
|
2556
|
+
{ label: "Header", value: "header" },
|
|
2557
|
+
{ label: "Footer", value: "footer" },
|
|
2558
|
+
{ label: "Mobile", value: "mobile" }
|
|
2559
|
+
]
|
|
2560
|
+
},
|
|
2561
|
+
{ name: "order", type: "number", label: "Sort Order", defaultValue: 0 },
|
|
2562
|
+
{
|
|
2563
|
+
name: "parent",
|
|
2564
|
+
type: "relationship",
|
|
2565
|
+
label: "Parent Item",
|
|
2566
|
+
relationTo: "navigation"
|
|
2567
|
+
},
|
|
2568
|
+
{
|
|
2569
|
+
name: "newTab",
|
|
2570
|
+
type: "checkbox",
|
|
2571
|
+
label: "Open in New Tab",
|
|
2572
|
+
defaultValue: false
|
|
2573
|
+
}
|
|
2574
|
+
],
|
|
2575
|
+
timestamps: true
|
|
2576
|
+
}
|
|
2577
|
+
};
|
|
2578
|
+
|
|
2579
|
+
const mediaCollection = {
|
|
2580
|
+
slug: "media",
|
|
2581
|
+
label: "Media Library",
|
|
2582
|
+
admin: {
|
|
2583
|
+
useAsTitle: "title",
|
|
2584
|
+
defaultColumns: ["thumbnailUrl", "title", "type", "fileSize", "createdAt"],
|
|
2585
|
+
group: "content"
|
|
2586
|
+
},
|
|
2587
|
+
access: {
|
|
2588
|
+
read: () => true,
|
|
2589
|
+
create: () => true,
|
|
2590
|
+
update: () => true,
|
|
2591
|
+
delete: () => true
|
|
2592
|
+
},
|
|
2593
|
+
fields: [
|
|
2594
|
+
{
|
|
2595
|
+
name: "title",
|
|
2596
|
+
type: "text",
|
|
2597
|
+
label: "Title",
|
|
2598
|
+
required: true,
|
|
2599
|
+
admin: {
|
|
2600
|
+
description: "Descriptive title for the file"
|
|
2601
|
+
}
|
|
2602
|
+
},
|
|
2603
|
+
{
|
|
2604
|
+
name: "filename",
|
|
2605
|
+
type: "text",
|
|
2606
|
+
label: "Filename",
|
|
2607
|
+
required: true,
|
|
2608
|
+
admin: {
|
|
2609
|
+
readOnly: true,
|
|
2610
|
+
description: "System filename (auto-generated)"
|
|
2611
|
+
}
|
|
2612
|
+
},
|
|
2613
|
+
{
|
|
2614
|
+
name: "originalName",
|
|
2615
|
+
type: "text",
|
|
2616
|
+
label: "Original Name",
|
|
2617
|
+
admin: {
|
|
2618
|
+
readOnly: true
|
|
2619
|
+
}
|
|
2620
|
+
},
|
|
2621
|
+
{
|
|
2622
|
+
name: "alt",
|
|
2623
|
+
type: "text",
|
|
2624
|
+
label: "Alt Text",
|
|
2625
|
+
admin: {
|
|
2626
|
+
description: "Alternative text for images (accessibility)"
|
|
2627
|
+
}
|
|
2628
|
+
},
|
|
2629
|
+
{
|
|
2630
|
+
name: "caption",
|
|
2631
|
+
type: "textarea",
|
|
2632
|
+
label: "Caption",
|
|
2633
|
+
admin: {
|
|
2634
|
+
description: "Optional caption or description"
|
|
2635
|
+
}
|
|
2636
|
+
},
|
|
2637
|
+
{
|
|
2638
|
+
name: "type",
|
|
2639
|
+
type: "select",
|
|
2640
|
+
label: "File Type",
|
|
2641
|
+
required: true,
|
|
2642
|
+
defaultValue: "document",
|
|
2643
|
+
options: [
|
|
2644
|
+
{ label: "Image", value: "image" },
|
|
2645
|
+
{ label: "Video", value: "video" },
|
|
2646
|
+
{ label: "Audio", value: "audio" },
|
|
2647
|
+
{ label: "Document", value: "document" },
|
|
2648
|
+
{ label: "Archive", value: "archive" },
|
|
2649
|
+
{ label: "Other", value: "other" }
|
|
2650
|
+
],
|
|
2651
|
+
admin: {
|
|
2652
|
+
readOnly: true
|
|
2653
|
+
}
|
|
2654
|
+
},
|
|
2655
|
+
{
|
|
2656
|
+
name: "mimeType",
|
|
2657
|
+
type: "text",
|
|
2658
|
+
label: "MIME Type",
|
|
2659
|
+
admin: {
|
|
2660
|
+
readOnly: true
|
|
2661
|
+
}
|
|
2662
|
+
},
|
|
2663
|
+
{
|
|
2664
|
+
name: "url",
|
|
2665
|
+
type: "text",
|
|
2666
|
+
label: "URL",
|
|
2667
|
+
required: true,
|
|
2668
|
+
admin: {
|
|
2669
|
+
readOnly: true,
|
|
2670
|
+
description: "Public URL of the file"
|
|
2671
|
+
}
|
|
2672
|
+
},
|
|
2673
|
+
{
|
|
2674
|
+
name: "thumbnailUrl",
|
|
2675
|
+
type: "text",
|
|
2676
|
+
label: "Thumbnail URL",
|
|
2677
|
+
admin: {
|
|
2678
|
+
readOnly: true
|
|
2679
|
+
}
|
|
2680
|
+
},
|
|
2681
|
+
{
|
|
2682
|
+
name: "width",
|
|
2683
|
+
type: "number",
|
|
2684
|
+
label: "Width",
|
|
2685
|
+
admin: {
|
|
2686
|
+
readOnly: true,
|
|
2687
|
+
description: "Width in pixels (for images/videos)"
|
|
2688
|
+
}
|
|
2689
|
+
},
|
|
2690
|
+
{
|
|
2691
|
+
name: "height",
|
|
2692
|
+
type: "number",
|
|
2693
|
+
label: "Height",
|
|
2694
|
+
admin: {
|
|
2695
|
+
readOnly: true,
|
|
2696
|
+
description: "Height in pixels (for images/videos)"
|
|
2697
|
+
}
|
|
2698
|
+
},
|
|
2699
|
+
{
|
|
2700
|
+
name: "fileSize",
|
|
2701
|
+
type: "number",
|
|
2702
|
+
label: "File Size",
|
|
2703
|
+
admin: {
|
|
2704
|
+
readOnly: true,
|
|
2705
|
+
description: "Size in bytes"
|
|
2706
|
+
}
|
|
2707
|
+
},
|
|
2708
|
+
{
|
|
2709
|
+
name: "folder",
|
|
2710
|
+
type: "text",
|
|
2711
|
+
label: "Folder",
|
|
2712
|
+
admin: {
|
|
2713
|
+
description: "Folder path for organization (e.g., /products, /blog)"
|
|
2714
|
+
}
|
|
2715
|
+
},
|
|
2716
|
+
{
|
|
2717
|
+
name: "tags",
|
|
2718
|
+
type: "array",
|
|
2719
|
+
label: "Tags",
|
|
2720
|
+
fields: [
|
|
2721
|
+
{
|
|
2722
|
+
name: "tag",
|
|
2723
|
+
type: "text",
|
|
2724
|
+
label: "Tag"
|
|
2725
|
+
}
|
|
2726
|
+
]
|
|
2727
|
+
},
|
|
2728
|
+
{
|
|
2729
|
+
name: "focalPoint",
|
|
2730
|
+
type: "group",
|
|
2731
|
+
label: "Focal Point",
|
|
2732
|
+
admin: {
|
|
2733
|
+
description: "Point of interest for cropping"
|
|
2734
|
+
},
|
|
2735
|
+
fields: [
|
|
2736
|
+
{
|
|
2737
|
+
name: "x",
|
|
2738
|
+
type: "number",
|
|
2739
|
+
label: "X",
|
|
2740
|
+
defaultValue: 50
|
|
2741
|
+
},
|
|
2742
|
+
{
|
|
2743
|
+
name: "y",
|
|
2744
|
+
type: "number",
|
|
2745
|
+
label: "Y",
|
|
2746
|
+
defaultValue: 50
|
|
2747
|
+
}
|
|
2748
|
+
]
|
|
2749
|
+
},
|
|
2750
|
+
{
|
|
2751
|
+
name: "metadata",
|
|
2752
|
+
type: "json",
|
|
2753
|
+
label: "Metadata",
|
|
2754
|
+
admin: {
|
|
2755
|
+
readOnly: true,
|
|
2756
|
+
description: "Additional file metadata (EXIF, etc.)"
|
|
2757
|
+
}
|
|
2758
|
+
},
|
|
2759
|
+
{
|
|
2760
|
+
name: "provider",
|
|
2761
|
+
type: "select",
|
|
2762
|
+
label: "Storage Provider",
|
|
2763
|
+
defaultValue: "local",
|
|
2764
|
+
options: [
|
|
2765
|
+
{ label: "Local", value: "local" },
|
|
2766
|
+
{ label: "AWS S3", value: "s3" },
|
|
2767
|
+
{ label: "Cloudinary", value: "cloudinary" },
|
|
2768
|
+
{ label: "Imgix", value: "imgix" }
|
|
2769
|
+
],
|
|
2770
|
+
admin: {
|
|
2771
|
+
readOnly: true
|
|
2772
|
+
}
|
|
2773
|
+
},
|
|
2774
|
+
{
|
|
2775
|
+
name: "status",
|
|
2776
|
+
type: "select",
|
|
2777
|
+
label: "Status",
|
|
2778
|
+
defaultValue: "active",
|
|
2779
|
+
options: [
|
|
2780
|
+
{ label: "Active", value: "active" },
|
|
2781
|
+
{ label: "Archived", value: "archived" }
|
|
2782
|
+
]
|
|
2783
|
+
},
|
|
2784
|
+
{
|
|
2785
|
+
name: "createdAt",
|
|
2786
|
+
type: "date",
|
|
2787
|
+
label: "Created",
|
|
2788
|
+
admin: {
|
|
2789
|
+
readOnly: true
|
|
2790
|
+
}
|
|
2791
|
+
},
|
|
2792
|
+
{
|
|
2793
|
+
name: "updatedAt",
|
|
2794
|
+
type: "date",
|
|
2795
|
+
label: "Last Modified",
|
|
2796
|
+
admin: {
|
|
2797
|
+
readOnly: true
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
]
|
|
2801
|
+
};
|
|
2802
|
+
const mediaFoldersCollection = {
|
|
2803
|
+
slug: "media-folders",
|
|
2804
|
+
label: "Media Folders",
|
|
2805
|
+
admin: {
|
|
2806
|
+
useAsTitle: "name",
|
|
2807
|
+
hidden: true
|
|
2808
|
+
// Only accessible via API
|
|
2809
|
+
},
|
|
2810
|
+
access: {
|
|
2811
|
+
read: () => true,
|
|
2812
|
+
create: () => true,
|
|
2813
|
+
update: () => true,
|
|
2814
|
+
delete: () => true
|
|
2815
|
+
},
|
|
2816
|
+
fields: [
|
|
2817
|
+
{
|
|
2818
|
+
name: "name",
|
|
2819
|
+
type: "text",
|
|
2820
|
+
label: "Name",
|
|
2821
|
+
required: true
|
|
2822
|
+
},
|
|
2823
|
+
{
|
|
2824
|
+
name: "slug",
|
|
2825
|
+
type: "text",
|
|
2826
|
+
label: "Slug",
|
|
2827
|
+
required: true,
|
|
2828
|
+
admin: {
|
|
2829
|
+
description: "URL-friendly identifier"
|
|
2830
|
+
}
|
|
2831
|
+
},
|
|
2832
|
+
{
|
|
2833
|
+
name: "parent",
|
|
2834
|
+
type: "relationship",
|
|
2835
|
+
label: "Parent Folder",
|
|
2836
|
+
relationTo: "media-folders"
|
|
2837
|
+
},
|
|
2838
|
+
{
|
|
2839
|
+
name: "path",
|
|
2840
|
+
type: "text",
|
|
2841
|
+
label: "Full Path",
|
|
2842
|
+
admin: {
|
|
2843
|
+
readOnly: true,
|
|
2844
|
+
description: "Complete folder path"
|
|
2845
|
+
}
|
|
2846
|
+
},
|
|
2847
|
+
{
|
|
2848
|
+
name: "fileCount",
|
|
2849
|
+
type: "number",
|
|
2850
|
+
label: "File Count",
|
|
2851
|
+
admin: {
|
|
2852
|
+
readOnly: true
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
]
|
|
2856
|
+
};
|
|
2857
|
+
const mediaCollections = [mediaCollection, mediaFoldersCollection];
|
|
2858
|
+
|
|
2859
|
+
const siteSettingsGlobal = {
|
|
2860
|
+
slug: "site-settings",
|
|
2861
|
+
label: "Site Settings",
|
|
2862
|
+
admin: {
|
|
2863
|
+
group: "settings"
|
|
2864
|
+
},
|
|
2865
|
+
access: {
|
|
2866
|
+
read: () => true,
|
|
2867
|
+
update: () => true
|
|
2868
|
+
},
|
|
2869
|
+
fields: [
|
|
2870
|
+
{
|
|
2871
|
+
name: "siteName",
|
|
2872
|
+
type: "text",
|
|
2873
|
+
label: "Site Name",
|
|
2874
|
+
required: true,
|
|
2875
|
+
admin: {}
|
|
2876
|
+
},
|
|
2877
|
+
{
|
|
2878
|
+
name: "siteDescription",
|
|
2879
|
+
type: "textarea",
|
|
2880
|
+
label: "Site Description",
|
|
2881
|
+
admin: {}
|
|
2882
|
+
},
|
|
2883
|
+
{
|
|
2884
|
+
name: "siteLogo",
|
|
2885
|
+
type: "relationship",
|
|
2886
|
+
label: "Site Logo",
|
|
2887
|
+
relationTo: "media",
|
|
2888
|
+
admin: {}
|
|
2889
|
+
},
|
|
2890
|
+
{
|
|
2891
|
+
name: "siteFavicon",
|
|
2892
|
+
type: "relationship",
|
|
2893
|
+
label: "Favicon",
|
|
2894
|
+
relationTo: "media",
|
|
2895
|
+
admin: {}
|
|
2896
|
+
},
|
|
2897
|
+
{
|
|
2898
|
+
name: "siteOgImage",
|
|
2899
|
+
type: "relationship",
|
|
2900
|
+
label: "Default OG Image",
|
|
2901
|
+
relationTo: "media",
|
|
2902
|
+
admin: {}
|
|
2903
|
+
},
|
|
2904
|
+
{
|
|
2905
|
+
name: "siteUrl",
|
|
2906
|
+
type: "text",
|
|
2907
|
+
label: "Site URL",
|
|
2908
|
+
admin: {}
|
|
2909
|
+
},
|
|
2910
|
+
{
|
|
2911
|
+
name: "language",
|
|
2912
|
+
type: "select",
|
|
2913
|
+
label: "Default Language",
|
|
2914
|
+
defaultValue: "en",
|
|
2915
|
+
options: [
|
|
2916
|
+
{ label: "English", value: "en" },
|
|
2917
|
+
{ label: "Spanish", value: "es" },
|
|
2918
|
+
{ label: "French", value: "fr" },
|
|
2919
|
+
{ label: "German", value: "de" },
|
|
2920
|
+
{ label: "Portuguese", value: "pt" },
|
|
2921
|
+
{ label: "Italian", value: "it" },
|
|
2922
|
+
{ label: "Dutch", value: "nl" },
|
|
2923
|
+
{ label: "Russian", value: "ru" },
|
|
2924
|
+
{ label: "Japanese", value: "ja" },
|
|
2925
|
+
{ label: "Chinese", value: "zh" },
|
|
2926
|
+
{ label: "Korean", value: "ko" },
|
|
2927
|
+
{ label: "Arabic", value: "ar" }
|
|
2928
|
+
]
|
|
2929
|
+
},
|
|
2930
|
+
{
|
|
2931
|
+
name: "timezone",
|
|
2932
|
+
type: "text",
|
|
2933
|
+
label: "Timezone",
|
|
2934
|
+
defaultValue: "UTC",
|
|
2935
|
+
admin: {}
|
|
2936
|
+
},
|
|
2937
|
+
{
|
|
2938
|
+
name: "logo",
|
|
2939
|
+
type: "group",
|
|
2940
|
+
label: "Logo Settings",
|
|
2941
|
+
fields: [
|
|
2942
|
+
{
|
|
2943
|
+
name: "width",
|
|
2944
|
+
type: "number",
|
|
2945
|
+
label: "Max Width (px)"
|
|
2946
|
+
},
|
|
2947
|
+
{
|
|
2948
|
+
name: "height",
|
|
2949
|
+
type: "number",
|
|
2950
|
+
label: "Max Height (px)"
|
|
2951
|
+
},
|
|
2952
|
+
{
|
|
2953
|
+
name: "altText",
|
|
2954
|
+
type: "text",
|
|
2955
|
+
label: "Alt Text"
|
|
2956
|
+
}
|
|
2957
|
+
]
|
|
2958
|
+
},
|
|
2959
|
+
{
|
|
2960
|
+
name: "analytics",
|
|
2961
|
+
type: "group",
|
|
2962
|
+
label: "Analytics",
|
|
2963
|
+
fields: [
|
|
2964
|
+
{
|
|
2965
|
+
name: "googleAnalyticsId",
|
|
2966
|
+
type: "text",
|
|
2967
|
+
label: "Google Analytics ID",
|
|
2968
|
+
admin: {}
|
|
2969
|
+
},
|
|
2970
|
+
{
|
|
2971
|
+
name: "googleTagManagerId",
|
|
2972
|
+
type: "text",
|
|
2973
|
+
label: "Google Tag Manager ID",
|
|
2974
|
+
admin: {}
|
|
2975
|
+
},
|
|
2976
|
+
{
|
|
2977
|
+
name: "plausibleDomain",
|
|
2978
|
+
type: "text",
|
|
2979
|
+
label: "Plausible Domain",
|
|
2980
|
+
admin: {}
|
|
2981
|
+
},
|
|
2982
|
+
{
|
|
2983
|
+
name: "mixpanelToken",
|
|
2984
|
+
type: "password",
|
|
2985
|
+
label: "Mixpanel Token"
|
|
2986
|
+
}
|
|
2987
|
+
]
|
|
2988
|
+
}
|
|
2989
|
+
]
|
|
2990
|
+
};
|
|
2991
|
+
|
|
2992
|
+
const seoSettingsGlobal = {
|
|
2993
|
+
slug: "seo-settings",
|
|
2994
|
+
label: "SEO Settings",
|
|
2995
|
+
admin: {
|
|
2996
|
+
group: "settings"
|
|
2997
|
+
},
|
|
2998
|
+
access: {
|
|
2999
|
+
read: () => true,
|
|
3000
|
+
update: () => true
|
|
3001
|
+
},
|
|
3002
|
+
fields: [
|
|
3003
|
+
{
|
|
3004
|
+
name: "defaultTitle",
|
|
3005
|
+
type: "text",
|
|
3006
|
+
label: "Default Title",
|
|
3007
|
+
admin: {}
|
|
3008
|
+
},
|
|
3009
|
+
{
|
|
3010
|
+
name: "defaultDescription",
|
|
3011
|
+
type: "textarea",
|
|
3012
|
+
label: "Default Description",
|
|
3013
|
+
admin: {}
|
|
3014
|
+
},
|
|
3015
|
+
{
|
|
3016
|
+
name: "titleTemplate",
|
|
3017
|
+
type: "text",
|
|
3018
|
+
label: "Title Template",
|
|
3019
|
+
defaultValue: "{{title}} | {{siteName}}",
|
|
3020
|
+
admin: {}
|
|
3021
|
+
},
|
|
3022
|
+
{
|
|
3023
|
+
name: "siteNameInTitle",
|
|
3024
|
+
type: "checkbox",
|
|
3025
|
+
label: "Include Site Name in Title",
|
|
3026
|
+
defaultValue: true
|
|
3027
|
+
},
|
|
3028
|
+
{
|
|
3029
|
+
name: "separator",
|
|
3030
|
+
type: "text",
|
|
3031
|
+
label: "Title Separator",
|
|
3032
|
+
defaultValue: " | ",
|
|
3033
|
+
admin: {}
|
|
3034
|
+
},
|
|
3035
|
+
{
|
|
3036
|
+
name: "meta",
|
|
3037
|
+
type: "group",
|
|
3038
|
+
label: "Meta Settings",
|
|
3039
|
+
fields: [
|
|
3040
|
+
{
|
|
3041
|
+
name: "robots",
|
|
3042
|
+
type: "text",
|
|
3043
|
+
label: "Robots Meta",
|
|
3044
|
+
defaultValue: "index, follow",
|
|
3045
|
+
admin: {}
|
|
3046
|
+
},
|
|
3047
|
+
{
|
|
3048
|
+
name: "canonicalUrl",
|
|
3049
|
+
type: "text",
|
|
3050
|
+
label: "Canonical URL",
|
|
3051
|
+
admin: {}
|
|
3052
|
+
},
|
|
3053
|
+
{
|
|
3054
|
+
name: "ogType",
|
|
3055
|
+
type: "select",
|
|
3056
|
+
label: "Default OG Type",
|
|
3057
|
+
defaultValue: "website",
|
|
3058
|
+
options: [
|
|
3059
|
+
{ label: "Website", value: "website" },
|
|
3060
|
+
{ label: "Article", value: "article" },
|
|
3061
|
+
{ label: "Product", value: "product" },
|
|
3062
|
+
{ label: "Book", value: "book" },
|
|
3063
|
+
{ label: "Music", value: "music" },
|
|
3064
|
+
{ label: "Video", value: "video" }
|
|
3065
|
+
]
|
|
3066
|
+
}
|
|
3067
|
+
]
|
|
3068
|
+
},
|
|
3069
|
+
{
|
|
3070
|
+
name: "sitemap",
|
|
3071
|
+
type: "checkbox",
|
|
3072
|
+
label: "Enable XML Sitemap",
|
|
3073
|
+
defaultValue: true,
|
|
3074
|
+
admin: {}
|
|
3075
|
+
},
|
|
3076
|
+
{
|
|
3077
|
+
name: "sitemapUrls",
|
|
3078
|
+
type: "number",
|
|
3079
|
+
label: "Sitemap URL Limit",
|
|
3080
|
+
defaultValue: 5e4,
|
|
3081
|
+
admin: {}
|
|
3082
|
+
},
|
|
3083
|
+
{
|
|
3084
|
+
name: "robotsTxt",
|
|
3085
|
+
type: "textarea",
|
|
3086
|
+
label: "Custom robots.txt",
|
|
3087
|
+
admin: {}
|
|
3088
|
+
},
|
|
3089
|
+
{
|
|
3090
|
+
name: "social",
|
|
3091
|
+
type: "group",
|
|
3092
|
+
label: "Social Media",
|
|
3093
|
+
fields: [
|
|
3094
|
+
{
|
|
3095
|
+
name: "twitterHandle",
|
|
3096
|
+
type: "text",
|
|
3097
|
+
label: "Twitter/X Handle",
|
|
3098
|
+
admin: {}
|
|
3099
|
+
},
|
|
3100
|
+
{
|
|
3101
|
+
name: "twitterCardType",
|
|
3102
|
+
type: "select",
|
|
3103
|
+
label: "Twitter Card Type",
|
|
3104
|
+
defaultValue: "summary_large_image",
|
|
3105
|
+
options: [
|
|
3106
|
+
{ label: "Summary", value: "summary" },
|
|
3107
|
+
{ label: "Summary with Large Image", value: "summary_large_image" },
|
|
3108
|
+
{ label: "App", value: "app" },
|
|
3109
|
+
{ label: "Player", value: "player" }
|
|
3110
|
+
]
|
|
3111
|
+
},
|
|
3112
|
+
{
|
|
3113
|
+
name: "fbAppId",
|
|
3114
|
+
type: "text",
|
|
3115
|
+
label: "Facebook App ID"
|
|
3116
|
+
}
|
|
3117
|
+
]
|
|
3118
|
+
},
|
|
3119
|
+
{
|
|
3120
|
+
name: "advanced",
|
|
3121
|
+
type: "group",
|
|
3122
|
+
label: "Advanced",
|
|
3123
|
+
fields: [
|
|
3124
|
+
{
|
|
3125
|
+
name: "jsonLdEnabled",
|
|
3126
|
+
type: "checkbox",
|
|
3127
|
+
label: "Enable JSON-LD",
|
|
3128
|
+
defaultValue: true,
|
|
3129
|
+
admin: {}
|
|
3130
|
+
},
|
|
3131
|
+
{
|
|
3132
|
+
name: "breadcrumbsEnabled",
|
|
3133
|
+
type: "checkbox",
|
|
3134
|
+
label: "Enable Breadcrumbs",
|
|
3135
|
+
defaultValue: true
|
|
3136
|
+
},
|
|
3137
|
+
{
|
|
3138
|
+
name: "paginationSize",
|
|
3139
|
+
type: "number",
|
|
3140
|
+
label: "Default Pagination Size",
|
|
3141
|
+
defaultValue: 10
|
|
3142
|
+
}
|
|
3143
|
+
]
|
|
3144
|
+
}
|
|
3145
|
+
]
|
|
3146
|
+
};
|
|
3147
|
+
|
|
3148
|
+
const socialSettingsGlobal = {
|
|
3149
|
+
slug: "social-settings",
|
|
3150
|
+
label: "Social Media",
|
|
3151
|
+
admin: {
|
|
3152
|
+
group: "settings"
|
|
3153
|
+
},
|
|
3154
|
+
access: {
|
|
3155
|
+
read: () => true,
|
|
3156
|
+
update: () => true
|
|
3157
|
+
},
|
|
3158
|
+
fields: [
|
|
3159
|
+
{
|
|
3160
|
+
name: "facebook",
|
|
3161
|
+
type: "text",
|
|
3162
|
+
label: "Facebook",
|
|
3163
|
+
admin: {}
|
|
3164
|
+
},
|
|
3165
|
+
{
|
|
3166
|
+
name: "twitter",
|
|
3167
|
+
type: "text",
|
|
3168
|
+
label: "Twitter/X"
|
|
3169
|
+
},
|
|
3170
|
+
{
|
|
3171
|
+
name: "instagram",
|
|
3172
|
+
type: "text",
|
|
3173
|
+
label: "Instagram"
|
|
3174
|
+
},
|
|
3175
|
+
{
|
|
3176
|
+
name: "linkedin",
|
|
3177
|
+
type: "text",
|
|
3178
|
+
label: "LinkedIn"
|
|
3179
|
+
},
|
|
3180
|
+
{
|
|
3181
|
+
name: "youtube",
|
|
3182
|
+
type: "text",
|
|
3183
|
+
label: "YouTube"
|
|
3184
|
+
},
|
|
3185
|
+
{
|
|
3186
|
+
name: "tiktok",
|
|
3187
|
+
type: "text",
|
|
3188
|
+
label: "TikTok"
|
|
3189
|
+
},
|
|
3190
|
+
{
|
|
3191
|
+
name: "pinterest",
|
|
3192
|
+
type: "text",
|
|
3193
|
+
label: "Pinterest"
|
|
3194
|
+
},
|
|
3195
|
+
{
|
|
3196
|
+
name: "discord",
|
|
3197
|
+
type: "text",
|
|
3198
|
+
label: "Discord"
|
|
3199
|
+
},
|
|
3200
|
+
{
|
|
3201
|
+
name: "twitch",
|
|
3202
|
+
type: "text",
|
|
3203
|
+
label: "Twitch"
|
|
3204
|
+
},
|
|
3205
|
+
{
|
|
3206
|
+
name: "github",
|
|
3207
|
+
type: "text",
|
|
3208
|
+
label: "GitHub"
|
|
3209
|
+
},
|
|
3210
|
+
{
|
|
3211
|
+
name: "mastodon",
|
|
3212
|
+
type: "text",
|
|
3213
|
+
label: "Mastodon"
|
|
3214
|
+
}
|
|
3215
|
+
]
|
|
3216
|
+
};
|
|
3217
|
+
|
|
3218
|
+
const emailSettingsGlobal = {
|
|
3219
|
+
slug: "email-settings",
|
|
3220
|
+
label: "Email Settings",
|
|
3221
|
+
admin: {
|
|
3222
|
+
group: "settings"
|
|
3223
|
+
},
|
|
3224
|
+
access: {
|
|
3225
|
+
read: () => true,
|
|
3226
|
+
update: () => true
|
|
3227
|
+
},
|
|
3228
|
+
fields: [
|
|
3229
|
+
{
|
|
3230
|
+
name: "fromName",
|
|
3231
|
+
type: "text",
|
|
3232
|
+
label: "From Name",
|
|
3233
|
+
admin: {}
|
|
3234
|
+
},
|
|
3235
|
+
{
|
|
3236
|
+
name: "fromEmail",
|
|
3237
|
+
type: "email",
|
|
3238
|
+
label: "From Email",
|
|
3239
|
+
required: true,
|
|
3240
|
+
admin: {}
|
|
3241
|
+
},
|
|
3242
|
+
{
|
|
3243
|
+
name: "replyTo",
|
|
3244
|
+
type: "email",
|
|
3245
|
+
label: "Reply-To Email",
|
|
3246
|
+
admin: {}
|
|
3247
|
+
},
|
|
3248
|
+
{
|
|
3249
|
+
name: "provider",
|
|
3250
|
+
type: "select",
|
|
3251
|
+
label: "Email Provider",
|
|
3252
|
+
defaultValue: "smtp",
|
|
3253
|
+
options: [
|
|
3254
|
+
{ label: "SMTP", value: "smtp" },
|
|
3255
|
+
{ label: "SendGrid", value: "sendgrid" },
|
|
3256
|
+
{ label: "Mailgun", value: "mailgun" },
|
|
3257
|
+
{ label: "AWS SES", value: "ses" },
|
|
3258
|
+
{ label: "Resend", value: "resend" }
|
|
3259
|
+
]
|
|
3260
|
+
},
|
|
3261
|
+
{
|
|
3262
|
+
name: "smtp",
|
|
3263
|
+
type: "group",
|
|
3264
|
+
label: "SMTP Settings",
|
|
3265
|
+
fields: [
|
|
3266
|
+
{
|
|
3267
|
+
name: "host",
|
|
3268
|
+
type: "text",
|
|
3269
|
+
label: "Host",
|
|
3270
|
+
admin: {
|
|
3271
|
+
placeholder: "smtp.example.com"
|
|
3272
|
+
}
|
|
3273
|
+
},
|
|
3274
|
+
{
|
|
3275
|
+
name: "port",
|
|
3276
|
+
type: "number",
|
|
3277
|
+
label: "Port",
|
|
3278
|
+
defaultValue: 587
|
|
3279
|
+
},
|
|
3280
|
+
{
|
|
3281
|
+
name: "secure",
|
|
3282
|
+
type: "checkbox",
|
|
3283
|
+
label: "Use TLS/SSL",
|
|
3284
|
+
defaultValue: true
|
|
3285
|
+
},
|
|
3286
|
+
{
|
|
3287
|
+
name: "username",
|
|
3288
|
+
type: "text",
|
|
3289
|
+
label: "Username"
|
|
3290
|
+
},
|
|
3291
|
+
{
|
|
3292
|
+
name: "password",
|
|
3293
|
+
type: "password",
|
|
3294
|
+
label: "Password"
|
|
3295
|
+
}
|
|
3296
|
+
]
|
|
3297
|
+
},
|
|
3298
|
+
{
|
|
3299
|
+
name: "api",
|
|
3300
|
+
type: "group",
|
|
3301
|
+
label: "API Settings",
|
|
3302
|
+
admin: {},
|
|
3303
|
+
fields: [
|
|
3304
|
+
{
|
|
3305
|
+
name: "apiKey",
|
|
3306
|
+
type: "password",
|
|
3307
|
+
label: "API Key"
|
|
3308
|
+
},
|
|
3309
|
+
{
|
|
3310
|
+
name: "domain",
|
|
3311
|
+
type: "text",
|
|
3312
|
+
label: "Domain",
|
|
3313
|
+
admin: {}
|
|
3314
|
+
},
|
|
3315
|
+
{
|
|
3316
|
+
name: "webhookSecret",
|
|
3317
|
+
type: "password",
|
|
3318
|
+
label: "Webhook Secret",
|
|
3319
|
+
admin: {}
|
|
3320
|
+
}
|
|
3321
|
+
]
|
|
3322
|
+
},
|
|
3323
|
+
{
|
|
3324
|
+
name: "testEmail",
|
|
3325
|
+
type: "email",
|
|
3326
|
+
label: "Test Email",
|
|
3327
|
+
admin: {}
|
|
3328
|
+
}
|
|
3329
|
+
]
|
|
3330
|
+
};
|
|
3331
|
+
|
|
3332
|
+
const storageSettingsGlobal = {
|
|
3333
|
+
slug: "storage-settings",
|
|
3334
|
+
label: "Storage Settings",
|
|
3335
|
+
admin: {
|
|
3336
|
+
group: "settings"
|
|
3337
|
+
},
|
|
3338
|
+
access: {
|
|
3339
|
+
read: () => true,
|
|
3340
|
+
update: () => true
|
|
3341
|
+
},
|
|
3342
|
+
fields: [
|
|
3343
|
+
{
|
|
3344
|
+
name: "provider",
|
|
3345
|
+
type: "select",
|
|
3346
|
+
label: "Storage Provider",
|
|
3347
|
+
defaultValue: "local",
|
|
3348
|
+
options: [
|
|
3349
|
+
{ label: "Local Server", value: "local" },
|
|
3350
|
+
{ label: "AWS S3", value: "s3" },
|
|
3351
|
+
{ label: "Cloudinary", value: "cloudinary" },
|
|
3352
|
+
{ label: "Imgix (Transforms Only)", value: "imgix" }
|
|
3353
|
+
]
|
|
3354
|
+
},
|
|
3355
|
+
{
|
|
3356
|
+
name: "local",
|
|
3357
|
+
type: "group",
|
|
3358
|
+
label: "Local Storage",
|
|
3359
|
+
fields: [
|
|
3360
|
+
{
|
|
3361
|
+
name: "uploadDir",
|
|
3362
|
+
type: "text",
|
|
3363
|
+
label: "Upload Directory",
|
|
3364
|
+
defaultValue: "./public/uploads",
|
|
3365
|
+
admin: {}
|
|
3366
|
+
},
|
|
3367
|
+
{
|
|
3368
|
+
name: "baseUrl",
|
|
3369
|
+
type: "text",
|
|
3370
|
+
label: "Base URL",
|
|
3371
|
+
defaultValue: "/uploads",
|
|
3372
|
+
admin: {}
|
|
3373
|
+
}
|
|
3374
|
+
]
|
|
3375
|
+
},
|
|
3376
|
+
{
|
|
3377
|
+
name: "s3",
|
|
3378
|
+
type: "group",
|
|
3379
|
+
label: "AWS S3 Settings",
|
|
3380
|
+
fields: [
|
|
3381
|
+
{
|
|
3382
|
+
name: "bucket",
|
|
3383
|
+
type: "text",
|
|
3384
|
+
label: "Bucket Name"
|
|
3385
|
+
},
|
|
3386
|
+
{
|
|
3387
|
+
name: "region",
|
|
3388
|
+
type: "text",
|
|
3389
|
+
label: "Region",
|
|
3390
|
+
admin: {
|
|
3391
|
+
placeholder: "us-east-1"
|
|
3392
|
+
}
|
|
3393
|
+
},
|
|
3394
|
+
{
|
|
3395
|
+
name: "accessKeyId",
|
|
3396
|
+
type: "password",
|
|
3397
|
+
label: "Access Key ID"
|
|
3398
|
+
},
|
|
3399
|
+
{
|
|
3400
|
+
name: "secretAccessKey",
|
|
3401
|
+
type: "password",
|
|
3402
|
+
label: "Secret Access Key"
|
|
3403
|
+
},
|
|
3404
|
+
{
|
|
3405
|
+
name: "cdnUrl",
|
|
3406
|
+
type: "text",
|
|
3407
|
+
label: "CDN URL",
|
|
3408
|
+
admin: {}
|
|
3409
|
+
},
|
|
3410
|
+
{
|
|
3411
|
+
name: "prefix",
|
|
3412
|
+
type: "text",
|
|
3413
|
+
label: "Path Prefix",
|
|
3414
|
+
admin: {}
|
|
3415
|
+
}
|
|
3416
|
+
]
|
|
3417
|
+
},
|
|
3418
|
+
{
|
|
3419
|
+
name: "cloudinary",
|
|
3420
|
+
type: "group",
|
|
3421
|
+
label: "Cloudinary Settings",
|
|
3422
|
+
fields: [
|
|
3423
|
+
{
|
|
3424
|
+
name: "cloudName",
|
|
3425
|
+
type: "text",
|
|
3426
|
+
label: "Cloud Name"
|
|
3427
|
+
},
|
|
3428
|
+
{
|
|
3429
|
+
name: "apiKey",
|
|
3430
|
+
type: "password",
|
|
3431
|
+
label: "API Key"
|
|
3432
|
+
},
|
|
3433
|
+
{
|
|
3434
|
+
name: "apiSecret",
|
|
3435
|
+
type: "password",
|
|
3436
|
+
label: "API Secret"
|
|
3437
|
+
},
|
|
3438
|
+
{
|
|
3439
|
+
name: "folder",
|
|
3440
|
+
type: "text",
|
|
3441
|
+
label: "Folder",
|
|
3442
|
+
admin: {}
|
|
3443
|
+
}
|
|
3444
|
+
]
|
|
3445
|
+
},
|
|
3446
|
+
{
|
|
3447
|
+
name: "imgix",
|
|
3448
|
+
type: "group",
|
|
3449
|
+
label: "Imgix Settings",
|
|
3450
|
+
fields: [
|
|
3451
|
+
{
|
|
3452
|
+
name: "domain",
|
|
3453
|
+
type: "text",
|
|
3454
|
+
label: "Imgix Domain",
|
|
3455
|
+
admin: {
|
|
3456
|
+
placeholder: "your-source.imgix.net"
|
|
3457
|
+
}
|
|
3458
|
+
},
|
|
3459
|
+
{
|
|
3460
|
+
name: "signKey",
|
|
3461
|
+
type: "password",
|
|
3462
|
+
label: "Signing Key",
|
|
3463
|
+
admin: {}
|
|
3464
|
+
}
|
|
3465
|
+
]
|
|
3466
|
+
},
|
|
3467
|
+
{
|
|
3468
|
+
name: "limits",
|
|
3469
|
+
type: "group",
|
|
3470
|
+
label: "Upload Limits",
|
|
3471
|
+
fields: [
|
|
3472
|
+
{
|
|
3473
|
+
name: "maxFileSize",
|
|
3474
|
+
type: "number",
|
|
3475
|
+
label: "Max File Size (bytes)",
|
|
3476
|
+
defaultValue: 10485760,
|
|
3477
|
+
admin: {}
|
|
3478
|
+
},
|
|
3479
|
+
{
|
|
3480
|
+
name: "allowedTypes",
|
|
3481
|
+
type: "json",
|
|
3482
|
+
label: "Allowed MIME Types",
|
|
3483
|
+
defaultValue: ["image/*", "video/*", "audio/*", "application/pdf"],
|
|
3484
|
+
admin: {}
|
|
3485
|
+
},
|
|
3486
|
+
{
|
|
3487
|
+
name: "maxFilesPerUpload",
|
|
3488
|
+
type: "number",
|
|
3489
|
+
label: "Max Files per Upload",
|
|
3490
|
+
defaultValue: 10
|
|
3491
|
+
}
|
|
3492
|
+
]
|
|
3493
|
+
}
|
|
3494
|
+
]
|
|
3495
|
+
};
|
|
3496
|
+
|
|
3497
|
+
const accessSettingsGlobal = {
|
|
3498
|
+
slug: "access-settings",
|
|
3499
|
+
label: "Access Control",
|
|
3500
|
+
admin: {
|
|
3501
|
+
group: "settings"
|
|
3502
|
+
},
|
|
3503
|
+
access: {
|
|
3504
|
+
read: () => true,
|
|
3505
|
+
update: () => true
|
|
3506
|
+
},
|
|
3507
|
+
fields: [
|
|
3508
|
+
{
|
|
3509
|
+
name: "enablePublicAccess",
|
|
3510
|
+
type: "checkbox",
|
|
3511
|
+
label: "Enable Public Access",
|
|
3512
|
+
defaultValue: true,
|
|
3513
|
+
admin: {}
|
|
3514
|
+
},
|
|
3515
|
+
{
|
|
3516
|
+
name: "defaultCollectionAccess",
|
|
3517
|
+
type: "select",
|
|
3518
|
+
label: "Default Collection Access",
|
|
3519
|
+
defaultValue: "read",
|
|
3520
|
+
options: [
|
|
3521
|
+
{ label: "Read Only", value: "read" },
|
|
3522
|
+
{ label: "Read & Create", value: "create" },
|
|
3523
|
+
{ label: "Read & Create & Update", value: "update" },
|
|
3524
|
+
{ label: "Full Access", value: "admin" },
|
|
3525
|
+
{ label: "No Access", value: "none" }
|
|
3526
|
+
],
|
|
3527
|
+
admin: {}
|
|
3528
|
+
},
|
|
3529
|
+
{
|
|
3530
|
+
name: "apiAccess",
|
|
3531
|
+
type: "group",
|
|
3532
|
+
label: "API Access",
|
|
3533
|
+
fields: [
|
|
3534
|
+
{
|
|
3535
|
+
name: "restEnabled",
|
|
3536
|
+
type: "checkbox",
|
|
3537
|
+
label: "Enable REST API",
|
|
3538
|
+
defaultValue: true
|
|
3539
|
+
},
|
|
3540
|
+
{
|
|
3541
|
+
name: "graphqlEnabled",
|
|
3542
|
+
type: "checkbox",
|
|
3543
|
+
label: "Enable GraphQL",
|
|
3544
|
+
defaultValue: false
|
|
3545
|
+
},
|
|
3546
|
+
{
|
|
3547
|
+
name: "trpcEnabled",
|
|
3548
|
+
type: "checkbox",
|
|
3549
|
+
label: "Enable tRPC",
|
|
3550
|
+
defaultValue: false
|
|
3551
|
+
},
|
|
3552
|
+
{
|
|
3553
|
+
name: "wsEnabled",
|
|
3554
|
+
type: "checkbox",
|
|
3555
|
+
label: "Enable WebSocket",
|
|
3556
|
+
defaultValue: false
|
|
3557
|
+
},
|
|
3558
|
+
{
|
|
3559
|
+
name: "requireAuth",
|
|
3560
|
+
type: "checkbox",
|
|
3561
|
+
label: "Require Authentication",
|
|
3562
|
+
defaultValue: false,
|
|
3563
|
+
admin: {}
|
|
3564
|
+
},
|
|
3565
|
+
{
|
|
3566
|
+
name: "allowedOrigins",
|
|
3567
|
+
type: "json",
|
|
3568
|
+
label: "CORS Allowed Origins",
|
|
3569
|
+
admin: {}
|
|
3570
|
+
}
|
|
3571
|
+
]
|
|
3572
|
+
},
|
|
3573
|
+
{
|
|
3574
|
+
name: "rateLimiting",
|
|
3575
|
+
type: "group",
|
|
3576
|
+
label: "Rate Limiting",
|
|
3577
|
+
fields: [
|
|
3578
|
+
{
|
|
3579
|
+
name: "enabled",
|
|
3580
|
+
type: "checkbox",
|
|
3581
|
+
label: "Enable Rate Limiting",
|
|
3582
|
+
defaultValue: true
|
|
3583
|
+
},
|
|
3584
|
+
{
|
|
3585
|
+
name: "maxRequests",
|
|
3586
|
+
type: "number",
|
|
3587
|
+
label: "Max Requests",
|
|
3588
|
+
defaultValue: 100,
|
|
3589
|
+
admin: {}
|
|
3590
|
+
},
|
|
3591
|
+
{
|
|
3592
|
+
name: "windowMs",
|
|
3593
|
+
type: "number",
|
|
3594
|
+
label: "Window (ms)",
|
|
3595
|
+
defaultValue: 6e4,
|
|
3596
|
+
admin: {}
|
|
3597
|
+
}
|
|
3598
|
+
]
|
|
3599
|
+
}
|
|
3600
|
+
]
|
|
3601
|
+
};
|
|
3602
|
+
|
|
3603
|
+
const countryOptions = [
|
|
3604
|
+
{ label: "United States", value: "US" },
|
|
3605
|
+
{ label: "United Kingdom", value: "GB" },
|
|
3606
|
+
{ label: "Canada", value: "CA" },
|
|
3607
|
+
{ label: "Australia", value: "AU" },
|
|
3608
|
+
{ label: "Germany", value: "DE" },
|
|
3609
|
+
{ label: "France", value: "FR" },
|
|
3610
|
+
{ label: "Netherlands", value: "NL" },
|
|
3611
|
+
{ label: "Belgium", value: "BE" },
|
|
3612
|
+
{ label: "Spain", value: "ES" },
|
|
3613
|
+
{ label: "Italy", value: "IT" },
|
|
3614
|
+
{ label: "Portugal", value: "PT" },
|
|
3615
|
+
{ label: "Sweden", value: "SE" },
|
|
3616
|
+
{ label: "Norway", value: "NO" },
|
|
3617
|
+
{ label: "Denmark", value: "DK" },
|
|
3618
|
+
{ label: "Finland", value: "FI" },
|
|
3619
|
+
{ label: "Austria", value: "AT" },
|
|
3620
|
+
{ label: "Switzerland", value: "CH" },
|
|
3621
|
+
{ label: "Japan", value: "JP" },
|
|
3622
|
+
{ label: "South Korea", value: "KR" },
|
|
3623
|
+
{ label: "China", value: "CN" },
|
|
3624
|
+
{ label: "India", value: "IN" },
|
|
3625
|
+
{ label: "Brazil", value: "BR" },
|
|
3626
|
+
{ label: "Mexico", value: "MX" }
|
|
3627
|
+
];
|
|
3628
|
+
const storeSettingsGlobal = {
|
|
3629
|
+
slug: "store-settings",
|
|
3630
|
+
label: "Store Settings",
|
|
3631
|
+
admin: {
|
|
3632
|
+
group: "settings"
|
|
3633
|
+
},
|
|
3634
|
+
access: {
|
|
3635
|
+
read: () => true,
|
|
3636
|
+
update: () => true
|
|
3637
|
+
},
|
|
3638
|
+
fields: [
|
|
3639
|
+
{
|
|
3640
|
+
name: "storeName",
|
|
3641
|
+
type: "text",
|
|
3642
|
+
label: "Store Name",
|
|
3643
|
+
required: true
|
|
3644
|
+
},
|
|
3645
|
+
{
|
|
3646
|
+
name: "storeEmail",
|
|
3647
|
+
type: "email",
|
|
3648
|
+
label: "Contact Email",
|
|
3649
|
+
required: true
|
|
3650
|
+
},
|
|
3651
|
+
{
|
|
3652
|
+
name: "storePhone",
|
|
3653
|
+
type: "text",
|
|
3654
|
+
label: "Phone Number"
|
|
3655
|
+
},
|
|
3656
|
+
{
|
|
3657
|
+
name: "address",
|
|
3658
|
+
type: "group",
|
|
3659
|
+
label: "Store Address",
|
|
3660
|
+
fields: [
|
|
3661
|
+
{
|
|
3662
|
+
name: "street",
|
|
3663
|
+
type: "text",
|
|
3664
|
+
label: "Street Address"
|
|
3665
|
+
},
|
|
3666
|
+
{
|
|
3667
|
+
name: "city",
|
|
3668
|
+
type: "text",
|
|
3669
|
+
label: "City"
|
|
3670
|
+
},
|
|
3671
|
+
{
|
|
3672
|
+
name: "state",
|
|
3673
|
+
type: "text",
|
|
3674
|
+
label: "State/Province"
|
|
3675
|
+
},
|
|
3676
|
+
{
|
|
3677
|
+
name: "postalCode",
|
|
3678
|
+
type: "text",
|
|
3679
|
+
label: "Postal Code"
|
|
3680
|
+
},
|
|
3681
|
+
{
|
|
3682
|
+
name: "country",
|
|
3683
|
+
type: "select",
|
|
3684
|
+
label: "Country",
|
|
3685
|
+
options: countryOptions
|
|
3686
|
+
}
|
|
3687
|
+
]
|
|
3688
|
+
},
|
|
3689
|
+
{
|
|
3690
|
+
name: "currency",
|
|
3691
|
+
type: "group",
|
|
3692
|
+
label: "Currency",
|
|
3693
|
+
fields: [
|
|
3694
|
+
{
|
|
3695
|
+
name: "code",
|
|
3696
|
+
type: "select",
|
|
3697
|
+
label: "Currency Code",
|
|
3698
|
+
defaultValue: "USD",
|
|
3699
|
+
options: [
|
|
3700
|
+
{ label: "USD - US Dollar", value: "USD" },
|
|
3701
|
+
{ label: "EUR - Euro", value: "EUR" },
|
|
3702
|
+
{ label: "GBP - British Pound", value: "GBP" },
|
|
3703
|
+
{ label: "CAD - Canadian Dollar", value: "CAD" },
|
|
3704
|
+
{ label: "AUD - Australian Dollar", value: "AUD" },
|
|
3705
|
+
{ label: "JPY - Japanese Yen", value: "JPY" },
|
|
3706
|
+
{ label: "CNY - Chinese Yuan", value: "CNY" },
|
|
3707
|
+
{ label: "INR - Indian Rupee", value: "INR" },
|
|
3708
|
+
{ label: "BRL - Brazilian Real", value: "BRL" },
|
|
3709
|
+
{ label: "MXN - Mexican Peso", value: "MXN" }
|
|
3710
|
+
]
|
|
3711
|
+
},
|
|
3712
|
+
{
|
|
3713
|
+
name: "symbol",
|
|
3714
|
+
type: "text",
|
|
3715
|
+
label: "Symbol",
|
|
3716
|
+
defaultValue: "$"
|
|
3717
|
+
},
|
|
3718
|
+
{
|
|
3719
|
+
name: "position",
|
|
3720
|
+
type: "select",
|
|
3721
|
+
label: "Symbol Position",
|
|
3722
|
+
defaultValue: "before",
|
|
3723
|
+
options: [
|
|
3724
|
+
{ label: "Before amount", value: "before" },
|
|
3725
|
+
{ label: "After amount", value: "after" }
|
|
3726
|
+
]
|
|
3727
|
+
},
|
|
3728
|
+
{
|
|
3729
|
+
name: "decimals",
|
|
3730
|
+
type: "number",
|
|
3731
|
+
label: "Decimal Places",
|
|
3732
|
+
defaultValue: 2
|
|
3733
|
+
}
|
|
3734
|
+
]
|
|
3735
|
+
},
|
|
3736
|
+
{
|
|
3737
|
+
name: "tax",
|
|
3738
|
+
type: "group",
|
|
3739
|
+
label: "Tax Settings",
|
|
3740
|
+
fields: [
|
|
3741
|
+
{
|
|
3742
|
+
name: "enabled",
|
|
3743
|
+
type: "checkbox",
|
|
3744
|
+
label: "Enable Tax",
|
|
3745
|
+
defaultValue: true
|
|
3746
|
+
},
|
|
3747
|
+
{
|
|
3748
|
+
name: "rate",
|
|
3749
|
+
type: "number",
|
|
3750
|
+
label: "Tax Rate (%)",
|
|
3751
|
+
admin: {
|
|
3752
|
+
placeholder: "10"
|
|
3753
|
+
}
|
|
3754
|
+
},
|
|
3755
|
+
{
|
|
3756
|
+
name: "includedInPrice",
|
|
3757
|
+
type: "checkbox",
|
|
3758
|
+
label: "Tax Included in Prices",
|
|
3759
|
+
admin: {}
|
|
3760
|
+
},
|
|
3761
|
+
{
|
|
3762
|
+
name: "taxId",
|
|
3763
|
+
type: "text",
|
|
3764
|
+
label: "Tax ID / VAT Number"
|
|
3765
|
+
}
|
|
3766
|
+
]
|
|
3767
|
+
},
|
|
3768
|
+
{
|
|
3769
|
+
name: "shipping",
|
|
3770
|
+
type: "group",
|
|
3771
|
+
label: "Shipping",
|
|
3772
|
+
fields: [
|
|
3773
|
+
{
|
|
3774
|
+
name: "freeShippingThreshold",
|
|
3775
|
+
type: "number",
|
|
3776
|
+
label: "Free Shipping Minimum",
|
|
3777
|
+
admin: {}
|
|
3778
|
+
},
|
|
3779
|
+
{
|
|
3780
|
+
name: "flatRate",
|
|
3781
|
+
type: "number",
|
|
3782
|
+
label: "Flat Rate Shipping",
|
|
3783
|
+
admin: {}
|
|
3784
|
+
},
|
|
3785
|
+
{
|
|
3786
|
+
name: "enableLocalPickup",
|
|
3787
|
+
type: "checkbox",
|
|
3788
|
+
label: "Enable Local Pickup",
|
|
3789
|
+
defaultValue: true
|
|
3790
|
+
}
|
|
3791
|
+
]
|
|
3792
|
+
},
|
|
3793
|
+
{
|
|
3794
|
+
name: "orders",
|
|
3795
|
+
type: "group",
|
|
3796
|
+
label: "Orders",
|
|
3797
|
+
fields: [
|
|
3798
|
+
{
|
|
3799
|
+
name: "orderNumberPrefix",
|
|
3800
|
+
type: "text",
|
|
3801
|
+
label: "Order Number Prefix",
|
|
3802
|
+
defaultValue: "ORD",
|
|
3803
|
+
admin: {}
|
|
3804
|
+
},
|
|
3805
|
+
{
|
|
3806
|
+
name: "allowGuestCheckout",
|
|
3807
|
+
type: "checkbox",
|
|
3808
|
+
label: "Allow Guest Checkout",
|
|
3809
|
+
defaultValue: true
|
|
3810
|
+
},
|
|
3811
|
+
{
|
|
3812
|
+
name: "requirePhone",
|
|
3813
|
+
type: "checkbox",
|
|
3814
|
+
label: "Require Phone Number",
|
|
3815
|
+
defaultValue: true
|
|
3816
|
+
}
|
|
3817
|
+
]
|
|
3818
|
+
}
|
|
3819
|
+
]
|
|
3820
|
+
};
|
|
3821
|
+
|
|
3822
|
+
const paymentSettingsGlobal = {
|
|
3823
|
+
slug: "payment-settings",
|
|
3824
|
+
label: "Payment Settings",
|
|
3825
|
+
admin: {
|
|
3826
|
+
group: "settings"
|
|
3827
|
+
},
|
|
3828
|
+
access: {
|
|
3829
|
+
read: () => true,
|
|
3830
|
+
update: () => true
|
|
3831
|
+
},
|
|
3832
|
+
fields: [
|
|
3833
|
+
{
|
|
3834
|
+
name: "testMode",
|
|
3835
|
+
type: "checkbox",
|
|
3836
|
+
label: "Test Mode",
|
|
3837
|
+
defaultValue: true,
|
|
3838
|
+
admin: {}
|
|
3839
|
+
},
|
|
3840
|
+
{
|
|
3841
|
+
name: "stripe",
|
|
3842
|
+
type: "group",
|
|
3843
|
+
label: "Stripe",
|
|
3844
|
+
fields: [
|
|
3845
|
+
{
|
|
3846
|
+
name: "enabled",
|
|
3847
|
+
type: "checkbox",
|
|
3848
|
+
label: "Enable Stripe",
|
|
3849
|
+
defaultValue: false
|
|
3850
|
+
},
|
|
3851
|
+
{
|
|
3852
|
+
name: "publishableKey",
|
|
3853
|
+
type: "text",
|
|
3854
|
+
label: "Publishable Key",
|
|
3855
|
+
admin: {
|
|
3856
|
+
placeholder: "pk_live_..."
|
|
3857
|
+
}
|
|
3858
|
+
},
|
|
3859
|
+
{
|
|
3860
|
+
name: "secretKey",
|
|
3861
|
+
type: "password",
|
|
3862
|
+
label: "Secret Key"
|
|
3863
|
+
},
|
|
3864
|
+
{
|
|
3865
|
+
name: "webhookSecret",
|
|
3866
|
+
type: "password",
|
|
3867
|
+
label: "Webhook Secret",
|
|
3868
|
+
admin: {}
|
|
3869
|
+
}
|
|
3870
|
+
]
|
|
3871
|
+
},
|
|
3872
|
+
{
|
|
3873
|
+
name: "paypal",
|
|
3874
|
+
type: "group",
|
|
3875
|
+
label: "PayPal",
|
|
3876
|
+
fields: [
|
|
3877
|
+
{
|
|
3878
|
+
name: "enabled",
|
|
3879
|
+
type: "checkbox",
|
|
3880
|
+
label: "Enable PayPal",
|
|
3881
|
+
defaultValue: false
|
|
3882
|
+
},
|
|
3883
|
+
{
|
|
3884
|
+
name: "clientId",
|
|
3885
|
+
type: "text",
|
|
3886
|
+
label: "Client ID"
|
|
3887
|
+
},
|
|
3888
|
+
{
|
|
3889
|
+
name: "clientSecret",
|
|
3890
|
+
type: "password",
|
|
3891
|
+
label: "Client Secret"
|
|
3892
|
+
},
|
|
3893
|
+
{
|
|
3894
|
+
name: "mode",
|
|
3895
|
+
type: "select",
|
|
3896
|
+
label: "Mode",
|
|
3897
|
+
defaultValue: "sandbox",
|
|
3898
|
+
options: [
|
|
3899
|
+
{ label: "Sandbox (Test)", value: "sandbox" },
|
|
3900
|
+
{ label: "Live", value: "live" }
|
|
3901
|
+
]
|
|
3902
|
+
}
|
|
3903
|
+
]
|
|
3904
|
+
},
|
|
3905
|
+
{
|
|
3906
|
+
name: "square",
|
|
3907
|
+
type: "group",
|
|
3908
|
+
label: "Square",
|
|
3909
|
+
fields: [
|
|
3910
|
+
{
|
|
3911
|
+
name: "enabled",
|
|
3912
|
+
type: "checkbox",
|
|
3913
|
+
label: "Enable Square",
|
|
3914
|
+
defaultValue: false
|
|
3915
|
+
},
|
|
3916
|
+
{
|
|
3917
|
+
name: "applicationId",
|
|
3918
|
+
type: "text",
|
|
3919
|
+
label: "Application ID"
|
|
3920
|
+
},
|
|
3921
|
+
{
|
|
3922
|
+
name: "accessToken",
|
|
3923
|
+
type: "password",
|
|
3924
|
+
label: "Access Token"
|
|
3925
|
+
},
|
|
3926
|
+
{
|
|
3927
|
+
name: "locationId",
|
|
3928
|
+
type: "text",
|
|
3929
|
+
label: "Location ID"
|
|
3930
|
+
}
|
|
3931
|
+
]
|
|
3932
|
+
},
|
|
3933
|
+
{
|
|
3934
|
+
name: "methods",
|
|
3935
|
+
type: "group",
|
|
3936
|
+
label: "Manual Payment Methods",
|
|
3937
|
+
fields: [
|
|
3938
|
+
{
|
|
3939
|
+
name: "cod",
|
|
3940
|
+
type: "checkbox",
|
|
3941
|
+
label: "Cash on Delivery",
|
|
3942
|
+
defaultValue: true
|
|
3943
|
+
},
|
|
3944
|
+
{
|
|
3945
|
+
name: "bankTransfer",
|
|
3946
|
+
type: "checkbox",
|
|
3947
|
+
label: "Bank Transfer",
|
|
3948
|
+
defaultValue: true
|
|
3949
|
+
},
|
|
3950
|
+
{
|
|
3951
|
+
name: "cash",
|
|
3952
|
+
type: "checkbox",
|
|
3953
|
+
label: "Cash (Local Pickup)",
|
|
3954
|
+
defaultValue: true
|
|
3955
|
+
},
|
|
3956
|
+
{
|
|
3957
|
+
name: "check",
|
|
3958
|
+
type: "checkbox",
|
|
3959
|
+
label: "Check",
|
|
3960
|
+
defaultValue: false
|
|
3961
|
+
}
|
|
3962
|
+
]
|
|
3963
|
+
},
|
|
3964
|
+
{
|
|
3965
|
+
name: "bankTransfer",
|
|
3966
|
+
type: "group",
|
|
3967
|
+
label: "Bank Transfer Details",
|
|
3968
|
+
admin: {},
|
|
3969
|
+
fields: [
|
|
3970
|
+
{
|
|
3971
|
+
name: "bankName",
|
|
3972
|
+
type: "text",
|
|
3973
|
+
label: "Bank Name"
|
|
3974
|
+
},
|
|
3975
|
+
{
|
|
3976
|
+
name: "accountName",
|
|
3977
|
+
type: "text",
|
|
3978
|
+
label: "Account Name"
|
|
3979
|
+
},
|
|
3980
|
+
{
|
|
3981
|
+
name: "accountNumber",
|
|
3982
|
+
type: "text",
|
|
3983
|
+
label: "Account Number"
|
|
3984
|
+
},
|
|
3985
|
+
{
|
|
3986
|
+
name: "routingNumber",
|
|
3987
|
+
type: "text",
|
|
3988
|
+
label: "Routing/Sort Code"
|
|
3989
|
+
},
|
|
3990
|
+
{
|
|
3991
|
+
name: "iban",
|
|
3992
|
+
type: "text",
|
|
3993
|
+
label: "IBAN"
|
|
3994
|
+
},
|
|
3995
|
+
{
|
|
3996
|
+
name: "swift",
|
|
3997
|
+
type: "text",
|
|
3998
|
+
label: "SWIFT/BIC"
|
|
3999
|
+
}
|
|
4000
|
+
]
|
|
4001
|
+
}
|
|
4002
|
+
]
|
|
4003
|
+
};
|
|
4004
|
+
|
|
4005
|
+
const allSettingsGlobals = [
|
|
4006
|
+
siteSettingsGlobal,
|
|
4007
|
+
seoSettingsGlobal,
|
|
4008
|
+
socialSettingsGlobal,
|
|
4009
|
+
emailSettingsGlobal,
|
|
4010
|
+
storageSettingsGlobal,
|
|
4011
|
+
accessSettingsGlobal,
|
|
4012
|
+
storeSettingsGlobal,
|
|
4013
|
+
paymentSettingsGlobal
|
|
4014
|
+
];
|
|
4015
|
+
const coreSettingsGlobals = [
|
|
4016
|
+
siteSettingsGlobal,
|
|
4017
|
+
seoSettingsGlobal,
|
|
4018
|
+
socialSettingsGlobal,
|
|
4019
|
+
emailSettingsGlobal,
|
|
4020
|
+
storageSettingsGlobal,
|
|
4021
|
+
accessSettingsGlobal
|
|
4022
|
+
];
|
|
4023
|
+
const ecommerceSettingsGlobals = [
|
|
4024
|
+
storeSettingsGlobal,
|
|
4025
|
+
paymentSettingsGlobal
|
|
4026
|
+
];
|
|
4027
|
+
({
|
|
4028
|
+
[siteSettingsGlobal.slug]: siteSettingsGlobal,
|
|
4029
|
+
[seoSettingsGlobal.slug]: seoSettingsGlobal,
|
|
4030
|
+
[socialSettingsGlobal.slug]: socialSettingsGlobal,
|
|
4031
|
+
[emailSettingsGlobal.slug]: emailSettingsGlobal,
|
|
4032
|
+
[storageSettingsGlobal.slug]: storageSettingsGlobal,
|
|
4033
|
+
[accessSettingsGlobal.slug]: accessSettingsGlobal,
|
|
4034
|
+
[storeSettingsGlobal.slug]: storeSettingsGlobal,
|
|
4035
|
+
[paymentSettingsGlobal.slug]: paymentSettingsGlobal
|
|
4036
|
+
});
|
|
4037
|
+
|
|
4038
|
+
const usersCollection = {
|
|
4039
|
+
slug: "users",
|
|
4040
|
+
label: "Users",
|
|
4041
|
+
singular: "User",
|
|
4042
|
+
type: "singleton",
|
|
4043
|
+
fields: [
|
|
4044
|
+
{ name: "id", type: "text", required: true, readonly: true },
|
|
4045
|
+
{ name: "email", type: "email", required: true },
|
|
4046
|
+
{
|
|
4047
|
+
name: "role",
|
|
4048
|
+
type: "select",
|
|
4049
|
+
options: [
|
|
4050
|
+
"super_admin",
|
|
4051
|
+
"admin",
|
|
4052
|
+
"editor",
|
|
4053
|
+
"author",
|
|
4054
|
+
"customer",
|
|
4055
|
+
"guest"
|
|
4056
|
+
],
|
|
4057
|
+
required: true
|
|
4058
|
+
},
|
|
4059
|
+
{ name: "tenantId", type: "text", label: "Tenant" },
|
|
4060
|
+
{ name: "emailVerified", type: "boolean", label: "Email Verified" },
|
|
4061
|
+
{ name: "locked", type: "boolean" },
|
|
4062
|
+
{
|
|
4063
|
+
name: "lastLogin",
|
|
4064
|
+
type: "datetime",
|
|
4065
|
+
label: "Last Login",
|
|
4066
|
+
readonly: true
|
|
4067
|
+
},
|
|
4068
|
+
{
|
|
4069
|
+
name: "failedLoginAttempts",
|
|
4070
|
+
type: "number",
|
|
4071
|
+
label: "Failed Login Attempts",
|
|
4072
|
+
readonly: true
|
|
4073
|
+
},
|
|
4074
|
+
{ name: "createdAt", type: "datetime", readonly: true },
|
|
4075
|
+
{ name: "updatedAt", type: "datetime", readonly: true }
|
|
4076
|
+
],
|
|
4077
|
+
access: {
|
|
4078
|
+
create: () => true,
|
|
4079
|
+
read: () => true,
|
|
4080
|
+
update: () => true,
|
|
4081
|
+
delete: () => true
|
|
4082
|
+
},
|
|
4083
|
+
list: {
|
|
4084
|
+
columns: [
|
|
4085
|
+
"email",
|
|
4086
|
+
"role",
|
|
4087
|
+
"tenantId",
|
|
4088
|
+
"emailVerified",
|
|
4089
|
+
"locked",
|
|
4090
|
+
"lastLogin"
|
|
4091
|
+
],
|
|
4092
|
+
defaultSort: "createdAt",
|
|
4093
|
+
defaultOrder: "desc"
|
|
4094
|
+
}
|
|
4095
|
+
};
|
|
4096
|
+
const rolesCollection = {
|
|
4097
|
+
slug: "roles",
|
|
4098
|
+
label: "Roles",
|
|
4099
|
+
singular: "Role",
|
|
4100
|
+
type: "collection",
|
|
4101
|
+
fields: [
|
|
4102
|
+
{ name: "name", type: "text", required: true },
|
|
4103
|
+
{ name: "level", type: "number", required: true },
|
|
4104
|
+
{
|
|
4105
|
+
name: "inherits",
|
|
4106
|
+
type: "array",
|
|
4107
|
+
items: { type: "text" },
|
|
4108
|
+
label: "Inherits From"
|
|
4109
|
+
},
|
|
4110
|
+
{ name: "description", type: "textarea" },
|
|
4111
|
+
{
|
|
4112
|
+
name: "permissions",
|
|
4113
|
+
type: "array",
|
|
4114
|
+
items: { type: "text" },
|
|
4115
|
+
label: "Permissions"
|
|
4116
|
+
}
|
|
4117
|
+
],
|
|
4118
|
+
access: {
|
|
4119
|
+
create: () => true,
|
|
4120
|
+
read: () => true,
|
|
4121
|
+
update: () => true,
|
|
4122
|
+
delete: () => true
|
|
4123
|
+
},
|
|
4124
|
+
list: {
|
|
4125
|
+
columns: ["name", "level", "inherits", "description"],
|
|
4126
|
+
defaultSort: "level",
|
|
4127
|
+
defaultOrder: "desc"
|
|
4128
|
+
}
|
|
4129
|
+
};
|
|
4130
|
+
const auditLogsCollection = {
|
|
4131
|
+
slug: "audit_logs",
|
|
4132
|
+
label: "Audit Logs",
|
|
4133
|
+
singular: "Audit Log",
|
|
4134
|
+
type: "collection",
|
|
4135
|
+
fields: [
|
|
4136
|
+
{ name: "id", type: "text", required: true, readonly: true },
|
|
4137
|
+
{ name: "action", type: "text", required: true },
|
|
4138
|
+
{ name: "userId", type: "text", label: "User ID" },
|
|
4139
|
+
{ name: "userEmail", type: "email", label: "User Email" },
|
|
4140
|
+
{ name: "role", type: "text" },
|
|
4141
|
+
{ name: "resource", type: "text", label: "Resource" },
|
|
4142
|
+
{ name: "ipAddress", type: "text", label: "IP Address" },
|
|
4143
|
+
{ name: "userAgent", type: "text", label: "User Agent" },
|
|
4144
|
+
{ name: "success", type: "boolean" },
|
|
4145
|
+
{ name: "error", type: "textarea" },
|
|
4146
|
+
{ name: "metadata", type: "json", label: "Metadata" },
|
|
4147
|
+
{ name: "timestamp", type: "datetime", required: true, readonly: true }
|
|
4148
|
+
],
|
|
4149
|
+
access: {
|
|
4150
|
+
create: () => false,
|
|
4151
|
+
read: () => true,
|
|
4152
|
+
update: () => false,
|
|
4153
|
+
delete: () => false
|
|
4154
|
+
},
|
|
4155
|
+
list: {
|
|
4156
|
+
columns: [
|
|
4157
|
+
"action",
|
|
4158
|
+
"userEmail",
|
|
4159
|
+
"role",
|
|
4160
|
+
"resource",
|
|
4161
|
+
"success",
|
|
4162
|
+
"timestamp"
|
|
4163
|
+
],
|
|
4164
|
+
defaultSort: "timestamp",
|
|
4165
|
+
defaultOrder: "desc"
|
|
4166
|
+
}
|
|
4167
|
+
};
|
|
4168
|
+
const authCollections = {
|
|
4169
|
+
users: usersCollection,
|
|
4170
|
+
roles: rolesCollection,
|
|
4171
|
+
audit_logs: auditLogsCollection
|
|
4172
|
+
};
|
|
4173
|
+
|
|
4174
|
+
function getAdminConfig(template = "blog") {
|
|
4175
|
+
const collections2 = [];
|
|
4176
|
+
const globals2 = [];
|
|
4177
|
+
collections2.push(...Object.values(mediaCollections));
|
|
4178
|
+
collections2.push(...Object.values(authCollections));
|
|
4179
|
+
switch (template) {
|
|
4180
|
+
case "minimal":
|
|
4181
|
+
collections2.push(...Object.values(minimalCollections));
|
|
4182
|
+
globals2.push(...coreSettingsGlobals);
|
|
4183
|
+
break;
|
|
4184
|
+
case "blog":
|
|
4185
|
+
collections2.push(...Object.values(blogCollections));
|
|
4186
|
+
globals2.push(...coreSettingsGlobals);
|
|
4187
|
+
break;
|
|
4188
|
+
case "ecommerce":
|
|
4189
|
+
collections2.push(...Object.values(ecommerceCollections));
|
|
4190
|
+
globals2.push(...coreSettingsGlobals, ...ecommerceSettingsGlobals);
|
|
4191
|
+
break;
|
|
4192
|
+
case "kitchen-sink":
|
|
4193
|
+
collections2.push(
|
|
4194
|
+
...Object.values(minimalCollections),
|
|
4195
|
+
...Object.values(blogCollections),
|
|
4196
|
+
...Object.values(ecommerceCollections),
|
|
4197
|
+
...Object.values(kitchenSinkCollections)
|
|
4198
|
+
);
|
|
4199
|
+
globals2.push(...allSettingsGlobals);
|
|
4200
|
+
break;
|
|
4201
|
+
}
|
|
4202
|
+
const collectionsMap = collections2.reduce(
|
|
4203
|
+
(acc, c) => {
|
|
4204
|
+
if (c.slug) acc[c.slug] = c;
|
|
4205
|
+
return acc;
|
|
4206
|
+
},
|
|
4207
|
+
{}
|
|
4208
|
+
);
|
|
4209
|
+
const globalsMap = globals2.reduce(
|
|
4210
|
+
(acc, g) => {
|
|
4211
|
+
if (g.slug) acc[g.slug] = g;
|
|
4212
|
+
return acc;
|
|
4213
|
+
},
|
|
4214
|
+
{}
|
|
4215
|
+
);
|
|
4216
|
+
return { collections: collectionsMap, globals: globalsMap };
|
|
4217
|
+
}
|
|
4218
|
+
const adminConfig = getAdminConfig("blog");
|
|
4219
|
+
const collections = adminConfig.collections;
|
|
4220
|
+
|
|
4221
|
+
export { collections as c };
|