-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauth.ts
More file actions
240 lines (227 loc) · 11.3 KB
/
Copy pathauth.ts
File metadata and controls
240 lines (227 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
import { after } from "next/server";
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
// import PostgresAdapter from "@auth/pg-adapter";
import pool from "@/lib/db";
import { Routes, UserRole, AuthProvider } from "@/lib/constants";
import { isValidEmailFormat } from "@/lib/auth/trusted-email";
import { verifyOtp } from "@/lib/auth/otp";
import { provisionUserByEmail } from "@/lib/auth/provision";
import { effectivePlan, activeCompPlan, SIGNUP_TRIAL_PLAN, signupTrialExpiry, type PlanId } from "@/lib/plans";
import { sendWelcomeEmail } from "@/lib/email";
import { recordMilestone } from "@/lib/platform-milestones";
import { isLocale, type Locale } from "@/lib/i18n/config";
// Debug configuration
if (!process.env.AUTH_SECRET) {
console.warn("Missing AUTH_SECRET environment variable");
}
if (!process.env.AUTH_GOOGLE_ID || !process.env.AUTH_GOOGLE_SECRET) {
console.warn("Missing Google OAuth environment variables (AUTH_GOOGLE_ID/SECRET)");
}
export const { handlers, auth, signIn, signOut } = NextAuth({
// 仅开发环境开:debug 会把 session/JWT 内容、provider 响应与回调 URL 打进日志
// (Auth.js 的 debug-enabled 警告即为此),生产开着等于把用户敏感信息写进 Vercel 日志。
debug: process.env.NODE_ENV !== "production",
// adapter: PostgresAdapter(pool),
// 免密 OTP 为主,会话保持 30 天并每日滑动续期:活跃用户几乎无需重复收码。
session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60, updateAge: 24 * 60 * 60 },
trustHost: true,
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
// 邮箱验证码(OTP)免密登录/注册:主入口,支持任意邮箱后缀。
Credentials({
id: AuthProvider.EmailOtp,
name: "Email OTP",
credentials: {
email: { label: "Email", type: "email" },
code: { label: "Code", type: "text" },
token: { label: "Invite Token", type: "text" },
},
async authorize(credentials) {
const email = typeof credentials?.email === "string" ? credentials.email : "";
const code = typeof credentials?.code === "string" ? credentials.code : "";
const token = typeof credentials?.token === "string" ? credentials.token : null;
if (!isValidEmailFormat(email) || !/^\d{6}$/.test(code)) return null;
const result = await verifyOtp(email, code);
if (result !== "ok") return null;
// 验证码通过 → find-or-create(新用户可套用邀请 token 权益)。
const user = await provisionUserByEmail(email, { token });
if (user.disabled) return null; // 禁用账号拒绝登录
return { id: user.id, name: user.name, email: user.email, image: user.image };
},
}),
// Dev-only provider: 仅在 NODE_ENV=development 且设置了 DEV_USER_EMAIL 时激活
...(process.env.NODE_ENV === "development" && process.env.DEV_USER_EMAIL
? [
Credentials({
id: "dev",
name: "Dev Login",
credentials: {},
async authorize() {
const email = process.env.DEV_USER_EMAIL!;
// 本地联调需要付费功能,dev 一键登录账号固定为 pro 套餐
const result = await pool.query(
`INSERT INTO users (email, name, plan)
VALUES ($1, 'Dev User', 'pro')
ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email, plan = 'pro'
RETURNING id, email, name, image`,
[email],
);
const user = result.rows[0];
return { id: user.id, name: user.name, email: user.email, image: user.image ?? null };
},
}),
]
: []),
],
pages: {
signIn: Routes.Login,
},
callbacks: {
async signIn({ user, account }) {
console.log("SignIn callback triggered", { email: user?.email, provider: account?.provider });
if (!user?.email) {
console.warn("SignIn failed: No email provided by provider");
return false;
}
try {
// 检查用户是否已经是系统内用户(针对受邀用户)
const existingUser = await pool.query(
"SELECT id, disabled_at FROM users WHERE email = $1",
[user.email]
);
if (existingUser.rows.length > 0) {
if (existingUser.rows[0].disabled_at) {
console.warn("SignIn failed: account disabled", user.email);
return false;
}
user.id = existingUser.rows[0].id;
console.log("SignIn success: Existing user found");
return true; // 已存在用户(包括受邀注册后的)允许登录
}
// 基础格式校验(域名已放开:任意邮箱后缀均可注册)
if (!isValidEmailFormat(user.email)) {
console.warn("SignIn failed: Invalid email format", user.email);
return false;
}
// 新用户:写入 users 表并以本地 ID 覆盖 user.id,确保 JWT/Session 用本地 ID;
// 建号即赠 Pro 试用(comp_plan 机制,到期自动回落)。
const inserted = await pool.query(
"INSERT INTO users (email, name, image, comp_plan, comp_plan_expires_at) VALUES ($1, $2, $3, $4, $5) RETURNING id",
[user.email, user.name ?? null, user.image ?? null, SIGNUP_TRIAL_PLAN, signupTrialExpiry()]
);
user.id = inserted.rows[0].id;
console.log("SignIn success: New trusted user created", user.id);
await recordMilestone(user.id!, "signup");
// 新用户(Google 首次登录建号)发欢迎/onboarding 邮件(best-effort,绝不阻断登录)。
const welcomeTo = user.email;
const welcomeName = user.name ?? null;
try {
after(async () => {
try {
const appUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "";
// 欢迎邮件不传 locale,走默认语言:新用户的 users.locale 恒为 NULL
// (建号路径刻意不写这一列,见 docs/feat_20260805_admin端国际化/design.md),
// 而这里在 after() 里跑,也读不到请求的 zb_locale cookie。
// 要按注册来源发欢迎邮件,得先把 locale 写进建号事务——那是另一件事。
await sendWelcomeEmail({ to: welcomeTo, name: welcomeName, appUrl });
} catch (err) {
console.error("welcome email (oauth) failed:", err);
}
});
} catch (err) {
console.error("welcome email (oauth) schedule failed:", err);
}
return true;
} catch (error) {
console.error("SignIn callback error:", error);
return false;
}
},
async jwt({ token, user }) {
try {
const userId = user?.id ?? token.sub;
if (userId) {
const r = await pool.query(
"SELECT email, plan, comp_plan, comp_plan_expires_at, role, trial_expires_at, disabled_at, billing_expires_at, billing_subscription_id, locale FROM users WHERE id = $1",
[userId]
);
const userData = r.rows[0];
if (userData) {
if (userData.disabled_at) {
// 已禁用:清空会话权益与角色;API 侧由 getUserPlanOrNull → session_stale 兜底
token.plan = "free" as PlanId;
token.paidPlan = "free" as PlanId;
token.compPlan = null;
token.compPlanExpiresAt = null;
token.role = UserRole.USER;
return token;
}
const adminEmails = (process.env.ADMIN_EMAILS || "").split(",").map(e => e.trim());
const isHardwareAdmin = adminEmails.includes(userData.email);
// 处理试用期过期
let currentPlan = userData.plan ?? "free";
if (userData.trial_expires_at && new Date(userData.trial_expires_at) < new Date()) {
if (currentPlan !== "free" && !isHardwareAdmin) {
await pool.query(
"UPDATE users SET plan = 'free', trial_expires_at = NULL WHERE id = $1",
[userId]
);
currentPlan = "free";
}
}
// 物理同步:如果环境变量里有,但数据库里还没改,则执行更新
if (isHardwareAdmin && userData.role !== UserRole.SUPER_ADMIN) {
await pool.query(
"UPDATE users SET role = $1, plan = $2 WHERE id = $3",
[UserRole.SUPER_ADMIN, "agency", userId]
);
currentPlan = "agency";
}
const comp = activeCompPlan(userData.comp_plan ?? null, userData.comp_plan_expires_at, new Date());
token.plan = effectivePlan(currentPlan as PlanId, comp);
// 付费订阅档与赠送档分开下发:billing 页换档基准必须是付费档,
// 否则赠送档更高时会把赠送档误当成「当前订阅档位」展示。
token.paidPlan = currentPlan as PlanId;
token.compPlan = comp;
token.compPlanExpiresAt = comp && userData.comp_plan_expires_at
? new Date(userData.comp_plan_expires_at).toISOString()
: null;
// 周期末取消的到期时间(无取消时为 null),billing 页展示「权益保留至 X」用。
token.billingExpiresAt = userData.billing_expires_at
? new Date(userData.billing_expires_at).toISOString()
: null;
// 是否持有渠道真实订阅:赠送套餐(comp_plan)无 subscription_id,据此在 billing 页
// 隐藏「更换套餐」自助换档区(换档会 404,对赠送用户无意义)。
token.hasSubscription = Boolean(userData.billing_subscription_id);
token.role = (isHardwareAdmin ? UserRole.SUPER_ADMIN : (userData.role ?? UserRole.USER)) as UserRole;
// 后台界面语言。脏值按「没选过」处理,交由 resolveAdminLocale 回退到注册来源,
// 而不是让一个人工改库写错的值把整个后台钉死在某个语言上。
token.locale = isLocale(userData.locale ?? "") ? (userData.locale as Locale) : null;
}
}
return token;
} catch (error) {
console.error("JWT callback error:", error);
return token;
}
},
session({ session, token }) {
session.user.id = token.sub!;
session.user.plan = token.plan as PlanId;
// 旧 token 无 paidPlan 时回退生效档(jwt 回调每请求重算,仅极端兜底)。
session.user.paidPlan = (token.paidPlan as PlanId | undefined) ?? (token.plan as PlanId);
session.user.compPlan = (token.compPlan as PlanId | null | undefined) ?? null;
session.user.compPlanExpiresAt = (token.compPlanExpiresAt as string | null | undefined) ?? null;
session.user.role = token.role as UserRole;
session.user.billingExpiresAt = (token.billingExpiresAt as string | null) ?? null;
session.user.hasSubscription = Boolean(token.hasSubscription);
session.user.locale = (token.locale as Locale | null | undefined) ?? null;
return session;
},
},
});