收紧钉钉登录一次性票据安全
This commit is contained in:
parent
f90c74fe6e
commit
b596c5d08b
94
app/api/dingtalk/complete-profile/__tests__/route.test.ts
Normal file
94
app/api/dingtalk/complete-profile/__tests__/route.test.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { POST } from '../route'
|
||||||
|
|
||||||
|
jest.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
user: {
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
dingtalkLoginToken: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
$transaction: jest.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { prisma } = require('@/lib/prisma')
|
||||||
|
|
||||||
|
describe('POST /api/dingtalk/complete-profile', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不再接受裸 userId 完善资料', async () => {
|
||||||
|
const response = await POST(
|
||||||
|
new NextRequest('http://localhost/api/dingtalk/complete-profile', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userId: 'user-1', department: '设计部' }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
expect(data.success).toBe(false)
|
||||||
|
expect(prisma.user.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('凭有效 profileToken 更新部门,并为已激活用户签发登录票据', async () => {
|
||||||
|
const mockTx = {
|
||||||
|
dingtalkLoginToken: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: 'profile-token-row',
|
||||||
|
purpose: 'complete_profile',
|
||||||
|
usedAt: null,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user@example.com',
|
||||||
|
username: '张三',
|
||||||
|
role: 'USER',
|
||||||
|
isSuperAdmin: false,
|
||||||
|
avatar: null,
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prisma.$transaction.mockImplementation((callback: any) => callback(mockTx))
|
||||||
|
prisma.user.update.mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user@example.com',
|
||||||
|
username: '张三',
|
||||||
|
realName: '张三',
|
||||||
|
avatar: null,
|
||||||
|
role: 'USER',
|
||||||
|
department: '设计部',
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
prisma.dingtalkLoginToken.create.mockResolvedValue({ id: 'login-token-row' })
|
||||||
|
|
||||||
|
const response = await POST(
|
||||||
|
new NextRequest('http://localhost/api/dingtalk/complete-profile', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ profileToken: 'profile-token', department: '设计部' }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data.loginToken).toEqual(expect.any(String))
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'user-1' },
|
||||||
|
data: { department: '设计部' },
|
||||||
|
})
|
||||||
|
expect(prisma.dingtalkLoginToken.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
userId: 'user-1',
|
||||||
|
purpose: 'login',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,45 +1,51 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import {
|
||||||
|
consumeDingtalkLoginToken,
|
||||||
|
createDingtalkLoginToken,
|
||||||
|
} from '@/lib/dingtalk-login-token'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { userId, department } = await request.json()
|
const { profileToken, department } = await request.json()
|
||||||
|
|
||||||
if (!userId || !department) {
|
if (!profileToken || !department) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, message: '缺少必要参数' },
|
{ success: false, message: '缺少必要参数' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('完善用户信息(选择部门):', { userId, department })
|
console.log('完善钉钉用户信息(选择部门)')
|
||||||
|
|
||||||
// 获取当前钉钉用户信息
|
const currentUser = await consumeDingtalkLoginToken(profileToken, 'complete_profile')
|
||||||
const currentUser = await prisma.user.findUnique({
|
|
||||||
where: { id: userId }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, message: '用户不存在' },
|
{ success: false, message: '登录凭证无效或已过期,请重新登录' },
|
||||||
{ status: 404 }
|
{ status: 401 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接更新部门信息(用户名已在登录时设置)
|
// 直接更新部门信息(用户名已在登录时设置)
|
||||||
const user = await prisma.user.update({
|
const user = await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: currentUser.id },
|
||||||
data: {
|
data: {
|
||||||
department,
|
department,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('用户信息更新成功:', user.username)
|
console.log('用户信息更新成功:', user.username)
|
||||||
|
|
||||||
|
const loginTokenResult = user.isActive
|
||||||
|
? await createDingtalkLoginToken(user.id, 'login')
|
||||||
|
: null
|
||||||
|
|
||||||
// 返回用户信息(不包含敏感信息)
|
// 返回用户信息(不包含敏感信息)
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '信息完善成功',
|
message: '信息完善成功',
|
||||||
|
loginToken: loginTokenResult?.token,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
|||||||
46
app/api/dingtalk/config/__tests__/route.test.ts
Normal file
46
app/api/dingtalk/config/__tests__/route.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { GET } from '../route'
|
||||||
|
|
||||||
|
describe('GET /api/dingtalk/config', () => {
|
||||||
|
const originalEnv = process.env
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules()
|
||||||
|
process.env = { ...originalEnv }
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env = originalEnv
|
||||||
|
})
|
||||||
|
|
||||||
|
it('缺少服务端 clientId/clientSecret 时不暴露默认钉钉配置', async () => {
|
||||||
|
delete process.env.DINGTALK_CLIENT_ID
|
||||||
|
delete process.env.DINGTALK_CLIENT_SECRET
|
||||||
|
|
||||||
|
const response = await GET()
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
clientId: null,
|
||||||
|
callbackUrl: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('配置完整时只返回前端需要的 clientId 和回调地址', async () => {
|
||||||
|
process.env.DINGTALK_CLIENT_ID = 'client-id'
|
||||||
|
process.env.DINGTALK_CLIENT_SECRET = 'client-secret'
|
||||||
|
process.env.DINGTALK_CALLBACK_URL = 'https://portal.example.com/dingtalk-callback'
|
||||||
|
|
||||||
|
const response = await GET()
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
clientId: 'client-id',
|
||||||
|
callbackUrl: 'https://portal.example.com/dingtalk-callback',
|
||||||
|
})
|
||||||
|
expect(JSON.stringify(data)).not.toContain('client-secret')
|
||||||
|
})
|
||||||
|
})
|
||||||
6
app/api/dingtalk/config/route.ts
Normal file
6
app/api/dingtalk/config/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getDingtalkPublicConfig } from '@/lib/dingtalk-config'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(getDingtalkPublicConfig())
|
||||||
|
}
|
||||||
172
app/api/dingtalk/login/__tests__/route.test.ts
Normal file
172
app/api/dingtalk/login/__tests__/route.test.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { POST } from '../route'
|
||||||
|
|
||||||
|
jest.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
dingtalkLoginToken: {
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('bcryptjs', () => ({
|
||||||
|
hash: jest.fn().mockResolvedValue('hashed-random-password'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { prisma } = require('@/lib/prisma')
|
||||||
|
const bcrypt = require('bcryptjs')
|
||||||
|
|
||||||
|
function mockDingtalkFetch() {
|
||||||
|
;(global.fetch as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ accessToken: 'user-access-token' }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ errcode: 0, access_token: 'app-access-token' }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ unionId: 'union-1' }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ errcode: 0, result: { userid: 'dingtalk-user-1' } }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
errcode: 0,
|
||||||
|
result: {
|
||||||
|
name: '张三',
|
||||||
|
email: 'zhangsan@example.com',
|
||||||
|
mobile: '13800000000',
|
||||||
|
avatar: 'https://example.com/avatar.png',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/dingtalk/login', () => {
|
||||||
|
const originalEnv = process.env
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks()
|
||||||
|
bcrypt.hash.mockResolvedValue('hashed-random-password')
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
DINGTALK_CLIENT_ID: 'client-id',
|
||||||
|
DINGTALK_CLIENT_SECRET: 'client-secret',
|
||||||
|
}
|
||||||
|
global.fetch = jest.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env = originalEnv
|
||||||
|
})
|
||||||
|
|
||||||
|
it('缺少钉钉配置时直接拒绝,不请求外部接口', async () => {
|
||||||
|
delete process.env.DINGTALK_CLIENT_ID
|
||||||
|
delete process.env.DINGTALK_CLIENT_SECRET
|
||||||
|
|
||||||
|
const response = await POST(
|
||||||
|
new NextRequest('http://localhost/api/dingtalk/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code: 'auth-code' }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(503)
|
||||||
|
expect(data.message).toContain('未配置钉钉登录')
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('绑定同名老用户时不覆盖原密码,并返回一次性登录票据', async () => {
|
||||||
|
mockDingtalkFetch()
|
||||||
|
const existingUser = {
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'old@example.com',
|
||||||
|
username: '张三',
|
||||||
|
password: 'old-password-hash',
|
||||||
|
dingtalkUserId: null,
|
||||||
|
realName: null,
|
||||||
|
avatar: null,
|
||||||
|
phone: null,
|
||||||
|
department: '设计部',
|
||||||
|
role: 'USER',
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
prisma.user.findUnique
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(existingUser)
|
||||||
|
prisma.user.update.mockResolvedValue({
|
||||||
|
...existingUser,
|
||||||
|
dingtalkUserId: 'dingtalk-user-1',
|
||||||
|
loginType: 'dingtalk',
|
||||||
|
realName: '张三',
|
||||||
|
})
|
||||||
|
prisma.dingtalkLoginToken.create.mockResolvedValue({ id: 'token-row' })
|
||||||
|
|
||||||
|
const response = await POST(
|
||||||
|
new NextRequest('http://localhost/api/dingtalk/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code: 'auth-code' }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data.loginToken).toEqual(expect.any(String))
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'user-1' },
|
||||||
|
data: expect.not.objectContaining({ password: expect.anything() }),
|
||||||
|
})
|
||||||
|
expect(bcrypt.hash).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('创建钉钉新用户时使用随机不可知密码,不写固定密码', async () => {
|
||||||
|
mockDingtalkFetch()
|
||||||
|
prisma.user.findUnique
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
prisma.user.create.mockResolvedValue({
|
||||||
|
id: 'user-2',
|
||||||
|
email: 'zhangsan@example.com',
|
||||||
|
username: '张三',
|
||||||
|
realName: '张三',
|
||||||
|
avatar: 'https://example.com/avatar.png',
|
||||||
|
role: 'USER',
|
||||||
|
department: '',
|
||||||
|
isActive: false,
|
||||||
|
})
|
||||||
|
prisma.dingtalkLoginToken.create.mockResolvedValue({ id: 'profile-token-row' })
|
||||||
|
|
||||||
|
const response = await POST(
|
||||||
|
new NextRequest('http://localhost/api/dingtalk/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code: 'auth-code' }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(data.needCompleteProfile).toBe(true)
|
||||||
|
expect(data.profileToken).toEqual(expect.any(String))
|
||||||
|
expect(bcrypt.hash).toHaveBeenCalledWith(expect.not.stringContaining('dingtalk_auto_login'), 10)
|
||||||
|
expect(prisma.user.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
password: 'hashed-random-password',
|
||||||
|
loginType: 'dingtalk',
|
||||||
|
dingtalkUserId: 'dingtalk-user-1',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,18 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import { DingtalkConfigError, requireDingtalkAppConfig } from '@/lib/dingtalk-config'
|
||||||
|
import { createDingtalkLoginToken } from '@/lib/dingtalk-login-token'
|
||||||
|
|
||||||
// 钉钉应用配置
|
function getDingtalkConfig() {
|
||||||
const DINGTALK_CONFIG: Record<string, { clientId: string; clientSecret: string }> = {
|
return requireDingtalkAppConfig()
|
||||||
default: {
|
|
||||||
clientId: process.env.DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f',
|
|
||||||
clientSecret: process.env.DINGTALK_CLIENT_SECRET || 'qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户访问令牌
|
// 获取用户访问令牌
|
||||||
async function getUserAccessToken(code: string, company: string = 'default') {
|
async function getUserAccessToken(code: string) {
|
||||||
const config = DINGTALK_CONFIG[company]
|
const config = getDingtalkConfig()
|
||||||
const url = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken'
|
const url = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken'
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
@ -46,8 +45,8 @@ async function getUserAccessToken(code: string, company: string = 'default') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取应用访问令牌
|
// 获取应用访问令牌
|
||||||
async function getAppAccessToken(company: string = 'default') {
|
async function getAppAccessToken() {
|
||||||
const config = DINGTALK_CONFIG[company]
|
const config = getDingtalkConfig()
|
||||||
const url = 'https://oapi.dingtalk.com/gettoken'
|
const url = 'https://oapi.dingtalk.com/gettoken'
|
||||||
|
|
||||||
const response = await fetch(`${url}?appkey=${config.clientId}&appsecret=${config.clientSecret}`)
|
const response = await fetch(`${url}?appkey=${config.clientId}&appsecret=${config.clientSecret}`)
|
||||||
@ -160,12 +159,7 @@ async function getUserInfo(userId: string, appToken: string) {
|
|||||||
|
|
||||||
// 生成随机密码
|
// 生成随机密码
|
||||||
function generateRandomPassword(length: number = 12): string {
|
function generateRandomPassword(length: number = 12): string {
|
||||||
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'
|
return randomBytes(Math.max(length, 24)).toString('base64url')
|
||||||
let password = ''
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
password += charset.charAt(Math.floor(Math.random() * charset.length))
|
|
||||||
}
|
|
||||||
return password
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成随机邮箱
|
// 生成随机邮箱
|
||||||
@ -176,7 +170,7 @@ function generateRandomEmail(): string {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { code, state } = await request.json()
|
const { code } = await request.json()
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -185,14 +179,10 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// state 用于传递前端数据(如origin),不是公司标识
|
console.log('开始钉钉登录流程')
|
||||||
// 始终使用 default 配置
|
|
||||||
const company = 'default'
|
|
||||||
|
|
||||||
console.log('开始钉钉登录流程:', { code, company })
|
|
||||||
|
|
||||||
// 1. 获取用户访问令牌
|
// 1. 获取用户访问令牌
|
||||||
const tokenData = await getUserAccessToken(code, company)
|
const tokenData = await getUserAccessToken(code)
|
||||||
|
|
||||||
// 检查返回的数据
|
// 检查返回的数据
|
||||||
if (!tokenData || !tokenData.accessToken) {
|
if (!tokenData || !tokenData.accessToken) {
|
||||||
@ -207,7 +197,7 @@ export async function POST(request: NextRequest) {
|
|||||||
console.log('获取用户访问令牌成功')
|
console.log('获取用户访问令牌成功')
|
||||||
|
|
||||||
// 2. 获取应用访问令牌
|
// 2. 获取应用访问令牌
|
||||||
const appAccessToken = await getAppAccessToken(company)
|
const appAccessToken = await getAppAccessToken()
|
||||||
console.log('获取应用访问令牌成功')
|
console.log('获取应用访问令牌成功')
|
||||||
|
|
||||||
// 3. 获取unionId
|
// 3. 获取unionId
|
||||||
@ -227,7 +217,6 @@ export async function POST(request: NextRequest) {
|
|||||||
where: { dingtalkUserId: userId }
|
where: { dingtalkUserId: userId }
|
||||||
})
|
})
|
||||||
|
|
||||||
let isNewUser = false
|
|
||||||
let needCompleteProfile = false
|
let needCompleteProfile = false
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@ -236,9 +225,6 @@ export async function POST(request: NextRequest) {
|
|||||||
where: { username: userInfo.name }
|
where: { username: userInfo.name }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 使用固定密码用于钉钉自动登录
|
|
||||||
const hashedPassword = await bcrypt.hash('dingtalk_auto_login', 10)
|
|
||||||
|
|
||||||
if (existingUserByName && !existingUserByName.dingtalkUserId) {
|
if (existingUserByName && !existingUserByName.dingtalkUserId) {
|
||||||
// 找到了同名用户且未绑定钉钉,直接关联
|
// 找到了同名用户且未绑定钉钉,直接关联
|
||||||
console.log('找到同名用户,自动关联钉钉账号:', existingUserByName.username)
|
console.log('找到同名用户,自动关联钉钉账号:', existingUserByName.username)
|
||||||
@ -251,7 +237,6 @@ export async function POST(request: NextRequest) {
|
|||||||
realName: userInfo.name,
|
realName: userInfo.name,
|
||||||
avatar: userInfo.avatar || existingUserByName.avatar,
|
avatar: userInfo.avatar || existingUserByName.avatar,
|
||||||
phone: userInfo.mobile || existingUserByName.phone,
|
phone: userInfo.mobile || existingUserByName.phone,
|
||||||
password: hashedPassword, // 更新密码为固定值
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -268,11 +253,13 @@ export async function POST(request: NextRequest) {
|
|||||||
emailToUse = generateRandomEmail()
|
emailToUse = generateRandomEmail()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const randomPasswordHash = await bcrypt.hash(generateRandomPassword(32), 10)
|
||||||
|
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username: userInfo.name, // 直接使用钉钉真实姓名
|
username: userInfo.name, // 直接使用钉钉真实姓名
|
||||||
email: emailToUse,
|
email: emailToUse,
|
||||||
password: hashedPassword,
|
password: randomPasswordHash,
|
||||||
realName: userInfo.name,
|
realName: userInfo.name,
|
||||||
avatar: userInfo.avatar || null,
|
avatar: userInfo.avatar || null,
|
||||||
phone: userInfo.mobile || null,
|
phone: userInfo.mobile || null,
|
||||||
@ -284,34 +271,32 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
isNewUser = true
|
|
||||||
needCompleteProfile = true // 需要选择部门
|
needCompleteProfile = true // 需要选择部门
|
||||||
console.log('用户创建成功:', user.id)
|
console.log('用户创建成功:', user.id)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 更新现有用户信息
|
// 更新现有用户信息
|
||||||
console.log('更新现有用户:', user.username)
|
console.log('更新现有用户:', user.username)
|
||||||
|
|
||||||
// 确保密码也更新为固定值,以便前端能够自动登录
|
|
||||||
const hashedPassword = await bcrypt.hash('dingtalk_auto_login', 10)
|
|
||||||
|
|
||||||
user = await prisma.user.update({
|
user = await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
realName: userInfo.name,
|
realName: userInfo.name,
|
||||||
avatar: userInfo.avatar || user.avatar,
|
avatar: userInfo.avatar || user.avatar,
|
||||||
phone: userInfo.mobile || user.phone,
|
phone: userInfo.mobile || user.phone,
|
||||||
password: hashedPassword, // 更新密码为固定值
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果需要完善信息(选择部门)
|
// 如果需要完善信息(选择部门)
|
||||||
if (needCompleteProfile) {
|
if (needCompleteProfile) {
|
||||||
|
const tokenResult = await createDingtalkLoginToken(user.id, 'complete_profile')
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
needCompleteProfile: true,
|
needCompleteProfile: true,
|
||||||
message: '请选择您的部门',
|
message: '请选择您的部门',
|
||||||
|
profileToken: tokenResult.token,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@ -332,12 +317,15 @@ export async function POST(request: NextRequest) {
|
|||||||
message: '您的账号尚未激活,请联系管理员激活后再登录'
|
message: '您的账号尚未激活,请联系管理员激活后再登录'
|
||||||
}, { status: 403 })
|
}, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenResult = await createDingtalkLoginToken(user.id, 'login')
|
||||||
|
|
||||||
// 返回用户信息(不包含敏感信息)
|
// 返回用户信息(不包含敏感信息)
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
needCompleteProfile: false,
|
needCompleteProfile: false,
|
||||||
message: '登录成功',
|
message: '登录成功',
|
||||||
|
loginToken: tokenResult.token,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@ -350,6 +338,16 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error instanceof DingtalkConfigError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: '当前环境未配置钉钉登录,请联系管理员',
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
console.error('钉钉登录失败:', error)
|
console.error('钉钉登录失败:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -20,6 +20,13 @@ function CompleteProfileContent() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [success, setSuccess] = useState('')
|
const [success, setSuccess] = useState('')
|
||||||
const [userInfo, setUserInfo] = useState<any>(null)
|
const [userInfo, setUserInfo] = useState<any>(null)
|
||||||
|
const [profileToken, setProfileToken] = useState('')
|
||||||
|
const [logoError, setLogoError] = useState(false)
|
||||||
|
const pageClass = 'min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4'
|
||||||
|
const panelClass = 'w-full max-w-md rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-6'
|
||||||
|
const inputClass = 'w-full px-4 py-3 rounded-xl border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:border-slate-900 dark:focus:border-slate-500 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 outline-none transition-colors'
|
||||||
|
const readonlyClass = `${inputClass} bg-slate-50 dark:bg-slate-700 cursor-not-allowed`
|
||||||
|
const buttonClass = 'w-full py-3 rounded-xl bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 hover:bg-slate-800 dark:hover:bg-white font-medium transition-colors disabled:opacity-60'
|
||||||
|
|
||||||
// 系统设置
|
// 系统设置
|
||||||
const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
|
const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
|
||||||
@ -36,6 +43,7 @@ function CompleteProfileContent() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const settings = await response.json()
|
const settings = await response.json()
|
||||||
setLoginLogoUrl(settings.loginLogoUrl || defaultLogo)
|
setLoginLogoUrl(settings.loginLogoUrl || defaultLogo)
|
||||||
|
setLogoError(false)
|
||||||
setSiteName(settings.siteName || defaultSiteName)
|
setSiteName(settings.siteName || defaultSiteName)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -70,6 +78,13 @@ function CompleteProfileContent() {
|
|||||||
setError('缺少用户信息,请重新登录')
|
setError('缺少用户信息,请重新登录')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenParam = searchParams.get('profileToken')
|
||||||
|
if (tokenParam) {
|
||||||
|
setProfileToken(tokenParam)
|
||||||
|
} else {
|
||||||
|
setError('缺少登录凭证,请重新登录')
|
||||||
|
}
|
||||||
|
|
||||||
loadSettings()
|
loadSettings()
|
||||||
loadDepartments()
|
loadDepartments()
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
@ -93,7 +108,7 @@ function CompleteProfileContent() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
userId: userInfo?.id,
|
profileToken,
|
||||||
department,
|
department,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@ -134,8 +149,7 @@ function CompleteProfileContent() {
|
|||||||
|
|
||||||
// 已激活用户:使用 NextAuth 自动登录
|
// 已激活用户:使用 NextAuth 自动登录
|
||||||
const result = await signIn('credentials', {
|
const result = await signIn('credentials', {
|
||||||
email: data.user.email,
|
dingtalkToken: data.loginToken,
|
||||||
password: 'dingtalk_auto_login',
|
|
||||||
redirect: false,
|
redirect: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -160,8 +174,8 @@ function CompleteProfileContent() {
|
|||||||
|
|
||||||
if (error && !userInfo) {
|
if (error && !userInfo) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
|
<div className={pageClass}>
|
||||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
|
<div className="w-full max-w-md rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<svg className="mx-auto h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="mx-auto h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@ -172,7 +186,7 @@ function CompleteProfileContent() {
|
|||||||
<p className="text-slate-600 dark:text-slate-400 mb-4">{error}</p>
|
<p className="text-slate-600 dark:text-slate-400 mb-4">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/login')}
|
onClick={() => router.push('/login')}
|
||||||
className="btn-primary"
|
className={buttonClass}
|
||||||
>
|
>
|
||||||
返回登录
|
返回登录
|
||||||
</button>
|
</button>
|
||||||
@ -183,28 +197,35 @@ function CompleteProfileContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
|
<div className={pageClass}>
|
||||||
<div className="max-w-md w-full">
|
<div className="max-w-md w-full">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<Image
|
{loginLogoUrl && !logoError ? (
|
||||||
src={loginLogoUrl}
|
<Image
|
||||||
alt={siteName}
|
src={loginLogoUrl}
|
||||||
width={80}
|
alt={siteName}
|
||||||
height={80}
|
width={80}
|
||||||
unoptimized
|
height={80}
|
||||||
className="rounded-apple shadow-apple"
|
unoptimized
|
||||||
/>
|
className="rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm"
|
||||||
|
onError={() => setLogoError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-20 h-20 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm bg-slate-900 dark:bg-slate-700 flex items-center justify-center">
|
||||||
|
<span className="text-3xl font-bold text-white">{siteName.charAt(0) || 'F'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">完善个人信息</h1>
|
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">完善个人信息</h1>
|
||||||
<p className="text-slate-600 dark:text-slate-400 mt-2">首次登录,请完善您的用户信息</p>
|
<p className="text-slate-600 dark:text-slate-400 mt-2">首次登录,请补全部门信息</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<div className="card-apple p-8">
|
<div className={panelClass}>
|
||||||
{userInfo && (
|
{userInfo && (
|
||||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
<div className="mb-6 p-4 bg-slate-50 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{userInfo.avatar && (
|
{userInfo.avatar && (
|
||||||
<Image
|
<Image
|
||||||
@ -226,13 +247,13 @@ function CompleteProfileContent() {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm">
|
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-xl text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
<div className="bg-green-50 border border-green-200 text-green-600 px-4 py-3 rounded-apple text-sm">
|
<div className="bg-green-50 border border-green-200 text-green-600 px-4 py-3 rounded-xl text-sm">
|
||||||
{success}
|
{success}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -242,7 +263,7 @@ function CompleteProfileContent() {
|
|||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
用户名
|
用户名
|
||||||
</label>
|
</label>
|
||||||
<div className="input-apple bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-300 cursor-not-allowed">
|
<div className={readonlyClass}>
|
||||||
{userInfo?.username || userInfo?.realName || '加载中...'}
|
{userInfo?.username || userInfo?.realName || '加载中...'}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">使用您的钉钉姓名作为系统用户名</p>
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">使用您的钉钉姓名作为系统用户名</p>
|
||||||
@ -255,7 +276,7 @@ function CompleteProfileContent() {
|
|||||||
<select
|
<select
|
||||||
value={department}
|
value={department}
|
||||||
onChange={(e) => setDepartment(e.target.value)}
|
onChange={(e) => setDepartment(e.target.value)}
|
||||||
className="input-apple"
|
className={inputClass}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">请选择部门</option>
|
<option value="">请选择部门</option>
|
||||||
@ -269,7 +290,7 @@ function CompleteProfileContent() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full btn-primary"
|
className={buttonClass}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? '提交中...' : '完成并登录'}
|
{loading ? '提交中...' : '完成并登录'}
|
||||||
@ -293,9 +314,9 @@ function CompleteProfileContent() {
|
|||||||
export default function CompleteProfilePage() {
|
export default function CompleteProfilePage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-slate-400 border-t-transparent"></div>
|
||||||
<p className="mt-4 text-slate-600 dark:text-slate-400">加载中...</p>
|
<p className="mt-4 text-slate-600 dark:text-slate-400">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,12 +4,62 @@ import { useEffect, useState, Suspense } from 'react'
|
|||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { signIn } from 'next-auth/react'
|
import { signIn } from 'next-auth/react'
|
||||||
|
|
||||||
|
type CallbackStatus = 'loading' | 'success' | 'error'
|
||||||
|
|
||||||
|
function CallbackShell({
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
status: CallbackStatus
|
||||||
|
message: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4 py-10 dark:bg-slate-950">
|
||||||
|
<div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-800">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-slate-400 border-t-transparent" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<svg className="h-5 w-5 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<svg className="h-5 w-5 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className={`mb-2 text-lg font-semibold ${
|
||||||
|
status === 'error' ? 'text-red-600 dark:text-red-400' : 'text-slate-900 dark:text-slate-100'
|
||||||
|
}`}>
|
||||||
|
{status === 'loading' && '钉钉登录'}
|
||||||
|
{status === 'success' && '登录成功'}
|
||||||
|
{status === 'error' && '登录失败'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-sm leading-6 text-slate-600 dark:text-slate-400">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DingtalkCallbackContent() {
|
function DingtalkCallbackContent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
const [status, setStatus] = useState<CallbackStatus>('loading')
|
||||||
const [message, setMessage] = useState('正在处理钉钉登录...')
|
const [message, setMessage] = useState('正在处理钉钉登录...')
|
||||||
|
|
||||||
|
const postToParent = (payload: Record<string, unknown>) => {
|
||||||
|
window.parent.postMessage(payload, window.location.origin)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDingtalkLogin = async () => {
|
const handleDingtalkLogin = async () => {
|
||||||
try {
|
try {
|
||||||
@ -54,15 +104,13 @@ function DingtalkCallbackContent() {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.parent !== window) {
|
if (window.parent !== window) {
|
||||||
// 在 iframe 中,通知父窗口跳转
|
// 在 iframe 中,通知父窗口跳转
|
||||||
window.parent.postMessage(
|
postToParent({
|
||||||
{
|
type: 'dingtalk_need_complete_profile',
|
||||||
type: 'dingtalk_need_complete_profile',
|
userInfo: data.user,
|
||||||
userInfo: data.user,
|
profileToken: data.profileToken,
|
||||||
},
|
})
|
||||||
'*'
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
router.push(`/complete-profile?userInfo=${userInfoParam}`)
|
router.push(`/complete-profile?userInfo=${userInfoParam}&profileToken=${encodeURIComponent(data.profileToken || '')}`)
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
return
|
return
|
||||||
@ -76,26 +124,23 @@ function DingtalkCallbackContent() {
|
|||||||
|
|
||||||
// 通知父窗口登录成功(如果在 iframe 中)
|
// 通知父窗口登录成功(如果在 iframe 中)
|
||||||
if (window.parent !== window) {
|
if (window.parent !== window) {
|
||||||
window.parent.postMessage(
|
postToParent({
|
||||||
{
|
type: 'dingtalk_login_success',
|
||||||
type: 'dingtalk_login_success',
|
userInfo: data.user,
|
||||||
userInfo: data.user,
|
loginToken: data.loginToken,
|
||||||
originUrl: originUrl // 携带原始地址
|
originUrl: originUrl // 携带原始地址
|
||||||
},
|
})
|
||||||
'*'
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
// 不在 iframe 中(直接访问回调页面)
|
// 不在 iframe 中(直接访问回调页面)
|
||||||
if (isFromIntranet) {
|
if (isFromIntranet) {
|
||||||
// 从内网来的:不在这里创建session,跳转回内网后由内网页面创建
|
// 从内网来的:不在这里创建session,跳转回内网后由内网页面创建
|
||||||
setMessage('登录成功,正在返回内网...')
|
setMessage('登录成功,正在返回内网...')
|
||||||
// 将用户信息作为参数传递,让内网页面创建session
|
// 将一次性登录凭证传递回内网页面,让内网页面创建 session
|
||||||
window.location.href = `${originUrl}/login?dingtalk_success=true&email=${encodeURIComponent(data.user.email)}`
|
window.location.href = `${originUrl}/login?dingtalk_success=true&token=${encodeURIComponent(data.loginToken)}`
|
||||||
} else {
|
} else {
|
||||||
// 从外网来的:直接创建session并跳转
|
// 从外网来的:直接创建session并跳转
|
||||||
const signInResult = await signIn('credentials', {
|
const signInResult = await signIn('credentials', {
|
||||||
email: data.user.email,
|
dingtalkToken: data.loginToken,
|
||||||
password: 'dingtalk_auto_login',
|
|
||||||
redirect: false,
|
redirect: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -117,7 +162,7 @@ function DingtalkCallbackContent() {
|
|||||||
// 3秒后关闭或跳转
|
// 3秒后关闭或跳转
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.parent !== window) {
|
if (window.parent !== window) {
|
||||||
window.parent.postMessage({ type: 'dingtalk_login_error', message: error.message }, '*')
|
postToParent({ type: 'dingtalk_login_error', message: error.message })
|
||||||
} else {
|
} else {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
@ -128,61 +173,13 @@ function DingtalkCallbackContent() {
|
|||||||
handleDingtalkLogin()
|
handleDingtalkLogin()
|
||||||
}, [searchParams, router])
|
}, [searchParams, router])
|
||||||
|
|
||||||
return (
|
return <CallbackShell status={status} message={message} />
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
|
|
||||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
|
|
||||||
<div className="text-center">
|
|
||||||
{status === 'loading' && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'success' && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<svg className="mx-auto h-12 w-12 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'error' && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<svg className="mx-auto h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h2 className={`text-xl font-semibold mb-2 ${
|
|
||||||
status === 'error' ? 'text-red-600' : 'text-slate-800 dark:text-slate-200'
|
|
||||||
}`}>
|
|
||||||
{status === 'loading' && '钉钉登录'}
|
|
||||||
{status === 'success' && '登录成功'}
|
|
||||||
{status === 'error' && '登录失败'}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-slate-600 dark:text-slate-400">{message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DingtalkCallbackPage() {
|
export default function DingtalkCallbackPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
|
<CallbackShell status="loading" message="正在加载..." />
|
||||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold mb-2 text-slate-800 dark:text-slate-200">钉钉登录</h2>
|
|
||||||
<p className="text-slate-600 dark:text-slate-400">正在加载...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}>
|
}>
|
||||||
<DingtalkCallbackContent />
|
<DingtalkCallbackContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@ -8,7 +8,13 @@ import Link from 'next/link'
|
|||||||
|
|
||||||
// 默认登录页 Logo:优先使用构建时环境变量,否则使用内置静态资源
|
// 默认登录页 Logo:优先使用构建时环境变量,否则使用内置静态资源
|
||||||
const DEFAULT_LOGIN_LOGO =
|
const DEFAULT_LOGIN_LOGO =
|
||||||
process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png'
|
process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''
|
||||||
|
|
||||||
|
type DingtalkPublicConfig = {
|
||||||
|
enabled: boolean
|
||||||
|
clientId: string | null
|
||||||
|
callbackUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -17,10 +23,16 @@ export default function LoginPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [showDingtalkModal, setShowDingtalkModal] = useState(false)
|
const [showDingtalkModal, setShowDingtalkModal] = useState(false)
|
||||||
|
const [dingtalkConfigLoaded, setDingtalkConfigLoaded] = useState(false)
|
||||||
|
|
||||||
// 系统设置
|
// 系统设置
|
||||||
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO)
|
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO)
|
||||||
const [siteName, setSiteName] = useState('FLNXI素材库')
|
const [siteName, setSiteName] = useState('FLNXI素材库')
|
||||||
|
const [dingtalkConfig, setDingtalkConfig] = useState<DingtalkPublicConfig>({
|
||||||
|
enabled: false,
|
||||||
|
clientId: null,
|
||||||
|
callbackUrl: null,
|
||||||
|
})
|
||||||
|
|
||||||
// Logo 加载状态
|
// Logo 加载状态
|
||||||
const [logoError, setLogoError] = useState(false)
|
const [logoError, setLogoError] = useState(false)
|
||||||
@ -54,9 +66,6 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [loginLogoUrl])
|
}, [loginLogoUrl])
|
||||||
|
|
||||||
// 钉钉配置(从环境变量或配置文件获取)
|
|
||||||
const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'
|
|
||||||
|
|
||||||
// 加载系统设置
|
// 加载系统设置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
@ -74,6 +83,28 @@ export default function LoginPage() {
|
|||||||
loadSettings()
|
loadSettings()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDingtalkConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/dingtalk/config')
|
||||||
|
if (response.ok) {
|
||||||
|
const config = await response.json()
|
||||||
|
setDingtalkConfig({
|
||||||
|
enabled: Boolean(config.enabled),
|
||||||
|
clientId: config.clientId || null,
|
||||||
|
callbackUrl: config.callbackUrl || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载钉钉配置失败:', error)
|
||||||
|
} finally {
|
||||||
|
setDingtalkConfigLoaded(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDingtalkConfig()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 页面加载时识别访问环境
|
// 页面加载时识别访问环境
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const envDomains = (process.env.NEXT_PUBLIC_PUBLIC_DOMAINS || '').split(',').map(s => s.trim()).filter(Boolean)
|
const envDomains = (process.env.NEXT_PUBLIC_PUBLIC_DOMAINS || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||||
@ -112,23 +143,21 @@ export default function LoginPage() {
|
|||||||
const handleDingtalkAutoLogin = async () => {
|
const handleDingtalkAutoLogin = async () => {
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const dingtalkSuccess = urlParams.get('dingtalk_success')
|
const dingtalkSuccess = urlParams.get('dingtalk_success')
|
||||||
const userEmail = urlParams.get('email')
|
const loginToken = urlParams.get('token')
|
||||||
|
|
||||||
if (dingtalkSuccess === 'true' && userEmail) {
|
if (dingtalkSuccess === 'true' && loginToken) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
window.history.replaceState({}, '', '/login')
|
||||||
// 在内网域名下创建 session
|
// 在内网域名下创建 session
|
||||||
const result = await signIn('credentials', {
|
const result = await signIn('credentials', {
|
||||||
email: decodeURIComponent(userEmail),
|
dingtalkToken: decodeURIComponent(loginToken),
|
||||||
password: 'dingtalk_auto_login',
|
|
||||||
redirect: false,
|
redirect: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
setError('登录失败,请重试')
|
setError('登录失败,请重试')
|
||||||
} else {
|
} else {
|
||||||
// 清除 URL 参数并跳转到首页
|
|
||||||
window.history.replaceState({}, '', '/login')
|
|
||||||
router.push('/')
|
router.push('/')
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
@ -170,8 +199,13 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDingtalkLogin = () => {
|
const handleDingtalkLogin = () => {
|
||||||
|
if (!dingtalkConfig.enabled || !dingtalkConfig.clientId) {
|
||||||
|
setError('钉钉登录未配置,请联系管理员')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 强制使用外网域名作为回调地址,支持内网访问
|
// 强制使用外网域名作为回调地址,支持内网访问
|
||||||
const redirectUrl = process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_URL || `${window.location.origin}/dingtalk-callback`
|
const redirectUrl = dingtalkConfig.callbackUrl || `${window.location.origin}/dingtalk-callback`
|
||||||
|
|
||||||
// 在state中携带原始访问地址信息
|
// 在state中携带原始访问地址信息
|
||||||
const isIntranet = window.location.hostname.includes('192.168.')
|
const isIntranet = window.location.hostname.includes('192.168.')
|
||||||
@ -180,7 +214,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?` +
|
const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?` +
|
||||||
`redirect_uri=${encodeURIComponent(redirectUrl)}` +
|
`redirect_uri=${encodeURIComponent(redirectUrl)}` +
|
||||||
`&client_id=${DINGTALK_CLIENT_ID}` +
|
`&client_id=${dingtalkConfig.clientId}` +
|
||||||
`&scope=openid` +
|
`&scope=openid` +
|
||||||
`&response_type=code` +
|
`&response_type=code` +
|
||||||
`&state=${encodeURIComponent(stateData)}` +
|
`&state=${encodeURIComponent(stateData)}` +
|
||||||
@ -207,14 +241,17 @@ export default function LoginPage() {
|
|||||||
// 监听来自 iframe 的消息
|
// 监听来自 iframe 的消息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMessage = async (event: MessageEvent) => {
|
const handleMessage = async (event: MessageEvent) => {
|
||||||
|
if (event.origin !== window.location.origin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.data && event.data.type === 'dingtalk_login_success') {
|
if (event.data && event.data.type === 'dingtalk_login_success') {
|
||||||
setShowDingtalkModal(false)
|
setShowDingtalkModal(false)
|
||||||
|
|
||||||
// 创建 NextAuth session
|
// 创建 NextAuth session
|
||||||
try {
|
try {
|
||||||
const result = await signIn('credentials', {
|
const result = await signIn('credentials', {
|
||||||
email: event.data.userInfo.email,
|
dingtalkToken: event.data.loginToken,
|
||||||
password: 'dingtalk_auto_login',
|
|
||||||
redirect: false,
|
redirect: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -243,7 +280,7 @@ export default function LoginPage() {
|
|||||||
setShowDingtalkModal(false)
|
setShowDingtalkModal(false)
|
||||||
// 跳转到信息完善页面
|
// 跳转到信息完善页面
|
||||||
const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))
|
const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))
|
||||||
router.push(`/complete-profile?userInfo=${userInfoParam}`)
|
router.push(`/complete-profile?userInfo=${userInfoParam}&profileToken=${encodeURIComponent(event.data.profileToken || '')}`)
|
||||||
} else if (event.data && event.data.type === 'dingtalk_login_error') {
|
} else if (event.data && event.data.type === 'dingtalk_login_error') {
|
||||||
setShowDingtalkModal(false)
|
setShowDingtalkModal(false)
|
||||||
setError(event.data.message || '钉钉登录失败')
|
setError(event.data.message || '钉钉登录失败')
|
||||||
@ -255,7 +292,7 @@ export default function LoginPage() {
|
|||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4">
|
||||||
<div className="max-w-md w-full">
|
<div className="max-w-md w-full">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
@ -268,7 +305,7 @@ export default function LoginPage() {
|
|||||||
width={80}
|
width={80}
|
||||||
height={80}
|
height={80}
|
||||||
unoptimized
|
unoptimized
|
||||||
className="rounded-apple shadow-apple"
|
className="rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const img = e.target as HTMLImageElement
|
const img = e.target as HTMLImageElement
|
||||||
setLogoRetryCount((prevCount) => {
|
setLogoRetryCount((prevCount) => {
|
||||||
@ -291,20 +328,20 @@ export default function LoginPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-20 h-20 rounded-apple shadow-apple bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center">
|
<div className="w-20 h-20 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm bg-slate-900 dark:bg-slate-700 flex items-center justify-center">
|
||||||
<span className="text-3xl font-bold text-white">{siteName.charAt(0)}</span>
|
<span className="text-3xl font-bold text-white">{siteName.charAt(0)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">{siteName}</h1>
|
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{siteName}</h1>
|
||||||
<p className="text-slate-600 dark:text-slate-400 mt-2">欢迎回来,请登录您的账号</p>
|
<p className="text-slate-600 dark:text-slate-400 mt-2">请登录后继续使用</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Form */}
|
{/* Login Form */}
|
||||||
<div className="card-apple p-8">
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-6">
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm">
|
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-xl text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -317,7 +354,7 @@ export default function LoginPage() {
|
|||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="input-apple"
|
className="w-full px-4 py-3 rounded-xl border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:border-slate-900 dark:focus:border-slate-500 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 outline-none transition-colors"
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -331,7 +368,7 @@ export default function LoginPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="input-apple"
|
className="w-full px-4 py-3 rounded-xl border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:border-slate-900 dark:focus:border-slate-500 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 outline-none transition-colors"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -339,7 +376,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full btn-primary"
|
className="w-full py-3 rounded-xl bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 hover:bg-slate-800 dark:hover:bg-white font-medium transition-colors disabled:opacity-60"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? '登录中...' : '登录'}
|
{loading ? '登录中...' : '登录'}
|
||||||
@ -350,10 +387,10 @@ export default function LoginPage() {
|
|||||||
{/* 分隔线 */}
|
{/* 分隔线 */}
|
||||||
<div className="relative my-6">
|
<div className="relative my-6">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-slate-300 dark:border-slate-600"></div>
|
<div className="w-full border-t border-slate-200 dark:border-slate-700"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-sm">
|
<div className="relative flex justify-center text-sm">
|
||||||
<span className="px-2 bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">或</span>
|
<span className="px-2 bg-white dark:bg-slate-900 text-slate-500 dark:text-slate-400">或</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -362,12 +399,19 @@ export default function LoginPage() {
|
|||||||
id="dingtalk-login-btn"
|
id="dingtalk-login-btn"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDingtalkLogin}
|
onClick={handleDingtalkLogin}
|
||||||
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-xl shadow-sm bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
disabled={!dingtalkConfig.enabled || !dingtalkConfig.clientId}
|
||||||
|
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-slate-300 dark:border-slate-700 rounded-xl bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60 transition-colors"
|
||||||
>
|
>
|
||||||
<svg style={{ width: '28px', height: '28px' }} viewBox="0 0 1024 1024">
|
<svg style={{ width: '28px', height: '28px' }} viewBox="0 0 1024 1024">
|
||||||
<path d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z" fill="#3296FA"></path>
|
<path d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z" fill="#3296FA"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-lg font-medium text-slate-700 dark:text-slate-300">钉钉登录</span>
|
<span className="text-lg font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{!dingtalkConfigLoaded
|
||||||
|
? '正在检查钉钉配置'
|
||||||
|
: dingtalkConfig.enabled
|
||||||
|
? '钉钉登录'
|
||||||
|
: '钉钉登录未配置'}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -387,11 +431,11 @@ export default function LoginPage() {
|
|||||||
{/* 钉钉登录 iframe 弹窗 */}
|
{/* 钉钉登录 iframe 弹窗 */}
|
||||||
{showDingtalkModal && (
|
{showDingtalkModal && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||||
onClick={() => setShowDingtalkModal(false)}
|
onClick={() => setShowDingtalkModal(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4"
|
className="relative bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-lg w-full max-w-md mx-4"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-700">
|
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-700">
|
||||||
@ -408,10 +452,10 @@ export default function LoginPage() {
|
|||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<iframe
|
<iframe
|
||||||
id="dingtalkIframe"
|
id="dingtalkIframe"
|
||||||
className="w-full h-96 border-0 rounded-lg"
|
className="w-full h-96 border-0 rounded-lg bg-white dark:bg-slate-900"
|
||||||
title="钉钉登录"
|
title="钉钉登录"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
69
lib/__tests__/auth-dingtalk-token.test.ts
Normal file
69
lib/__tests__/auth-dingtalk-token.test.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
jest.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
systemSettings: {
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/lib/dingtalk-login-token', () => ({
|
||||||
|
consumeDingtalkLoginToken: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('bcryptjs', () => ({
|
||||||
|
compare: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { authOptions } from '../auth'
|
||||||
|
import { consumeDingtalkLoginToken } from '../dingtalk-login-token'
|
||||||
|
import { prisma } from '../prisma'
|
||||||
|
|
||||||
|
const mockConsumeDingtalkLoginToken = consumeDingtalkLoginToken as jest.MockedFunction<typeof consumeDingtalkLoginToken>
|
||||||
|
|
||||||
|
function getDingtalkProvider() {
|
||||||
|
const provider = authOptions.providers.find((item: any) => item.id === 'credentials') as any
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error('credentials provider missing')
|
||||||
|
}
|
||||||
|
return provider
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('auth dingtalk-token provider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('使用一次性钉钉票据登录并返回会话用户', async () => {
|
||||||
|
mockConsumeDingtalkLoginToken.mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user@example.com',
|
||||||
|
username: '张三',
|
||||||
|
role: 'USER',
|
||||||
|
isSuperAdmin: false,
|
||||||
|
avatar: null,
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = await getDingtalkProvider().options.authorize({ dingtalkToken: 'one-time-token' })
|
||||||
|
|
||||||
|
expect(mockConsumeDingtalkLoginToken).toHaveBeenCalledWith('one-time-token', 'login')
|
||||||
|
expect(user).toEqual(expect.objectContaining({
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user@example.com',
|
||||||
|
name: '张三',
|
||||||
|
role: 'USER',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('拒绝无效或已消费的钉钉票据', async () => {
|
||||||
|
mockConsumeDingtalkLoginToken.mockResolvedValue(null)
|
||||||
|
|
||||||
|
await expect(getDingtalkProvider().options.authorize({ dingtalkToken: 'bad-token' }))
|
||||||
|
.rejects
|
||||||
|
.toThrow('钉钉登录凭证已失效')
|
||||||
|
})
|
||||||
|
})
|
||||||
92
lib/__tests__/dingtalk-login-token.test.ts
Normal file
92
lib/__tests__/dingtalk-login-token.test.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
consumeDingtalkLoginToken,
|
||||||
|
createDingtalkLoginToken,
|
||||||
|
} from '../dingtalk-login-token'
|
||||||
|
|
||||||
|
jest.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
dingtalkLoginToken: {
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
$transaction: jest.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { prisma } = require('@/lib/prisma')
|
||||||
|
|
||||||
|
describe('dingtalk-login-token', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('创建票据时只保存哈希,不保存明文 token', async () => {
|
||||||
|
const token = await createDingtalkLoginToken('user-1', 'login')
|
||||||
|
|
||||||
|
expect(token.token).toEqual(expect.any(String))
|
||||||
|
expect(token.token.length).toBeGreaterThan(30)
|
||||||
|
expect(prisma.dingtalkLoginToken.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
userId: 'user-1',
|
||||||
|
purpose: 'login',
|
||||||
|
tokenHash: expect.any(String),
|
||||||
|
expiresAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
expect(prisma.dingtalkLoginToken.create.mock.calls[0][0].data.tokenHash).not.toBe(token.token)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('消费票据时会标记已使用并返回用户', async () => {
|
||||||
|
const mockTx = {
|
||||||
|
dingtalkLoginToken: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: 'token-1',
|
||||||
|
purpose: 'login',
|
||||||
|
usedAt: null,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user@example.com',
|
||||||
|
username: '张三',
|
||||||
|
role: 'USER',
|
||||||
|
isSuperAdmin: false,
|
||||||
|
avatar: null,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prisma.$transaction.mockImplementation((callback: any) => callback(mockTx))
|
||||||
|
|
||||||
|
const user = await consumeDingtalkLoginToken('plain-token', 'login')
|
||||||
|
|
||||||
|
expect(user).toEqual(expect.objectContaining({ id: 'user-1', email: 'user@example.com' }))
|
||||||
|
expect(mockTx.dingtalkLoginToken.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
id: 'token-1',
|
||||||
|
purpose: 'login',
|
||||||
|
usedAt: null,
|
||||||
|
}),
|
||||||
|
data: { usedAt: expect.any(Date) },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('拒绝过期或已使用票据', async () => {
|
||||||
|
const mockTx = {
|
||||||
|
dingtalkLoginToken: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: 'token-1',
|
||||||
|
purpose: 'login',
|
||||||
|
usedAt: new Date(),
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
user: { id: 'user-1' },
|
||||||
|
}),
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prisma.$transaction.mockImplementation((callback: any) => callback(mockTx))
|
||||||
|
|
||||||
|
await expect(consumeDingtalkLoginToken('plain-token', 'login')).resolves.toBeNull()
|
||||||
|
expect(mockTx.dingtalkLoginToken.updateMany).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
31
lib/auth.ts
31
lib/auth.ts
@ -1,17 +1,42 @@
|
|||||||
import { NextAuthOptions } from 'next-auth'
|
import { NextAuthOptions } from 'next-auth'
|
||||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { consumeDingtalkLoginToken } from '@/lib/dingtalk-login-token'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
providers: [
|
providers: [
|
||||||
CredentialsProvider({
|
CredentialsProvider({
|
||||||
|
id: 'credentials',
|
||||||
name: 'Credentials',
|
name: 'Credentials',
|
||||||
credentials: {
|
credentials: {
|
||||||
email: { label: 'Email', type: 'email' },
|
email: { label: 'Email', type: 'email' },
|
||||||
password: { label: 'Password', type: 'password' },
|
password: { label: 'Password', type: 'password' },
|
||||||
|
dingtalkToken: { label: 'Dingtalk Token', type: 'text' },
|
||||||
},
|
},
|
||||||
async authorize(credentials) {
|
async authorize(credentials) {
|
||||||
|
const dingtalkToken = credentials?.dingtalkToken?.trim()
|
||||||
|
if (dingtalkToken) {
|
||||||
|
const user = await consumeDingtalkLoginToken(dingtalkToken, 'login')
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('钉钉登录凭证已失效,请重新扫码')
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await prisma.systemSettings.findFirst()
|
||||||
|
if (settings?.enableUserActivation && !user.isActive && user.role !== 'ADMIN' && !(user as any).isSuperAdmin) {
|
||||||
|
throw new Error('账号未激活,请联系管理员')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.username,
|
||||||
|
role: user.role as "ADMIN" | "USER",
|
||||||
|
isSuperAdmin: (user as any).isSuperAdmin,
|
||||||
|
avatar: user.avatar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!credentials?.email || !credentials?.password) {
|
if (!credentials?.email || !credentials?.password) {
|
||||||
throw new Error('请输入邮箱和密码')
|
throw new Error('请输入邮箱和密码')
|
||||||
}
|
}
|
||||||
@ -66,10 +91,10 @@ export const authOptions: NextAuthOptions = {
|
|||||||
if (token.id && typeof token.id === 'string') {
|
if (token.id && typeof token.id === 'string') {
|
||||||
const avatarUpdatedAt = (token as any).avatarUpdatedAt || 0;
|
const avatarUpdatedAt = (token as any).avatarUpdatedAt || 0;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const fiveMinutes = 5 * 60 * 1000;
|
const refreshInterval = 5 * 60 * 1000; // 5分钟
|
||||||
|
|
||||||
// 如果超过5分钟未刷新,或者手动触发更新,则从数据库获取最新信息
|
// 如果超过刷新间隔未刷新,或者手动触发更新,则从数据库获取最新信息
|
||||||
if (trigger === 'update' || (now - avatarUpdatedAt) > fiveMinutes) {
|
if (trigger === 'update' || (now - avatarUpdatedAt) > refreshInterval) {
|
||||||
try {
|
try {
|
||||||
const dbUser = await prisma.user.findUnique({
|
const dbUser = await prisma.user.findUnique({
|
||||||
where: { id: token.id as string },
|
where: { id: token.id as string },
|
||||||
|
|||||||
39
lib/dingtalk-config.ts
Normal file
39
lib/dingtalk-config.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
export class DingtalkConfigError extends Error {
|
||||||
|
constructor(message = '当前环境未配置钉钉登录') {
|
||||||
|
super(message)
|
||||||
|
this.name = 'DingtalkConfigError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DingtalkAppConfig {
|
||||||
|
clientId: string
|
||||||
|
clientSecret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnv(name: string): string {
|
||||||
|
return (process.env[name] || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDingtalkPublicConfig() {
|
||||||
|
const clientId = readEnv('DINGTALK_CLIENT_ID')
|
||||||
|
const clientSecret = readEnv('DINGTALK_CLIENT_SECRET')
|
||||||
|
const callbackUrl =
|
||||||
|
readEnv('DINGTALK_CALLBACK_URL') || readEnv('NEXT_PUBLIC_DINGTALK_CALLBACK_URL')
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: Boolean(clientId && clientSecret),
|
||||||
|
clientId: clientId && clientSecret ? clientId : null,
|
||||||
|
callbackUrl: callbackUrl || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireDingtalkAppConfig(): DingtalkAppConfig {
|
||||||
|
const clientId = readEnv('DINGTALK_CLIENT_ID')
|
||||||
|
const clientSecret = readEnv('DINGTALK_CLIENT_SECRET')
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
throw new DingtalkConfigError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clientId, clientSecret }
|
||||||
|
}
|
||||||
97
lib/dingtalk-login-token.ts
Normal file
97
lib/dingtalk-login-token.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { createHash, randomBytes } from 'crypto'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export const DINGTALK_LOGIN_TOKEN_TTL_MS = 5 * 60 * 1000
|
||||||
|
export const DINGTALK_PROFILE_TOKEN_TTL_MS = 15 * 60 * 1000
|
||||||
|
|
||||||
|
export type DingtalkLoginTokenPurpose = 'login' | 'complete_profile'
|
||||||
|
|
||||||
|
type DingtalkTokenUser = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
role: 'ADMIN' | 'USER'
|
||||||
|
isSuperAdmin?: boolean
|
||||||
|
avatar?: string | null
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashDingtalkLoginToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDingtalkLoginToken(
|
||||||
|
userId: string,
|
||||||
|
purpose: DingtalkLoginTokenPurpose = 'login',
|
||||||
|
ttlMs = purpose === 'complete_profile'
|
||||||
|
? DINGTALK_PROFILE_TOKEN_TTL_MS
|
||||||
|
: DINGTALK_LOGIN_TOKEN_TTL_MS
|
||||||
|
): Promise<{ token: string; expiresAt: Date }> {
|
||||||
|
const token = randomBytes(32).toString('base64url')
|
||||||
|
const expiresAt = new Date(Date.now() + ttlMs)
|
||||||
|
|
||||||
|
await prisma.dingtalkLoginToken.create({
|
||||||
|
data: {
|
||||||
|
tokenHash: hashDingtalkLoginToken(token),
|
||||||
|
userId,
|
||||||
|
purpose,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { token, expiresAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeDingtalkLoginToken(
|
||||||
|
token: string,
|
||||||
|
purpose: DingtalkLoginTokenPurpose = 'login'
|
||||||
|
): Promise<DingtalkTokenUser | null> {
|
||||||
|
const normalizedToken = token?.trim()
|
||||||
|
if (!normalizedToken) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = hashDingtalkLoginToken(normalizedToken)
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const loginToken = await tx.dingtalkLoginToken.findUnique({
|
||||||
|
where: { tokenHash },
|
||||||
|
include: { user: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
!loginToken ||
|
||||||
|
loginToken.purpose !== purpose ||
|
||||||
|
loginToken.usedAt ||
|
||||||
|
loginToken.expiresAt <= now ||
|
||||||
|
!loginToken.user
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumed = await tx.dingtalkLoginToken.updateMany({
|
||||||
|
where: {
|
||||||
|
id: loginToken.id,
|
||||||
|
purpose,
|
||||||
|
usedAt: null,
|
||||||
|
expiresAt: { gt: now },
|
||||||
|
},
|
||||||
|
data: { usedAt: now },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (consumed.count !== 1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: loginToken.user.id,
|
||||||
|
email: loginToken.user.email,
|
||||||
|
username: loginToken.user.username,
|
||||||
|
role: loginToken.user.role as 'ADMIN' | 'USER',
|
||||||
|
isSuperAdmin: loginToken.user.isSuperAdmin,
|
||||||
|
avatar: loginToken.user.avatar,
|
||||||
|
isActive: loginToken.user.isActive,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
-- 钉钉登录一次性票据:替代固定自动登录密码
|
||||||
|
CREATE TABLE IF NOT EXISTS "dingtalk_login_tokens" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"purpose" TEXT NOT NULL DEFAULT 'login',
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "dingtalk_login_tokens_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "dingtalk_login_tokens_tokenHash_key"
|
||||||
|
ON "dingtalk_login_tokens"("tokenHash");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "dingtalk_login_tokens_userId_idx"
|
||||||
|
ON "dingtalk_login_tokens"("userId");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "dingtalk_login_tokens_purpose_idx"
|
||||||
|
ON "dingtalk_login_tokens"("purpose");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "dingtalk_login_tokens_expiresAt_idx"
|
||||||
|
ON "dingtalk_login_tokens"("expiresAt");
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'dingtalk_login_tokens_userId_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "dingtalk_login_tokens"
|
||||||
|
ADD CONSTRAINT "dingtalk_login_tokens_userId_fkey"
|
||||||
|
FOREIGN KEY ("userId") REFERENCES "users"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@ -40,11 +40,28 @@ model User {
|
|||||||
children User[] @relation("UserHierarchy")
|
children User[] @relation("UserHierarchy")
|
||||||
favorites MaterialFavorite[]
|
favorites MaterialFavorite[]
|
||||||
browseHistory BrowseHistory[]
|
browseHistory BrowseHistory[]
|
||||||
|
dingtalkLoginTokens DingtalkLoginToken[]
|
||||||
materialShareTokens MaterialShareToken[]
|
materialShareTokens MaterialShareToken[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DingtalkLoginToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tokenHash String @unique
|
||||||
|
userId String
|
||||||
|
purpose String @default("login")
|
||||||
|
expiresAt DateTime
|
||||||
|
usedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([purpose])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@map("dingtalk_login_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
model MaterialShareToken {
|
model MaterialShareToken {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tokenHash String @unique
|
tokenHash String @unique
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user