create-efc-app 0.9.2 → 0.9.6
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/dist/index.js +227 -80
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -153,7 +153,7 @@ async function scaffold(opts) {
|
|
|
153
153
|
}
|
|
154
154
|
async function writePackageJson(dest, opts) {
|
|
155
155
|
const deps = {
|
|
156
|
-
"express-file-cluster": "^0.9.
|
|
156
|
+
"express-file-cluster": "^0.9.6"
|
|
157
157
|
};
|
|
158
158
|
if (opts.database === "mongodb") deps["mongoose"] = "^9.7.2";
|
|
159
159
|
if (opts.database === "postgresql") {
|
|
@@ -592,9 +592,9 @@ ${hasUser ? `import { User } from '../../model/User.js';
|
|
|
592
592
|
const match = await verifyPassword(password, admin.password);
|
|
593
593
|
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
|
594
594
|
if (!admin.isActive) return res.status(403).json({ error: 'Account suspended' });
|
|
595
|
-
await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
|
|
596
|
-
await issueRefreshToken(res, Admin, admin.id);
|
|
597
|
-
return res.json({ message: 'Logged in as admin' });
|
|
595
|
+
const token = await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
|
|
596
|
+
const refreshToken = await issueRefreshToken(res, Admin, admin.id);
|
|
597
|
+
return res.json({ message: 'Logged in as admin', ...(token ? { token, refreshToken } : {}) });
|
|
598
598
|
}
|
|
599
599
|
|
|
600
600
|
const user = await ${db.findOne("User", `{ email }`)};
|
|
@@ -602,23 +602,26 @@ ${hasUser ? `import { User } from '../../model/User.js';
|
|
|
602
602
|
const match = await verifyPassword(password, user.password);
|
|
603
603
|
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
|
604
604
|
if (!user.isActive) return res.status(403).json({ error: 'Account suspended' });
|
|
605
|
-
await issueToken(res, { id: user.id, role: user.role, email: user.email });
|
|
606
|
-
await issueRefreshToken(res, User, user.id);
|
|
607
|
-
|
|
605
|
+
const token = await issueToken(res, { id: user.id, role: user.role, email: user.email });
|
|
606
|
+
const refreshToken = await issueRefreshToken(res, User, user.id);
|
|
607
|
+
// bearer: the client stores both tokens itself; http-only: they travel as cookies.
|
|
608
|
+
res.json({ message: 'Logged in', ...(token ? { token, refreshToken } : {}) });` : hasAdmin ? ` const admin = await ${db.findOne("Admin", `{ email }`)};
|
|
608
609
|
if (!admin) return res.status(401).json({ error: 'Invalid credentials' });
|
|
609
610
|
const match = await verifyPassword(password, admin.password);
|
|
610
611
|
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
|
611
612
|
if (!admin.isActive) return res.status(403).json({ error: 'Account suspended' });
|
|
612
|
-
await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
|
|
613
|
-
await issueRefreshToken(res, Admin, admin.id);
|
|
614
|
-
|
|
613
|
+
const token = await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
|
|
614
|
+
const refreshToken = await issueRefreshToken(res, Admin, admin.id);
|
|
615
|
+
// bearer: the client stores both tokens itself; http-only: they travel as cookies.
|
|
616
|
+
res.json({ message: 'Logged in as admin', ...(token ? { token, refreshToken } : {}) });` : ` const user = await ${db.findOne("User", `{ email }`)};
|
|
615
617
|
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
|
616
618
|
const match = await verifyPassword(password, user.password);
|
|
617
619
|
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
|
618
620
|
if (!user.isActive) return res.status(403).json({ error: 'Account suspended' });
|
|
619
|
-
await issueToken(res, { id: user.id, role: user.role, email: user.email });
|
|
620
|
-
await issueRefreshToken(res, User, user.id);
|
|
621
|
-
|
|
621
|
+
const token = await issueToken(res, { id: user.id, role: user.role, email: user.email });
|
|
622
|
+
const refreshToken = await issueRefreshToken(res, User, user.id);
|
|
623
|
+
// bearer: the client stores both tokens itself; http-only: they travel as cookies.
|
|
624
|
+
res.json({ message: 'Logged in', ...(token ? { token, refreshToken } : {}) });`;
|
|
622
625
|
const loginContent = hasModels ? ts ? `import { issueToken, verifyPassword } from 'express-file-cluster/auth';
|
|
623
626
|
import type { Request, Response } from 'express';
|
|
624
627
|
${loginDbImports}${loginMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
|
|
@@ -627,7 +630,7 @@ async function issueRefreshToken(
|
|
|
627
630
|
res: Response,
|
|
628
631
|
model: ${usesTables(opts) ? "{ update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown> }" : "{ update: (id: string, data: Record<string, unknown>) => Promise<unknown> }"},
|
|
629
632
|
id: string,
|
|
630
|
-
): Promise<
|
|
633
|
+
): Promise<string> {
|
|
631
634
|
const refreshToken = crypto.randomBytes(40).toString('hex');
|
|
632
635
|
await ${db.update("model", "id", "{ refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) }")};
|
|
633
636
|
res.cookie('efc_refresh_token', refreshToken, {
|
|
@@ -636,11 +639,15 @@ async function issueRefreshToken(
|
|
|
636
639
|
sameSite: 'strict',
|
|
637
640
|
maxAge: REFRESH_TOKEN_TTL_MS,
|
|
638
641
|
});
|
|
642
|
+
return refreshToken;
|
|
639
643
|
}
|
|
640
644
|
|
|
641
645
|
export const POST = async (req: Request, res: Response) => {
|
|
642
646
|
const { email, password } = req.body;
|
|
643
|
-
|
|
647
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
648
|
+
if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
|
|
649
|
+
return res.status(400).json({ error: 'email and password are required' });
|
|
650
|
+
}
|
|
644
651
|
|
|
645
652
|
${loginBody}
|
|
646
653
|
};
|
|
@@ -656,11 +663,15 @@ async function issueRefreshToken(res, model, id) {
|
|
|
656
663
|
sameSite: 'strict',
|
|
657
664
|
maxAge: REFRESH_TOKEN_TTL_MS,
|
|
658
665
|
});
|
|
666
|
+
return refreshToken;
|
|
659
667
|
}
|
|
660
668
|
|
|
661
669
|
export const POST = async (req, res) => {
|
|
662
670
|
const { email, password } = req.body;
|
|
663
|
-
|
|
671
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
672
|
+
if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
|
|
673
|
+
return res.status(400).json({ error: 'email and password are required' });
|
|
674
|
+
}
|
|
664
675
|
|
|
665
676
|
${loginBody}
|
|
666
677
|
};
|
|
@@ -750,7 +761,8 @@ ${sendsMail(opts) ? "import { enqueue } from 'express-file-cluster/tasks';\n" :
|
|
|
750
761
|
const registerContent = ts ? `import type { Request, Response } from 'express';
|
|
751
762
|
${registerDbImports}${registerMeta}export const POST = async (req: Request, res: Response) => {
|
|
752
763
|
const { name, email, password } = req.body;
|
|
753
|
-
|
|
764
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
765
|
+
if (typeof name !== 'string' || typeof email !== 'string' || typeof password !== 'string' || !name || !email || !password) {
|
|
754
766
|
return res.status(400).json({ error: 'name, email and password are required' });
|
|
755
767
|
}
|
|
756
768
|
const existing = await ${db.findOne("User", `{ email }`)};
|
|
@@ -759,12 +771,13 @@ ${registerDbImports}${registerMeta}export const POST = async (req: Request, res:
|
|
|
759
771
|
const verifyToken = crypto.randomBytes(32).toString('hex');
|
|
760
772
|
const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
|
|
761
773
|
${sendVerifyEmail}
|
|
762
|
-
const { password: _, verifyToken:
|
|
774
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
763
775
|
res.status(201).json({ message: 'Account created successfully', user: safe });
|
|
764
776
|
};
|
|
765
777
|
` : `${registerDbImports}${registerMeta}export const POST = async (req, res) => {
|
|
766
778
|
const { name, email, password } = req.body;
|
|
767
|
-
|
|
779
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
780
|
+
if (typeof name !== 'string' || typeof email !== 'string' || typeof password !== 'string' || !name || !email || !password) {
|
|
768
781
|
return res.status(400).json({ error: 'name, email and password are required' });
|
|
769
782
|
}
|
|
770
783
|
const existing = await ${db.findOne("User", `{ email }`)};
|
|
@@ -773,7 +786,7 @@ ${sendVerifyEmail}
|
|
|
773
786
|
const verifyToken = crypto.randomBytes(32).toString('hex');
|
|
774
787
|
const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
|
|
775
788
|
${sendVerifyEmail}
|
|
776
|
-
const { password: _, verifyToken:
|
|
789
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
777
790
|
res.status(201).json({ message: 'Account created successfully', user: safe });
|
|
778
791
|
};
|
|
779
792
|
`;
|
|
@@ -925,18 +938,21 @@ export const GET = async (req: Request, res: Response) => {
|
|
|
925
938
|
${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
|
|
926
939
|
const limit = Math.min(100, Number(req.query.limit) || 20);
|
|
927
940
|
const [all, total] = await Promise.all([${db.find("User", `{}`)}, ${db.count("User", `{}`)}]);
|
|
928
|
-
const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, ...u }) => u);
|
|
941
|
+
const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...u }) => u);
|
|
929
942
|
res.json({ users, total, page, limit });
|
|
930
943
|
};
|
|
931
944
|
|
|
932
945
|
export const POST = async (req: Request, res: Response) => {
|
|
933
946
|
${roleGuard} const { name, email, password, role } = req.body;
|
|
934
|
-
|
|
947
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
948
|
+
if (typeof name !== 'string' || typeof email !== 'string' || typeof password !== 'string' || !name || !email || !password) {
|
|
949
|
+
return res.status(400).json({ error: 'name, email and password are required' });
|
|
950
|
+
}
|
|
935
951
|
const existing = await ${db.findOne("User", `{ email }`)};
|
|
936
952
|
if (existing) return res.status(409).json({ error: 'Email already in use' });
|
|
937
953
|
const hashed = await hashPassword(password);
|
|
938
954
|
const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
|
|
939
|
-
const { password: _, ...safe } = user;
|
|
955
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
940
956
|
res.status(201).json({ message: 'User created', user: safe });
|
|
941
957
|
};
|
|
942
958
|
` : `import { hashPassword, requireAuth } from 'express-file-cluster/auth';
|
|
@@ -945,18 +961,21 @@ export const GET = async (req, res) => {
|
|
|
945
961
|
${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
|
|
946
962
|
const limit = Math.min(100, Number(req.query.limit) || 20);
|
|
947
963
|
const [all, total] = await Promise.all([${db.find("User", `{}`)}, ${db.count("User", `{}`)}]);
|
|
948
|
-
const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, ...u }) => u);
|
|
964
|
+
const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...u }) => u);
|
|
949
965
|
res.json({ users, total, page, limit });
|
|
950
966
|
};
|
|
951
967
|
|
|
952
968
|
export const POST = async (req, res) => {
|
|
953
969
|
${roleGuard} const { name, email, password, role } = req.body;
|
|
954
|
-
|
|
970
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
971
|
+
if (typeof name !== 'string' || typeof email !== 'string' || typeof password !== 'string' || !name || !email || !password) {
|
|
972
|
+
return res.status(400).json({ error: 'name, email and password are required' });
|
|
973
|
+
}
|
|
955
974
|
const existing = await ${db.findOne("User", `{ email }`)};
|
|
956
975
|
if (existing) return res.status(409).json({ error: 'Email already in use' });
|
|
957
976
|
const hashed = await hashPassword(password);
|
|
958
977
|
const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
|
|
959
|
-
const { password: _, ...safe } = user;
|
|
978
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
960
979
|
res.status(201).json({ message: 'User created', user: safe });
|
|
961
980
|
};
|
|
962
981
|
`;
|
|
@@ -1005,14 +1024,14 @@ export const meta: RouteMeta = {
|
|
|
1005
1024
|
` : "";
|
|
1006
1025
|
const adminUserByIdDbImport = `import { User } from '../../../model/User.js';
|
|
1007
1026
|
`;
|
|
1008
|
-
const userByIdContent = ts ? `import { requireAuth } from 'express-file-cluster/auth';
|
|
1027
|
+
const userByIdContent = ts ? `import { requireAuth, revokeAllSessions } from 'express-file-cluster/auth';
|
|
1009
1028
|
import type { Request, Response } from 'express';
|
|
1010
1029
|
${adminUserByIdDbImport}${userByIdMeta}${middlewares}
|
|
1011
1030
|
export const GET = async (req: Request, res: Response) => {
|
|
1012
1031
|
${roleGuard} const { id } = req.params;
|
|
1013
1032
|
const user = await ${db.findById("User", `id`)};
|
|
1014
1033
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1015
|
-
const { password: _, ...safe } = user;
|
|
1034
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
1016
1035
|
res.json({ user: safe });
|
|
1017
1036
|
};
|
|
1018
1037
|
|
|
@@ -1021,7 +1040,10 @@ ${roleGuard} const { id } = req.params;
|
|
|
1021
1040
|
const { name, email, role, isActive } = req.body;
|
|
1022
1041
|
const updated = await ${db.update("User", `id`, `{ name, email, role, isActive }`)};
|
|
1023
1042
|
if (!updated) return res.status(404).json({ error: 'User not found' });
|
|
1024
|
-
|
|
1043
|
+
// The role lives in the user's JWTs: end them, or a demoted/suspended user keeps access
|
|
1044
|
+
// until they expire. (/auth/refresh also refuses inactive accounts.)
|
|
1045
|
+
if (role !== undefined || isActive === false) await revokeAllSessions(id);
|
|
1046
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
|
|
1025
1047
|
res.json({ message: 'User updated', user: safe });
|
|
1026
1048
|
};
|
|
1027
1049
|
|
|
@@ -1030,15 +1052,16 @@ ${roleGuard} const { id } = req.params;
|
|
|
1030
1052
|
const user = await ${db.findById("User", `id`)};
|
|
1031
1053
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1032
1054
|
await ${db.delete("User", `id`)};
|
|
1055
|
+
await revokeAllSessions(id);
|
|
1033
1056
|
res.json({ message: \`User \${id} deleted\` });
|
|
1034
1057
|
};
|
|
1035
|
-
` : `import { requireAuth } from 'express-file-cluster/auth';
|
|
1058
|
+
` : `import { requireAuth, revokeAllSessions } from 'express-file-cluster/auth';
|
|
1036
1059
|
${adminUserByIdDbImport}${userByIdMeta}${middlewares}
|
|
1037
1060
|
export const GET = async (req, res) => {
|
|
1038
1061
|
${roleGuard} const { id } = req.params;
|
|
1039
1062
|
const user = await ${db.findById("User", `id`)};
|
|
1040
1063
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1041
|
-
const { password: _, ...safe } = user;
|
|
1064
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
1042
1065
|
res.json({ user: safe });
|
|
1043
1066
|
};
|
|
1044
1067
|
|
|
@@ -1047,7 +1070,10 @@ ${roleGuard} const { id } = req.params;
|
|
|
1047
1070
|
const { name, email, role, isActive } = req.body;
|
|
1048
1071
|
const updated = await ${db.update("User", `id`, `{ name, email, role, isActive }`)};
|
|
1049
1072
|
if (!updated) return res.status(404).json({ error: 'User not found' });
|
|
1050
|
-
|
|
1073
|
+
// The role lives in the user's JWTs: end them, or a demoted/suspended user keeps access
|
|
1074
|
+
// until they expire. (/auth/refresh also refuses inactive accounts.)
|
|
1075
|
+
if (role !== undefined || isActive === false) await revokeAllSessions(id);
|
|
1076
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
|
|
1051
1077
|
res.json({ message: 'User updated', user: safe });
|
|
1052
1078
|
};
|
|
1053
1079
|
|
|
@@ -1056,6 +1082,7 @@ ${roleGuard} const { id } = req.params;
|
|
|
1056
1082
|
const user = await ${db.findById("User", `id`)};
|
|
1057
1083
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1058
1084
|
await ${db.delete("User", `id`)};
|
|
1085
|
+
await revokeAllSessions(id);
|
|
1059
1086
|
res.json({ message: \`User \${id} deleted\` });
|
|
1060
1087
|
};
|
|
1061
1088
|
`;
|
|
@@ -1107,7 +1134,7 @@ export const GET = async (req: Request, res: Response) => {
|
|
|
1107
1134
|
const { id } = (req as any).user;
|
|
1108
1135
|
const user = await ${db.findById("User", `id`)};
|
|
1109
1136
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1110
|
-
const { password: _, ...safe } = user;
|
|
1137
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
1111
1138
|
res.json({ user: safe });
|
|
1112
1139
|
};
|
|
1113
1140
|
|
|
@@ -1116,7 +1143,7 @@ export const PUT = async (req: Request, res: Response) => {
|
|
|
1116
1143
|
const { name, email } = req.body;
|
|
1117
1144
|
const updated = await ${db.update("User", `id`, `{ name, email }`)};
|
|
1118
1145
|
if (!updated) return res.status(404).json({ error: 'User not found' });
|
|
1119
|
-
const { password: _, ...safe } = updated;
|
|
1146
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
|
|
1120
1147
|
res.json({ message: 'Profile updated', user: safe });
|
|
1121
1148
|
};
|
|
1122
1149
|
` : `import { requireAuth } from 'express-file-cluster/auth';
|
|
@@ -1125,7 +1152,7 @@ export const GET = async (req, res) => {
|
|
|
1125
1152
|
const { id } = req.user;
|
|
1126
1153
|
const user = await ${db.findById("User", `id`)};
|
|
1127
1154
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1128
|
-
const { password: _, ...safe } = user;
|
|
1155
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
1129
1156
|
res.json({ user: safe });
|
|
1130
1157
|
};
|
|
1131
1158
|
|
|
@@ -1134,7 +1161,7 @@ export const PUT = async (req, res) => {
|
|
|
1134
1161
|
const { name, email } = req.body;
|
|
1135
1162
|
const updated = await ${db.update("User", `id`, `{ name, email }`)};
|
|
1136
1163
|
if (!updated) return res.status(404).json({ error: 'User not found' });
|
|
1137
|
-
const { password: _, ...safe } = updated;
|
|
1164
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
|
|
1138
1165
|
res.json({ message: 'Profile updated', user: safe });
|
|
1139
1166
|
};
|
|
1140
1167
|
`;
|
|
@@ -1432,12 +1459,15 @@ ${refreshModelImports}${refreshMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60
|
|
|
1432
1459
|
export const POST = async (req: Request, res: Response) => {
|
|
1433
1460
|
const token = req.cookies?.['efc_refresh_token'] || req.body?.refreshToken;
|
|
1434
1461
|
if (!token) return res.status(401).json({ error: 'Refresh token required' });
|
|
1462
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1463
|
+
if (typeof token !== 'string') return res.status(400).json({ error: 'Invalid refresh token' });
|
|
1435
1464
|
|
|
1436
1465
|
${refreshLookup}
|
|
1437
1466
|
|
|
1438
1467
|
if (!account || !account.refreshTokenExpiry || new Date(account.refreshTokenExpiry) < new Date()) {
|
|
1439
1468
|
return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
|
1440
1469
|
}
|
|
1470
|
+
if (account.isActive === false) return res.status(403).json({ error: 'Account suspended' });
|
|
1441
1471
|
|
|
1442
1472
|
const newRefreshToken = crypto.randomBytes(40).toString('hex');
|
|
1443
1473
|
const refreshTokenExpiry = new Date(Date.now() + REFRESH_TOKEN_TTL_MS);
|
|
@@ -1450,8 +1480,12 @@ ${refreshPersist}
|
|
|
1450
1480
|
maxAge: REFRESH_TOKEN_TTL_MS,
|
|
1451
1481
|
});
|
|
1452
1482
|
|
|
1453
|
-
await issueToken(res, { id: account.id, role: account.role, email: account.email });
|
|
1454
|
-
|
|
1483
|
+
const accessToken = await issueToken(res, { id: account.id, role: account.role, email: account.email });
|
|
1484
|
+
// bearer: the client stores both tokens itself; http-only: they travel as cookies.
|
|
1485
|
+
res.json({
|
|
1486
|
+
message: 'Token refreshed',
|
|
1487
|
+
...(accessToken ? { token: accessToken, refreshToken: newRefreshToken } : {}),
|
|
1488
|
+
});
|
|
1455
1489
|
};
|
|
1456
1490
|
` : `import { issueToken } from 'express-file-cluster/auth';
|
|
1457
1491
|
import crypto from 'node:crypto';
|
|
@@ -1460,12 +1494,15 @@ ${refreshModelImports}${refreshMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60
|
|
|
1460
1494
|
export const POST = async (req, res) => {
|
|
1461
1495
|
const token = req.cookies?.efc_refresh_token || req.body?.refreshToken;
|
|
1462
1496
|
if (!token) return res.status(401).json({ error: 'Refresh token required' });
|
|
1497
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1498
|
+
if (typeof token !== 'string') return res.status(400).json({ error: 'Invalid refresh token' });
|
|
1463
1499
|
|
|
1464
1500
|
${refreshLookup}
|
|
1465
1501
|
|
|
1466
1502
|
if (!account || !account.refreshTokenExpiry || new Date(account.refreshTokenExpiry) < new Date()) {
|
|
1467
1503
|
return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
|
1468
1504
|
}
|
|
1505
|
+
if (account.isActive === false) return res.status(403).json({ error: 'Account suspended' });
|
|
1469
1506
|
|
|
1470
1507
|
const newRefreshToken = crypto.randomBytes(40).toString('hex');
|
|
1471
1508
|
const refreshTokenExpiry = new Date(Date.now() + REFRESH_TOKEN_TTL_MS);
|
|
@@ -1478,8 +1515,12 @@ ${refreshPersist}
|
|
|
1478
1515
|
maxAge: REFRESH_TOKEN_TTL_MS,
|
|
1479
1516
|
});
|
|
1480
1517
|
|
|
1481
|
-
await issueToken(res, { id: account.id, role: account.role, email: account.email });
|
|
1482
|
-
|
|
1518
|
+
const accessToken = await issueToken(res, { id: account.id, role: account.role, email: account.email });
|
|
1519
|
+
// bearer: the client stores both tokens itself; http-only: they travel as cookies.
|
|
1520
|
+
res.json({
|
|
1521
|
+
message: 'Token refreshed',
|
|
1522
|
+
...(accessToken ? { token: accessToken, refreshToken: newRefreshToken } : {}),
|
|
1523
|
+
});
|
|
1483
1524
|
};
|
|
1484
1525
|
`;
|
|
1485
1526
|
await fs.outputFile(path.join(dest, "src", "api", "auth", `refresh.${ext}`), refreshContent);
|
|
@@ -1513,7 +1554,8 @@ ${veMeta}export const GET = async (req: Request, res: Response) => {
|
|
|
1513
1554
|
|
|
1514
1555
|
export const POST = async (req: Request, res: Response) => {
|
|
1515
1556
|
const { email } = req.body;
|
|
1516
|
-
|
|
1557
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1558
|
+
if (typeof email !== 'string' || !email) return res.status(400).json({ error: 'email is required' });
|
|
1517
1559
|
|
|
1518
1560
|
const user = await ${db.findOne("User", `{ email }`)};
|
|
1519
1561
|
if (user && !user.isVerified) {
|
|
@@ -1538,7 +1580,8 @@ ${veMeta}export const GET = async (req, res) => {
|
|
|
1538
1580
|
|
|
1539
1581
|
export const POST = async (req, res) => {
|
|
1540
1582
|
const { email } = req.body;
|
|
1541
|
-
|
|
1583
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1584
|
+
if (typeof email !== 'string' || !email) return res.status(400).json({ error: 'email is required' });
|
|
1542
1585
|
|
|
1543
1586
|
const user = await ${db.findOne("User", `{ email }`)};
|
|
1544
1587
|
if (user && !user.isVerified) {
|
|
@@ -1573,7 +1616,8 @@ ${mailerTaskImport}${fpModelImports}${fpMeta}const RESET_TOKEN_TTL_MS = 1000 * 6
|
|
|
1573
1616
|
|
|
1574
1617
|
export const POST = async (req: Request, res: Response) => {
|
|
1575
1618
|
const { email } = req.body;
|
|
1576
|
-
|
|
1619
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1620
|
+
if (typeof email !== 'string' || !email) return res.status(400).json({ error: 'email is required' });
|
|
1577
1621
|
|
|
1578
1622
|
${fpLookup}
|
|
1579
1623
|
|
|
@@ -1592,7 +1636,8 @@ ${mailerTaskImport}${fpModelImports}${fpMeta}const RESET_TOKEN_TTL_MS = 1000 * 6
|
|
|
1592
1636
|
|
|
1593
1637
|
export const POST = async (req, res) => {
|
|
1594
1638
|
const { email } = req.body;
|
|
1595
|
-
|
|
1639
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1640
|
+
if (typeof email !== 'string' || !email) return res.status(400).json({ error: 'email is required' });
|
|
1596
1641
|
|
|
1597
1642
|
${fpLookup}
|
|
1598
1643
|
|
|
@@ -1628,7 +1673,10 @@ ${hasAdmin ? `import { Admin } from '../../model/Admin.js';
|
|
|
1628
1673
|
import { hashPassword, revokeAllSessions } from 'express-file-cluster/auth';
|
|
1629
1674
|
${rpModelImports}${rpMeta}export const POST = async (req: Request, res: Response) => {
|
|
1630
1675
|
const { token, password } = req.body;
|
|
1631
|
-
|
|
1676
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1677
|
+
if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
|
|
1678
|
+
return res.status(400).json({ error: 'token and password are required' });
|
|
1679
|
+
}
|
|
1632
1680
|
|
|
1633
1681
|
${rpLookup}
|
|
1634
1682
|
|
|
@@ -1646,7 +1694,10 @@ ${rpPersist}
|
|
|
1646
1694
|
` : `import { hashPassword, revokeAllSessions } from 'express-file-cluster/auth';
|
|
1647
1695
|
${rpModelImports}${rpMeta}export const POST = async (req, res) => {
|
|
1648
1696
|
const { token, password } = req.body;
|
|
1649
|
-
|
|
1697
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1698
|
+
if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
|
|
1699
|
+
return res.status(400).json({ error: 'token and password are required' });
|
|
1700
|
+
}
|
|
1650
1701
|
|
|
1651
1702
|
${rpLookup}
|
|
1652
1703
|
|
|
@@ -2821,14 +2872,14 @@ ${suspendMeta}${mwAdmin4}
|
|
|
2821
2872
|
export const POST = async (req: Request, res: Response) => {
|
|
2822
2873
|
${roleGuard} const { id } = req.params;
|
|
2823
2874
|
const { reason } = req.body;
|
|
2824
|
-
// TODO: set user.isActive = false, log audit event
|
|
2875
|
+
// TODO: set user.isActive = false (then revokeAllSessions(id) so open sessions end), log audit event
|
|
2825
2876
|
res.json({ message: \`User \${id} suspended\`, reason });
|
|
2826
2877
|
};
|
|
2827
2878
|
` : `${RA}${suspendMeta}${mwAdmin4}
|
|
2828
2879
|
export const POST = async (req, res) => {
|
|
2829
2880
|
${roleGuard} const { id } = req.params;
|
|
2830
2881
|
const { reason } = req.body;
|
|
2831
|
-
// TODO: set user.isActive = false, log audit event
|
|
2882
|
+
// TODO: set user.isActive = false (then revokeAllSessions(id) so open sessions end), log audit event
|
|
2832
2883
|
res.json({ message: \`User \${id} suspended\`, reason });
|
|
2833
2884
|
};
|
|
2834
2885
|
`;
|
|
@@ -3664,7 +3715,7 @@ async function writeOAuth(dest, opts) {
|
|
|
3664
3715
|
const T = (s) => ts ? s : "";
|
|
3665
3716
|
const userField = usesTables(opts) && generatedModels(opts).has("User") ? "user" : "userId";
|
|
3666
3717
|
const lib = `import crypto from 'node:crypto';
|
|
3667
|
-
import { HttpError } from 'express-file-cluster';
|
|
3718
|
+
import { HttpError, cache } from 'express-file-cluster';
|
|
3668
3719
|
import { hashPassword, issueToken } from 'express-file-cluster/auth';
|
|
3669
3720
|
${T("import type { Request, Response } from 'express';\n")}import { User } from '../model/User.js';
|
|
3670
3721
|
import { OAuthAccount } from '../model/OAuthAccount.js';
|
|
@@ -3672,11 +3723,14 @@ import { OAuthAccount } from '../model/OAuthAccount.js';
|
|
|
3672
3723
|
// Shared by the OAuth sign-in routes (src/api/auth/<provider>/).
|
|
3673
3724
|
|
|
3674
3725
|
const STATE_TTL_MS = 10 * 60 * 1000; // a sign-in attempt has 10 minutes to come back
|
|
3726
|
+
const CODE_TTL_S = 60; // a bearer app has a minute to exchange its sign-in code
|
|
3675
3727
|
${T(`
|
|
3676
3728
|
export interface OAuthState {
|
|
3677
3729
|
state: string;
|
|
3678
3730
|
nonce: string;
|
|
3679
3731
|
verifier?: string;
|
|
3732
|
+
/** Bearer apps: the S256 challenge of a verifier only the app knows (app \u2194 this server). */
|
|
3733
|
+
appChallenge?: string;
|
|
3680
3734
|
}
|
|
3681
3735
|
|
|
3682
3736
|
export interface OAuthProfile {
|
|
@@ -3743,7 +3797,7 @@ export function takeState(
|
|
|
3743
3797
|
* user with the same (provider-verified) email, else a new user. New users get a random
|
|
3744
3798
|
* password \u2014 they can set a real one with the forgot-password flow.
|
|
3745
3799
|
*/
|
|
3746
|
-
export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuthProfile")})${T(": Promise<
|
|
3800
|
+
export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuthProfile")})${T(": Promise<string | undefined>")} {
|
|
3747
3801
|
const key = \`\${profile.provider}:\${profile.providerId}\`;
|
|
3748
3802
|
const account = await ${db.findOne("OAuthAccount", "{ key }")};
|
|
3749
3803
|
let user = account ? await ${db.findById("User", `account.${userField}`)} : null;
|
|
@@ -3778,12 +3832,66 @@ export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuth
|
|
|
3778
3832
|
}
|
|
3779
3833
|
}
|
|
3780
3834
|
if (!user.isActive) throw new HttpError(403, 'Account suspended');
|
|
3781
|
-
|
|
3835
|
+
// http-only: sets the cookie, returns undefined. bearer: returns the token.
|
|
3836
|
+
return issueToken(res, { id: user.id, role: user.role, email: user.email });
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
/**
|
|
3840
|
+
* Bearer apps start sign-in with \`?code_challenge=<base64url SHA-256 of a random verifier>\`
|
|
3841
|
+
* (PKCE, RFC 7636, between the app and this server). Only the app holding the verifier can
|
|
3842
|
+
* redeem the sign-in code \u2014 another app catching the myapp:// redirect can't.
|
|
3843
|
+
*/
|
|
3844
|
+
export function appChallengeFrom(query${T(": Request['query']")})${T(": string | undefined")} {
|
|
3845
|
+
const challenge = query.code_challenge;
|
|
3846
|
+
if (challenge === undefined) return undefined;
|
|
3847
|
+
if (typeof challenge !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(challenge)) {
|
|
3848
|
+
throw new HttpError(400, 'code_challenge must be the base64url SHA-256 of your code_verifier');
|
|
3849
|
+
}
|
|
3850
|
+
return challenge;
|
|
3782
3851
|
}
|
|
3783
3852
|
|
|
3784
|
-
/**
|
|
3785
|
-
|
|
3786
|
-
|
|
3853
|
+
/**
|
|
3854
|
+
* Where the browser goes once signed in. With authStrategy 'bearer' the JWT never goes in the
|
|
3855
|
+
* URL (history, screenshots, logs): it gets a one-time code, valid for a minute, which the app
|
|
3856
|
+
* that started the sign-in trades for the token at POST /auth/oauth/exchange.
|
|
3857
|
+
*/
|
|
3858
|
+
export async function successRedirect(
|
|
3859
|
+
token${T("?: string")},
|
|
3860
|
+
appChallenge${T("?: string")},
|
|
3861
|
+
)${T(": Promise<string>")} {
|
|
3862
|
+
const to = process.env.OAUTH_SUCCESS_REDIRECT || '/';
|
|
3863
|
+
if (!token) return to;
|
|
3864
|
+
if (!appChallenge) {
|
|
3865
|
+
throw new HttpError(
|
|
3866
|
+
400,
|
|
3867
|
+
'Bearer sign-in needs a code_challenge \u2014 start at /auth/<provider>?code_challenge=\u2026',
|
|
3868
|
+
);
|
|
3869
|
+
}
|
|
3870
|
+
const code = randomToken();
|
|
3871
|
+
await cache.set(\`oauth:code:\${code}\`, { token, appChallenge }, { ttl: CODE_TTL_S });
|
|
3872
|
+
return \`\${to}#code=\${code}\`;
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
/**
|
|
3876
|
+
* The token for a one-time sign-in code, given the verifier behind the challenge the sign-in
|
|
3877
|
+
* started with. Once: a second use (or after a minute) gets 400.
|
|
3878
|
+
*/
|
|
3879
|
+
export async function exchangeCode(code${T(": unknown")}, verifier${T(": unknown")})${T(": Promise<string>")} {
|
|
3880
|
+
if (typeof code !== 'string' || !code) throw new HttpError(400, 'code is required');
|
|
3881
|
+
if (typeof verifier !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) {
|
|
3882
|
+
throw new HttpError(400, 'code_verifier is required (43-128 characters)');
|
|
3883
|
+
}
|
|
3884
|
+
const key = \`oauth:code:\${code}\`;
|
|
3885
|
+
const saved = await cache.get${T("<{ token: string; appChallenge: string }>")}(key);
|
|
3886
|
+
const expected = Buffer.from(saved?.appChallenge ?? '');
|
|
3887
|
+
const actual = Buffer.from(pkceChallenge(verifier));
|
|
3888
|
+
// A wrong verifier leaves the code alone, so it can't be used to burn someone's sign-in.
|
|
3889
|
+
if (!saved || expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
|
|
3890
|
+
throw new HttpError(400, 'Invalid or expired sign-in code');
|
|
3891
|
+
}
|
|
3892
|
+
// del is atomic: of two requests racing with the same code, only one removes it.
|
|
3893
|
+
if ((await cache.del(key)) !== 1) throw new HttpError(400, 'Invalid or expired sign-in code');
|
|
3894
|
+
return saved.token;
|
|
3787
3895
|
}
|
|
3788
3896
|
`;
|
|
3789
3897
|
const reqRes = (r) => ts ? `(${r}: Request, res: Response)` : `(${r}, res)`;
|
|
@@ -3793,14 +3901,16 @@ export function successRedirect()${T(": string")} {
|
|
|
3793
3901
|
};
|
|
3794
3902
|
|
|
3795
3903
|
`;
|
|
3796
|
-
const googleStart = `${typeImport}import { pkceChallenge, randomToken, rememberState } from '../../../lib/oauth.js';
|
|
3904
|
+
const googleStart = `${typeImport}import { appChallengeFrom, pkceChallenge, randomToken, rememberState } from '../../../lib/oauth.js';
|
|
3797
3905
|
${meta("GET", 'Start "Sign in with Google": redirects to Google.')}// Redirects to Google's consent screen; Google sends the browser back to the callback route
|
|
3798
3906
|
// (GOOGLE_REDIRECT_URI \u2014 register that exact URL in the Google Cloud console).
|
|
3799
|
-
|
|
3907
|
+
// Bearer apps add ?code_challenge=\u2026 (see appChallengeFrom in src/lib/oauth).
|
|
3908
|
+
export const GET = async ${reqRes("req")} => {
|
|
3909
|
+
const appChallenge = appChallengeFrom(req.query);
|
|
3800
3910
|
const state = randomToken();
|
|
3801
3911
|
const nonce = randomToken();
|
|
3802
3912
|
const verifier = randomToken();
|
|
3803
|
-
rememberState(res, 'google', { state, nonce, verifier });
|
|
3913
|
+
rememberState(res, 'google', { state, nonce, verifier, ...(appChallenge ? { appChallenge } : {}) });
|
|
3804
3914
|
const params = new URLSearchParams({
|
|
3805
3915
|
client_id: process.env.GOOGLE_CLIENT_ID ?? '',
|
|
3806
3916
|
redirect_uri: process.env.GOOGLE_REDIRECT_URI ?? '',
|
|
@@ -3849,23 +3959,25 @@ export const GET = async ${reqRes("req")} => {
|
|
|
3849
3959
|
});
|
|
3850
3960
|
if (payload.nonce !== saved.nonce) throw new HttpError(400, 'Invalid sign-in response');
|
|
3851
3961
|
|
|
3852
|
-
await signInWithOAuth(res, {
|
|
3962
|
+
const token = await signInWithOAuth(res, {
|
|
3853
3963
|
provider: 'google',
|
|
3854
3964
|
providerId: String(payload.sub),
|
|
3855
3965
|
email: ${ts ? "payload.email as string | undefined" : "payload.email"},
|
|
3856
3966
|
emailVerified: payload.email_verified === true,
|
|
3857
3967
|
name: ${ts ? "payload.name as string | undefined" : "payload.name"},
|
|
3858
3968
|
});
|
|
3859
|
-
res.redirect(successRedirect());
|
|
3969
|
+
res.redirect(await successRedirect(token, saved.appChallenge));
|
|
3860
3970
|
};
|
|
3861
3971
|
`;
|
|
3862
|
-
const appleStart = `${typeImport}import { randomToken, rememberState } from '../../../lib/oauth.js';
|
|
3972
|
+
const appleStart = `${typeImport}import { appChallengeFrom, randomToken, rememberState } from '../../../lib/oauth.js';
|
|
3863
3973
|
${meta("GET", 'Start "Sign in with Apple": redirects to Apple.')}// Redirects to Apple; Apple POSTs the result to the callback route (APPLE_REDIRECT_URI \u2014
|
|
3864
3974
|
// register it on the Services ID; Apple only accepts https URLs on a verified domain).
|
|
3865
|
-
|
|
3975
|
+
// Bearer apps add ?code_challenge=\u2026 (see appChallengeFrom in src/lib/oauth).
|
|
3976
|
+
export const GET = async ${reqRes("req")} => {
|
|
3977
|
+
const appChallenge = appChallengeFrom(req.query);
|
|
3866
3978
|
const state = randomToken();
|
|
3867
3979
|
const nonce = randomToken();
|
|
3868
|
-
rememberState(res, 'apple', { state, nonce }, true);
|
|
3980
|
+
rememberState(res, 'apple', { state, nonce, ...(appChallenge ? { appChallenge } : {}) }, true);
|
|
3869
3981
|
const params = new URLSearchParams({
|
|
3870
3982
|
client_id: process.env.APPLE_CLIENT_ID ?? '',
|
|
3871
3983
|
redirect_uri: process.env.APPLE_REDIRECT_URI ?? '',
|
|
@@ -3934,18 +4046,24 @@ export const POST = async ${reqRes("req")} => {
|
|
|
3934
4046
|
name = undefined;
|
|
3935
4047
|
}
|
|
3936
4048
|
|
|
3937
|
-
await signInWithOAuth(res, {
|
|
4049
|
+
const token = await signInWithOAuth(res, {
|
|
3938
4050
|
provider: 'apple',
|
|
3939
4051
|
providerId: String(payload.sub),
|
|
3940
4052
|
email: ${ts ? "payload.email as string | undefined" : "payload.email"},
|
|
3941
4053
|
emailVerified: payload.email_verified === true || payload.email_verified === 'true',
|
|
3942
4054
|
name,
|
|
3943
4055
|
});
|
|
3944
|
-
res.redirect(303, successRedirect());
|
|
4056
|
+
res.redirect(303, await successRedirect(token, saved.appChallenge));
|
|
4057
|
+
};
|
|
4058
|
+
`;
|
|
4059
|
+
const exchange = `${typeImport}import { exchangeCode } from '../../../lib/oauth.js';
|
|
4060
|
+
${meta("POST", "Bearer apps: trade the one-time code from the sign-in redirect (plus the PKCE code_verifier) for a token.")}export const POST = async ${reqRes("req")} => {
|
|
4061
|
+
res.json({ token: await exchangeCode(req.body?.code, req.body?.code_verifier) });
|
|
3945
4062
|
};
|
|
3946
4063
|
`;
|
|
3947
4064
|
const oauth = opts.oauth ?? {};
|
|
3948
4065
|
await fs.outputFile(path.join(dest, "src", "lib", `oauth.${ext}`), lib);
|
|
4066
|
+
await fs.outputFile(path.join(dest, "src", "api", "auth", "oauth", `exchange.${ext}`), exchange);
|
|
3949
4067
|
if (oauth.google) {
|
|
3950
4068
|
await fs.outputFile(
|
|
3951
4069
|
path.join(dest, "src", "api", "auth", "google", `index.${ext}`),
|
|
@@ -4001,6 +4119,18 @@ var admin = (flag) => (o) => {
|
|
|
4001
4119
|
o.adminPortal = true;
|
|
4002
4120
|
o.adminFeatures = { ...o.adminFeatures, [flag]: true };
|
|
4003
4121
|
};
|
|
4122
|
+
var AUTH_STRATEGY_OPTIONS = [
|
|
4123
|
+
{
|
|
4124
|
+
value: "http-only",
|
|
4125
|
+
label: "http-only cookie",
|
|
4126
|
+
hint: "browsers \u2014 websites and SPAs (recommended)"
|
|
4127
|
+
},
|
|
4128
|
+
{
|
|
4129
|
+
value: "bearer",
|
|
4130
|
+
label: "Bearer token",
|
|
4131
|
+
hint: "mobile/desktop apps, API clients \u2014 keep it in Keychain/Keystore, not localStorage"
|
|
4132
|
+
}
|
|
4133
|
+
];
|
|
4004
4134
|
var ITEMS = [
|
|
4005
4135
|
{
|
|
4006
4136
|
name: "auth",
|
|
@@ -4008,7 +4138,16 @@ var ITEMS = [
|
|
|
4008
4138
|
description: "User & Admin models; register, login, logout and me routes",
|
|
4009
4139
|
group: "auth",
|
|
4010
4140
|
registryDependencies: [],
|
|
4011
|
-
docs: "Tokens are issued per `authStrategy` in efc.config \u2014 'http-only' (cookie) or '
|
|
4141
|
+
docs: "Tokens are issued per `authStrategy` in efc.config \u2014 'http-only' (cookie) or 'bearer' (login returns the token; send it as Authorization: Bearer). Verification emails go through the SendEmail task; `efc add email-client` makes it send real mail.",
|
|
4142
|
+
prompts: [
|
|
4143
|
+
{
|
|
4144
|
+
name: "authStrategy",
|
|
4145
|
+
config: "authStrategy",
|
|
4146
|
+
type: "select",
|
|
4147
|
+
message: "Authentication strategy:",
|
|
4148
|
+
options: AUTH_STRATEGY_OPTIONS
|
|
4149
|
+
}
|
|
4150
|
+
],
|
|
4012
4151
|
enable: (o) => {
|
|
4013
4152
|
o.userPortal = true;
|
|
4014
4153
|
}
|
|
@@ -4555,29 +4694,26 @@ async function main() {
|
|
|
4555
4694
|
hint: item.description
|
|
4556
4695
|
});
|
|
4557
4696
|
}
|
|
4697
|
+
const auth = await p.select({
|
|
4698
|
+
message: "Authentication:",
|
|
4699
|
+
options: [
|
|
4700
|
+
...AUTH_STRATEGY_OPTIONS,
|
|
4701
|
+
{ value: "none", label: "None", hint: "add it later with `efc add auth`" }
|
|
4702
|
+
]
|
|
4703
|
+
});
|
|
4704
|
+
if (p.isCancel(auth)) cancel2();
|
|
4558
4705
|
const picked = await p.groupMultiselect({
|
|
4559
4706
|
message: `Add features now? ${pc.dim("(space to toggle, enter to confirm \u2014 or add any later with `efc add <item>`)")}`,
|
|
4560
4707
|
options: groups,
|
|
4708
|
+
initialValues: auth === "none" ? [] : ["auth"],
|
|
4561
4709
|
required: false
|
|
4562
4710
|
});
|
|
4563
4711
|
if (p.isCancel(picked)) cancel2();
|
|
4564
4712
|
const items = withDependencies(picked);
|
|
4565
|
-
|
|
4566
|
-
if (items.includes("auth")) {
|
|
4567
|
-
const strategy = await p.select({
|
|
4568
|
-
message: "Authentication strategy:",
|
|
4569
|
-
options: [
|
|
4570
|
-
{ value: "http-only", label: "http-only", hint: "secure cookie \u2014 recommended for SSR" },
|
|
4571
|
-
{ value: "localStorage", label: "localStorage", hint: "bearer token \u2014 for SPAs" }
|
|
4572
|
-
]
|
|
4573
|
-
});
|
|
4574
|
-
if (p.isCancel(strategy)) cancel2();
|
|
4575
|
-
authStrategy = strategy;
|
|
4576
|
-
}
|
|
4577
|
-
const answers = {};
|
|
4713
|
+
const answers = auth === "none" ? {} : { authStrategy: auth };
|
|
4578
4714
|
const prompts = itemPrompts(items);
|
|
4579
4715
|
for (const q of prompts) {
|
|
4580
|
-
if (!promptApplies(q, answers)) continue;
|
|
4716
|
+
if (q.name in answers || !promptApplies(q, answers)) continue;
|
|
4581
4717
|
if (q.note) p.note(q.note);
|
|
4582
4718
|
if (q.type === "select") {
|
|
4583
4719
|
const choice = await p.select({
|
|
@@ -4617,7 +4753,7 @@ async function main() {
|
|
|
4617
4753
|
items,
|
|
4618
4754
|
{
|
|
4619
4755
|
projectName,
|
|
4620
|
-
authStrategy,
|
|
4756
|
+
authStrategy: answers["authStrategy"] ?? "http-only",
|
|
4621
4757
|
cluster,
|
|
4622
4758
|
taskBackend,
|
|
4623
4759
|
...answers["provider"] === "custom" && { smtpProvider: "custom" }
|
|
@@ -4652,6 +4788,17 @@ Rotate any of them immediately if it is ever exposed.`,
|
|
|
4652
4788
|
"Credentials"
|
|
4653
4789
|
);
|
|
4654
4790
|
}
|
|
4791
|
+
if (opts.database === "postgresql") {
|
|
4792
|
+
const db = path3.basename(opts.projectName);
|
|
4793
|
+
p.note(
|
|
4794
|
+
`.env points DATABASE_URL at postgresql://localhost:5432/${db}, with no user or password.
|
|
4795
|
+
EFC creates the tables on first use, but not the database or the login:
|
|
4796
|
+
\u2022 Local server: createdb ${db} (add user:password@ to the URL if it asks for one)
|
|
4797
|
+
\u2022 Neon, Supabase, RDS: create the database in the dashboard and paste its full
|
|
4798
|
+
connection string into .env as DATABASE_URL`,
|
|
4799
|
+
"PostgreSQL"
|
|
4800
|
+
);
|
|
4801
|
+
}
|
|
4655
4802
|
p.outro(
|
|
4656
4803
|
pc.green(`
|
|
4657
4804
|
Your project is ready!
|