Skip to content

Commit b9b89f9

Browse files
committed
checkpoint
1 parent fdd81d4 commit b9b89f9

48 files changed

Lines changed: 3329 additions & 4066 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { z } from 'zod'
4+
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
5+
import { getSession } from '@/lib/auth'
6+
import { acceptInvitation } from '@/lib/invitations/core'
7+
8+
const logger = createLogger('InvitationAcceptAPI')
9+
10+
const bodySchema = z.object({ token: z.string().min(1).optional() })
11+
12+
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
13+
const { id } = await params
14+
const session = await getSession()
15+
16+
if (!session?.user?.id || !session.user.email) {
17+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
18+
}
19+
20+
const body = await request.json().catch(() => ({}))
21+
const parsed = bodySchema.safeParse(body)
22+
if (!parsed.success) {
23+
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
24+
}
25+
26+
const result = await acceptInvitation({
27+
userId: session.user.id,
28+
userEmail: session.user.email,
29+
invitationId: id,
30+
token: parsed.data.token ?? null,
31+
})
32+
33+
if (!result.success) {
34+
const statusMap: Record<string, number> = {
35+
'not-found': 404,
36+
'invalid-token': 400,
37+
'already-processed': 400,
38+
expired: 400,
39+
'email-mismatch': 403,
40+
'already-in-organization': 409,
41+
'no-seats-available': 400,
42+
'server-error': 500,
43+
}
44+
const status = statusMap[result.kind] ?? 500
45+
logger.warn('Invitation accept rejected', { invitationId: id, reason: result.kind })
46+
return NextResponse.json({ error: result.kind }, { status })
47+
}
48+
49+
const inv = result.invitation
50+
51+
recordAudit({
52+
workspaceId: result.acceptedWorkspaceIds[0] ?? null,
53+
actorId: session.user.id,
54+
actorName: session.user.name ?? undefined,
55+
actorEmail: session.user.email ?? undefined,
56+
action:
57+
inv.kind === 'workspace'
58+
? AuditAction.INVITATION_ACCEPTED
59+
: AuditAction.ORG_INVITATION_ACCEPTED,
60+
resourceType:
61+
inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION,
62+
resourceId: inv.organizationId ?? result.acceptedWorkspaceIds[0] ?? inv.id,
63+
description: `Accepted ${inv.kind} invitation for ${inv.email}`,
64+
metadata: {
65+
invitationId: inv.id,
66+
targetEmail: inv.email,
67+
targetRole: inv.role,
68+
kind: inv.kind,
69+
workspaceIds: result.acceptedWorkspaceIds,
70+
},
71+
request,
72+
})
73+
74+
return NextResponse.json({
75+
success: true,
76+
redirectPath: result.redirectPath,
77+
invitation: {
78+
id: inv.id,
79+
kind: inv.kind,
80+
organizationId: inv.organizationId,
81+
acceptedWorkspaceIds: result.acceptedWorkspaceIds,
82+
},
83+
})
84+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { z } from 'zod'
4+
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
5+
import { getSession } from '@/lib/auth'
6+
import { rejectInvitation } from '@/lib/invitations/core'
7+
8+
const logger = createLogger('InvitationRejectAPI')
9+
10+
const bodySchema = z.object({ token: z.string().min(1).optional() })
11+
12+
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
13+
const { id } = await params
14+
const session = await getSession()
15+
16+
if (!session?.user?.id || !session.user.email) {
17+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
18+
}
19+
20+
const body = await request.json().catch(() => ({}))
21+
const parsed = bodySchema.safeParse(body)
22+
if (!parsed.success) {
23+
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
24+
}
25+
26+
const result = await rejectInvitation({
27+
userId: session.user.id,
28+
userEmail: session.user.email,
29+
invitationId: id,
30+
token: parsed.data.token ?? null,
31+
})
32+
33+
if (!result.success) {
34+
const statusMap: Record<string, number> = {
35+
'not-found': 404,
36+
'invalid-token': 400,
37+
'already-processed': 400,
38+
expired: 400,
39+
'email-mismatch': 403,
40+
}
41+
const status = statusMap[result.kind] ?? 500
42+
logger.warn('Invitation reject rejected', { invitationId: id, reason: result.kind })
43+
return NextResponse.json({ error: result.kind }, { status })
44+
}
45+
46+
const inv = result.invitation
47+
recordAudit({
48+
workspaceId: null,
49+
actorId: session.user.id,
50+
actorName: session.user.name ?? undefined,
51+
actorEmail: session.user.email ?? undefined,
52+
action:
53+
inv.kind === 'workspace'
54+
? AuditAction.INVITATION_REVOKED
55+
: AuditAction.ORG_INVITATION_REJECTED,
56+
resourceType:
57+
inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION,
58+
resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? inv.id,
59+
description: `Rejected ${inv.kind} invitation for ${inv.email}`,
60+
metadata: {
61+
invitationId: inv.id,
62+
targetEmail: inv.email,
63+
targetRole: inv.role,
64+
kind: inv.kind,
65+
},
66+
request,
67+
})
68+
69+
return NextResponse.json({ success: true })
70+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { db } from '@sim/db'
2+
import { member, user } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
7+
import { getSession } from '@/lib/auth'
8+
import { getInvitationById } from '@/lib/invitations/core'
9+
import { resendInvitationEmail, sendInvitationEmail } from '@/lib/invitations/send'
10+
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
11+
12+
const logger = createLogger('InvitationResendAPI')
13+
14+
async function isOrgAdmin(userId: string, organizationId: string): Promise<boolean> {
15+
const [row] = await db
16+
.select({ role: member.role })
17+
.from(member)
18+
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
19+
.limit(1)
20+
return row?.role === 'owner' || row?.role === 'admin'
21+
}
22+
23+
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
24+
const { id } = await params
25+
const session = await getSession()
26+
27+
if (!session?.user?.id) {
28+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
29+
}
30+
31+
try {
32+
const inv = await getInvitationById(id)
33+
if (!inv) {
34+
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
35+
}
36+
if (inv.status !== 'pending') {
37+
return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 })
38+
}
39+
40+
let canResend = false
41+
if (inv.organizationId) {
42+
canResend = await isOrgAdmin(session.user.id, inv.organizationId)
43+
}
44+
if (!canResend && inv.grants.length > 0) {
45+
const adminChecks = await Promise.all(
46+
inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId))
47+
)
48+
canResend = adminChecks.some(Boolean)
49+
}
50+
if (!canResend) {
51+
return NextResponse.json(
52+
{ error: 'Only an organization or workspace admin can resend this invitation' },
53+
{ status: 403 }
54+
)
55+
}
56+
57+
const { token } = await resendInvitationEmail({ invitationId: id, rotateToken: true })
58+
59+
const [inviterRow] = await db
60+
.select({ name: user.name, email: user.email })
61+
.from(user)
62+
.where(eq(user.id, session.user.id))
63+
.limit(1)
64+
65+
const emailResult = await sendInvitationEmail({
66+
invitationId: inv.id,
67+
token,
68+
kind: inv.kind,
69+
email: inv.email,
70+
inviterName: inviterRow?.name || inviterRow?.email || 'A user',
71+
organizationId: inv.organizationId,
72+
organizationRole: (inv.role as 'admin' | 'member') || 'member',
73+
grants: inv.grants.map((grant) => ({
74+
workspaceId: grant.workspaceId,
75+
permission: grant.permission,
76+
})),
77+
})
78+
79+
if (!emailResult.success) {
80+
return NextResponse.json(
81+
{ error: emailResult.error || 'Failed to send invitation email' },
82+
{ status: 502 }
83+
)
84+
}
85+
86+
recordAudit({
87+
workspaceId: inv.grants[0]?.workspaceId ?? null,
88+
actorId: session.user.id,
89+
actorName: session.user.name ?? undefined,
90+
actorEmail: session.user.email ?? undefined,
91+
action:
92+
inv.kind === 'workspace'
93+
? AuditAction.INVITATION_RESENT
94+
: AuditAction.ORG_INVITATION_RESENT,
95+
resourceType:
96+
inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION,
97+
resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? inv.id,
98+
description: `Resent ${inv.kind} invitation to ${inv.email}`,
99+
metadata: {
100+
invitationId: inv.id,
101+
targetEmail: inv.email,
102+
targetRole: inv.role,
103+
kind: inv.kind,
104+
},
105+
request,
106+
})
107+
108+
return NextResponse.json({ success: true })
109+
} catch (error) {
110+
logger.error('Failed to resend invitation', { invitationId: id, error })
111+
return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 })
112+
}
113+
}

0 commit comments

Comments
 (0)