Skip to content

Commit 28689ca

Browse files
wormistypingshaunwarman
authored andcommitted
feat: admin enterprise page
1 parent c5544cc commit 28689ca

File tree

72 files changed

+2805
-768
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

72 files changed

+2805
-768
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Copyright (c) Forward Email LLC
3+
* SPDX-License-Identifier: BUSL-1.1
4+
*/
5+
6+
const Boom = require('@hapi/boom');
7+
const config = require('#config');
8+
9+
async function enforceEnterprisePlan(ctx, next) {
10+
if (!ctx.isAuthenticated())
11+
throw Boom.unauthorized(ctx.translateError('LOGIN_REQUIRED'));
12+
13+
if (config.isSelfHosted) return next();
14+
15+
// Check if user or domain has enterprise plan
16+
const hasEnterprisePlan =
17+
ctx.state.user.plan === 'enterprise' ||
18+
ctx.state?.domain?.plan === 'enterprise';
19+
20+
if (!hasEnterprisePlan)
21+
throw Boom.paymentRequired(
22+
ctx.translateError(
23+
'ENTERPRISE_PLAN_REQUIRED',
24+
ctx.state.l('/my-account/billing/upgrade?plan=enterprise')
25+
)
26+
);
27+
28+
return next();
29+
}
30+
31+
module.exports = enforceEnterprisePlan;

app/controllers/api/v1/enforce-paid-plan.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ async function enforcePaidPlan(ctx, next) {
1313
// if the user is a member of a team plan and in the admin group, continue
1414
if (
1515
ctx.state?.domain?.group === 'admin' &&
16-
ctx.state?.domain?.plan === 'team'
16+
(ctx.state?.domain?.plan === 'team' ||
17+
ctx.state?.domain?.plan === 'enterprise')
1718
)
1819
return next();
1920

app/controllers/api/v1/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const calendars = require('./calendars');
1010
const contacts = require('./contacts');
1111
const domains = require('./domains');
1212
const enforcePaidPlan = require('./enforce-paid-plan');
13+
const enforceEnterprisePlan = require('./enforce-enterprise-plan');
1314
const folders = require('./folders');
1415
const inquiries = require('./inquiries');
1516
const log = require('./log');
@@ -35,6 +36,7 @@ module.exports = {
3536
contacts,
3637
domains,
3738
enforcePaidPlan,
39+
enforceEnterprisePlan,
3840
folders,
3941
inquiries,
4042
log,

app/controllers/api/v1/port.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ async function port(ctx) {
5151
const domain = await Domains.findOne({
5252
name: ctx.query.domain,
5353
verification_record: verifications[0],
54-
plan: { $in: ['enhanced_protection', 'team'] }
54+
plan: { $in: ['enhanced_protection', 'team', 'enterprise'] }
5555
})
5656
.lean()
5757
.exec();

app/controllers/api/v1/stripe.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ async function processEvent(ctx, event) {
7777
group: 'admin'
7878
}
7979
},
80-
plan: { $in: ['enhanced_protection', 'team'] },
80+
plan: { $in: ['enhanced_protection', 'team', 'enterprise'] },
8181
has_txt_record: true
8282
});
8383

@@ -258,7 +258,7 @@ async function processEvent(ctx, event) {
258258

259259
if (
260260
!isSANB(productToPlan) ||
261-
!['team', 'enhanced_protection'].includes(productToPlan)
261+
!['team', 'enhanced_protection', 'enterprise'].includes(productToPlan)
262262
)
263263
throw new Error('Plan was not valid');
264264

@@ -533,7 +533,7 @@ async function processEvent(ctx, event) {
533533
group: 'admin'
534534
}
535535
},
536-
plan: { $in: ['enhanced_protection', 'team'] },
536+
plan: { $in: ['enhanced_protection', 'team', 'enterprise'] },
537537
has_txt_record: true
538538
});
539539

app/controllers/web/admin/dashboard.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ async function getGrowthChart() {
4545
const docs = await Users.aggregate([
4646
{
4747
$match: {
48-
plan: { $in: ['enhanced_protection', 'team'] },
48+
plan: { $in: ['enhanced_protection', 'team', 'enterprise'] },
4949
created_at: {
5050
$gte: dayjs()
5151
.subtract(1, 'day')
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* Copyright (c) Forward Email LLC
3+
* SPDX-License-Identifier: BUSL-1.1
4+
*/
5+
6+
const Boom = require('@hapi/boom');
7+
const isSANB = require('is-string-and-not-blank');
8+
const paginate = require('koa-ctx-paginate');
9+
const parser = require('mongodb-query-parser');
10+
const _ = require('#helpers/lodash');
11+
12+
const { EnterpriseAccounts } = require('#models');
13+
14+
async function retrieve(ctx) {
15+
// Get enterprise account by ID
16+
const enterpriseAccount = await EnterpriseAccounts.findById(ctx.params.id)
17+
.populate('user', 'email plan created_at has_verified_email is_banned')
18+
.lean()
19+
.exec();
20+
21+
if (!enterpriseAccount) {
22+
throw Boom.notFound(
23+
ctx.translateError('ENTERPRISE_ACCOUNT_DOES_NOT_EXIST')
24+
);
25+
}
26+
27+
ctx.state.enterpriseAccount = enterpriseAccount;
28+
29+
if (ctx.accepts('html')) {
30+
return ctx.render('admin/enterprise/account');
31+
}
32+
33+
ctx.body = {
34+
enterpriseAccount
35+
};
36+
}
37+
38+
async function update(ctx) {
39+
const enterpriseAccount = await EnterpriseAccounts.findById(ctx.params.id);
40+
41+
if (!enterpriseAccount) {
42+
throw Boom.notFound(
43+
ctx.translateError('ENTERPRISE_ACCOUNT_DOES_NOT_EXIST')
44+
);
45+
}
46+
47+
// Update allowed fields for simplified form
48+
const allowedFields = ['company_name', 'company_address', 'is_active'];
49+
50+
for (const field of allowedFields) {
51+
if (ctx.request.body[field] !== undefined) {
52+
if (field === 'is_active') {
53+
// Handle checkbox value - convert to boolean
54+
enterpriseAccount[field] = Boolean(ctx.request.body[field]);
55+
} else {
56+
enterpriseAccount[field] = ctx.request.body[field];
57+
}
58+
}
59+
}
60+
61+
await enterpriseAccount.save();
62+
63+
if (ctx.accepts('html')) {
64+
ctx.flash('custom', {
65+
title: ctx.request.t('Success'),
66+
text: ctx.translate('ENTERPRISE_ACCOUNT_UPDATED'),
67+
type: 'success',
68+
toast: true,
69+
showConfirmButton: false,
70+
timer: 3000,
71+
position: 'top'
72+
});
73+
return ctx.redirect('back');
74+
}
75+
76+
ctx.body = { message: ctx.translate('ENTERPRISE_ACCOUNT_UPDATED') };
77+
}
78+
79+
async function list(ctx) {
80+
let query = {};
81+
82+
// Search functionality
83+
if (ctx.query.q) {
84+
const searchOr = [];
85+
const searchFields = [
86+
'company_name',
87+
'primary_contact.name',
88+
'primary_contact.email'
89+
];
90+
91+
for (const field of searchFields) {
92+
searchOr.push(
93+
{ [field]: { $regex: ctx.query.q, $options: 'i' } },
94+
{ [field]: { $regex: _.escapeRegExp(ctx.query.q), $options: 'i' } }
95+
);
96+
}
97+
98+
if (searchOr.length > 0) {
99+
query.$or = searchOr;
100+
}
101+
}
102+
103+
// MongoDB query parsing
104+
if (isSANB(ctx.query.mongodb_query)) {
105+
try {
106+
const mongoQuery = parser.parseFilter(ctx.query.mongodb_query);
107+
if (!mongoQuery || Object.keys(mongoQuery).length === 0)
108+
throw new Error('Query was not parsed properly');
109+
110+
query =
111+
ctx.query.q && Object.keys(query).length > 0
112+
? { $and: [query, mongoQuery] }
113+
: mongoQuery;
114+
} catch (err) {
115+
ctx.logger.warn(err);
116+
throw Boom.badRequest(err.message);
117+
}
118+
}
119+
120+
const [enterpriseAccounts, itemCount] = await Promise.all([
121+
EnterpriseAccounts.find(query || {})
122+
.populate('user', 'email plan created_at')
123+
.limit(ctx.query.limit)
124+
.skip(ctx.paginate.skip)
125+
.sort(ctx.query.sort || '-created_at')
126+
.lean()
127+
.exec(),
128+
EnterpriseAccounts.countDocuments(query || {})
129+
]);
130+
131+
const pageCount = Math.ceil(itemCount / ctx.query.limit);
132+
133+
if (ctx.accepts('html')) {
134+
return ctx.render('admin/enterprise/accounts', {
135+
enterpriseAccounts,
136+
pageCount,
137+
itemCount,
138+
pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page)
139+
});
140+
}
141+
142+
const table = await ctx.render('admin/enterprise/accounts/_table', {
143+
enterpriseAccounts,
144+
pageCount,
145+
itemCount,
146+
pages: paginate.getArrayPages(ctx)(6, pageCount, ctx.query.page)
147+
});
148+
149+
ctx.body = { table };
150+
}
151+
152+
async function remove(ctx) {
153+
const enterpriseAccount = await EnterpriseAccounts.findById(ctx.params.id);
154+
155+
if (!enterpriseAccount) {
156+
throw Boom.notFound(
157+
ctx.translateError('ENTERPRISE_ACCOUNT_DOES_NOT_EXIST')
158+
);
159+
}
160+
161+
// Soft delete by setting is_active to false
162+
enterpriseAccount.is_active = false;
163+
await enterpriseAccount.save();
164+
165+
const message = ctx.translate('ENTERPRISE_ACCOUNT_REMOVED');
166+
if (ctx.accepts('html')) {
167+
ctx.flash('custom', {
168+
title: ctx.request.t('Success'),
169+
text: message,
170+
type: 'success',
171+
toast: true,
172+
showConfirmButton: false,
173+
timer: 3000,
174+
position: 'top'
175+
});
176+
ctx.redirect('/admin/enterprise/accounts');
177+
} else {
178+
ctx.body = { message };
179+
}
180+
}
181+
182+
module.exports = {
183+
list,
184+
retrieve,
185+
update,
186+
remove
187+
};

0 commit comments

Comments
 (0)