mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-25 13:51:40 +08:00
Add admin panel with member management and password reset
Features: - Admin panel at /settings/members for workspace owners - View all workspace members with roles and last login - Create new users directly (with temporary password) - Change member roles (Owner/Editor/Viewer) - Reset user passwords (forces change on next login) - Remove members from workspace - Force password reset flow on login - Track last login timestamp for users API Routes: - GET/POST /api/workspaces/[id]/members - GET/PATCH/DELETE /api/workspaces/[id]/members/[memberId] - POST /api/workspaces/[id]/members/[memberId]/reset-password - POST /api/auth/change-password Schema changes: - Added lastLoginAt DateTime? to User model - Added forcePasswordReset Boolean to User model Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { withAuth, type AuthenticatedRequest, hashPassword } from '@/lib/auth'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { z } from 'zod'
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
forceChange: z.boolean().default(true),
|
||||
})
|
||||
|
||||
// POST /api/workspaces/[id]/members/[memberId]/reset-password - Reset user password
|
||||
export const POST = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, memberId } = await params
|
||||
|
||||
// Check access (must be owner)
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access || access.role !== 'OWNER') {
|
||||
return NextResponse.json({ error: 'Only owners can reset passwords' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = resetPasswordSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { newPassword, forceChange } = result.data
|
||||
|
||||
// Get the member
|
||||
const member = await prisma.workspaceMember.findFirst({
|
||||
where: { id: memberId, workspaceId },
|
||||
include: { user: true },
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Hash new password and update user
|
||||
const passwordHash = await hashPassword(newPassword)
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: member.userId },
|
||||
data: {
|
||||
passwordHash,
|
||||
forcePasswordReset: forceChange,
|
||||
},
|
||||
})
|
||||
|
||||
// Invalidate all existing sessions for this user
|
||||
await prisma.session.deleteMany({
|
||||
where: { userId: member.userId },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: forceChange
|
||||
? 'Password reset. User must change password on next login.'
|
||||
: 'Password reset successfully.',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Reset password error:', error)
|
||||
return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
161
src/app/api/workspaces/[id]/members/[memberId]/route.ts
Normal file
161
src/app/api/workspaces/[id]/members/[memberId]/route.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { z } from 'zod'
|
||||
|
||||
// GET /api/workspaces/[id]/members/[memberId] - Get member details
|
||||
export const GET = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, memberId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const member = await prisma.workspaceMember.findFirst({
|
||||
where: { id: memberId, workspaceId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
lastLoginAt: true,
|
||||
forcePasswordReset: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ member })
|
||||
} catch (error) {
|
||||
console.error('Get member error:', error)
|
||||
return NextResponse.json({ error: 'Failed to get member' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
const updateMemberSchema = z.object({
|
||||
role: z.enum(['OWNER', 'EDITOR', 'VIEWER']).optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/workspaces/[id]/members/[memberId] - Update member role
|
||||
export const PATCH = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, memberId } = await params
|
||||
|
||||
// Check access (must be owner)
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access || access.role !== 'OWNER') {
|
||||
return NextResponse.json({ error: 'Only owners can update members' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = updateMemberSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { role } = result.data
|
||||
|
||||
// Get the member
|
||||
const member = await prisma.workspaceMember.findFirst({
|
||||
where: { id: memberId, workspaceId },
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Prevent changing own role
|
||||
if (member.userId === req.session.user.id) {
|
||||
return NextResponse.json({ error: 'Cannot change your own role' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Update member
|
||||
const updatedMember = await prisma.workspaceMember.update({
|
||||
where: { id: memberId },
|
||||
data: { role },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
lastLoginAt: true,
|
||||
forcePasswordReset: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
member: {
|
||||
id: updatedMember.id,
|
||||
role: updatedMember.role,
|
||||
joinedAt: updatedMember.createdAt,
|
||||
user: updatedMember.user,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update member error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update member' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/workspaces/[id]/members/[memberId] - Remove member from workspace
|
||||
export const DELETE = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, memberId } = await params
|
||||
|
||||
// Check access (must be owner)
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access || access.role !== 'OWNER') {
|
||||
return NextResponse.json({ error: 'Only owners can remove members' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get the member
|
||||
const member = await prisma.workspaceMember.findFirst({
|
||||
where: { id: memberId, workspaceId },
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Prevent removing self
|
||||
if (member.userId === req.session.user.id) {
|
||||
return NextResponse.json({ error: 'Cannot remove yourself from workspace' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Delete member
|
||||
await prisma.workspaceMember.delete({
|
||||
where: { id: memberId },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Remove member error:', error)
|
||||
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
197
src/app/api/workspaces/[id]/members/route.ts
Normal file
197
src/app/api/workspaces/[id]/members/route.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { withAuth, type AuthenticatedRequest, hashPassword } from '@/lib/auth'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { z } from 'zod'
|
||||
|
||||
// GET /api/workspaces/[id]/members - List all members
|
||||
export const GET = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
|
||||
// Check access (must be at least a member)
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const members = await prisma.workspaceMember.findMany({
|
||||
where: { workspaceId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
lastLoginAt: true,
|
||||
forcePasswordReset: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
members: members.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
joinedAt: m.createdAt,
|
||||
user: m.user,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('List members error:', error)
|
||||
return NextResponse.json({ error: 'Failed to list members' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
const createUserSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
role: z.enum(['OWNER', 'EDITOR', 'VIEWER']).default('VIEWER'),
|
||||
forcePasswordReset: z.boolean().default(true),
|
||||
})
|
||||
|
||||
// POST /api/workspaces/[id]/members - Create a new user and add to workspace
|
||||
export const POST = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
|
||||
// Check access (must be owner)
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access || access.role !== 'OWNER') {
|
||||
return NextResponse.json({ error: 'Only owners can create users' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = createUserSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { name, email, password, role, forcePasswordReset } = result.data
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
// Check if already a member
|
||||
const existingMember = await prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId: existingUser.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (existingMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is already a member of this workspace' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Add existing user to workspace
|
||||
const member = await prisma.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: existingUser.id,
|
||||
role,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
lastLoginAt: true,
|
||||
forcePasswordReset: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
member: {
|
||||
id: member.id,
|
||||
role: member.role,
|
||||
joinedAt: member.createdAt,
|
||||
user: member.user,
|
||||
},
|
||||
message: 'Existing user added to workspace',
|
||||
})
|
||||
}
|
||||
|
||||
// Create new user and add to workspace
|
||||
const passwordHash = await hashPassword(password)
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
passwordHash,
|
||||
forcePasswordReset,
|
||||
workspaceMembers: {
|
||||
create: {
|
||||
workspaceId,
|
||||
role,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
lastLoginAt: true,
|
||||
forcePasswordReset: true,
|
||||
createdAt: true,
|
||||
workspaceMembers: {
|
||||
where: { workspaceId },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const member = user.workspaceMembers[0]
|
||||
|
||||
return NextResponse.json({
|
||||
member: {
|
||||
id: member.id,
|
||||
role: member.role,
|
||||
joinedAt: member.createdAt,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
forcePasswordReset: user.forcePasswordReset,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
},
|
||||
message: 'User created and added to workspace',
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create user error:', error)
|
||||
return NextResponse.json({ error: 'Failed to create user' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user