diff --git a/app/api/domain-invites/[domainId]/route.ts b/app/api/domain-invites/[domainId]/route.ts new file mode 100644 index 00000000..9b4eaa1a --- /dev/null +++ b/app/api/domain-invites/[domainId]/route.ts @@ -0,0 +1,6 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function DELETE(_request: NextRequest, { params: _params }: { params: { domainId: string } }) { + // Return error since domain invites are not implemented yet + return NextResponse.json({ success: false, error: 'Domain invites are coming soon' }, { status: 501 }); +} diff --git a/app/api/domain-invites/route.ts b/app/api/domain-invites/route.ts new file mode 100644 index 00000000..efa8a2d4 --- /dev/null +++ b/app/api/domain-invites/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(_request: NextRequest) { + // Return empty array for now since domain invites are "Coming Soon" + return NextResponse.json({ success: true, domainInvites: [] }); +} + +export async function POST(_request: NextRequest) { + // Return error since domain invites are not implemented yet + return NextResponse.json({ success: false, error: 'Domain invites are coming soon' }, { status: 501 }); +} diff --git a/app/api/invites/[inviteId]/route.ts b/app/api/invites/[inviteId]/route.ts new file mode 100644 index 00000000..8a6892d3 --- /dev/null +++ b/app/api/invites/[inviteId]/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireUserAbility } from '~/auth/session'; +import { PermissionAction, PermissionResource } from '@prisma/client'; +import { inviteService } from '~/lib/services/inviteService'; +import { createScopedLogger } from '~/utils/logger'; + +const logger = createScopedLogger('api.invites'); + +export async function DELETE(request: NextRequest, { params }: { params: { inviteId: string } }) { + try { + const { userAbility } = await requireUserAbility(request); + + if (!userAbility.can(PermissionAction.delete, PermissionResource.AdminApp)) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); + } + + const { inviteId } = params; + + await inviteService.deleteInvite(inviteId); + + return NextResponse.json({ + success: true, + message: 'Invite removed successfully', + }); + } catch (error) { + logger.error('Error deleting invite:', error); + + if (error instanceof Error && error.message === 'Invite not found') { + return NextResponse.json({ success: false, error: 'Invite not found' }, { status: 404 }); + } + + return NextResponse.json({ success: false, error: 'Failed to remove invite' }, { status: 500 }); + } +} diff --git a/app/api/invites/accept/route.ts b/app/api/invites/accept/route.ts new file mode 100644 index 00000000..d7d625ec --- /dev/null +++ b/app/api/invites/accept/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { requireUserAbility } from '~/auth/session'; +import { inviteService } from '~/lib/services/inviteService'; +import { createScopedLogger } from '~/utils/logger'; + +const logger = createScopedLogger('api.invites'); + +const acceptInviteSchema = z.object({ + inviteId: z.string().min(1, 'Invite ID is required'), +}); + +export async function POST(request: NextRequest) { + try { + const { userId } = await requireUserAbility(request); + + const body = await request.json(); + const parsedBody = acceptInviteSchema.safeParse(body); + + if (!parsedBody.success) { + return NextResponse.json({ success: false, error: parsedBody.error.flatten().fieldErrors }, { status: 400 }); + } + + const { inviteId } = parsedBody.data; + + await inviteService.acceptInvite(inviteId, userId); + + return NextResponse.json({ + success: true, + message: 'Invite accepted successfully', + }); + } catch (error) { + logger.error('Error accepting invite:', error); + + if (error instanceof Error) { + if ( + error.message.includes('not found') || + error.message.includes('no longer valid') || + error.message.includes('expired') || + error.message.includes('does not match') + ) { + return NextResponse.json({ success: false, error: error.message }, { status: 400 }); + } + } + + return NextResponse.json({ success: false, error: 'Failed to accept invite' }, { status: 500 }); + } +} diff --git a/app/api/invites/route.ts b/app/api/invites/route.ts new file mode 100644 index 00000000..28d23fcf --- /dev/null +++ b/app/api/invites/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { requireUserAbility } from '~/auth/session'; +import { PermissionAction, PermissionResource } from '@prisma/client'; +import { inviteService } from '~/lib/services/inviteService'; +import { InviteStatus } from '@prisma/client'; +import { createScopedLogger } from '~/utils/logger'; + +const logger = createScopedLogger('api.invites'); + +const createInviteSchema = z.object({ + email: z.string().email('Invalid email address'), + roleId: z.string().min(1, 'Role ID is required'), +}); + +export async function GET(request: NextRequest) { + try { + const { userAbility } = await requireUserAbility(request); + + if (!userAbility.can(PermissionAction.read, PermissionResource.AdminApp)) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const status = searchParams.get('status') as InviteStatus; + + if (status && !Object.values(InviteStatus).includes(status)) { + return NextResponse.json({ success: false, error: 'Invalid status' }, { status: 400 }); + } + + const invites = status + ? await inviteService.getInvitesByStatus(status) + : await inviteService.getInvitesByStatus(InviteStatus.PENDING); + + return NextResponse.json({ success: true, invites }); + } catch (error) { + logger.error('Error fetching invites:', error); + return NextResponse.json({ success: false, error: 'Failed to fetch invites' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const { userAbility, userId } = await requireUserAbility(request); + + if (!userAbility.can(PermissionAction.create, PermissionResource.AdminApp)) { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsedBody = createInviteSchema.safeParse(body); + + if (!parsedBody.success) { + return NextResponse.json({ success: false, error: parsedBody.error.flatten().fieldErrors }, { status: 400 }); + } + + const { email, roleId } = parsedBody.data; + + const invite = await inviteService.createInvite(email, roleId, userId); + + return NextResponse.json({ + success: true, + invite, + message: 'Invitation sent successfully', + }); + } catch (error) { + logger.error('Error creating invite:', error); + + if (error instanceof Error) { + if (error.message.includes('already exists') || error.message.includes('already a pending invite')) { + return NextResponse.json({ success: false, error: error.message }, { status: 400 }); + } + } + + return NextResponse.json({ success: false, error: 'Failed to create invite' }, { status: 500 }); + } +} diff --git a/app/api/user/route.ts b/app/api/user/route.ts index 5b6ec9df..feea9956 100644 --- a/app/api/user/route.ts +++ b/app/api/user/route.ts @@ -2,14 +2,15 @@ import { PermissionAction, PermissionResource } from '@prisma/client'; import { NextRequest, NextResponse } from 'next/server'; import { requireUserAbility } from '~/auth/session'; import { userService } from '~/lib/services/userService'; +import { z } from 'zod'; import { createScopedLogger } from '~/utils/logger'; const logger = createScopedLogger('user-api'); -type UpdateRoleBody = { - userId: string; - role: 'ADMIN' | 'MEMBER'; -}; +const updateRoleSchema = z.object({ + userId: z.string().min(1, 'User ID is required'), + roleId: z.string().min(1, 'Role ID is required'), +}); export async function GET(request: NextRequest) { try { @@ -19,9 +20,19 @@ export async function GET(request: NextRequest) { return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); } - const users = await userService.getAllUsers(); + const [usersWithRoles, usersWithoutRoles] = await Promise.all([ + userService.getAllUsersWithRoles(), + userService.getAllUsersWithoutRoles(), + ]); - return NextResponse.json({ success: true, users }, { status: 200 }); + return NextResponse.json( + { + success: true, + users: usersWithRoles, + usersWithoutRoles, + }, + { status: 200 }, + ); } catch (error) { logger.error('Failed to fetch users:', error); return NextResponse.json({ success: false, error: 'Failed to fetch users' }, { status: 500 }); @@ -31,13 +42,20 @@ export async function GET(request: NextRequest) { export async function PATCH(request: NextRequest) { try { const { userAbility } = await requireUserAbility(request); - const body = (await request.json()) as UpdateRoleBody; + const body = await request.json(); + const parsedBody = updateRoleSchema.safeParse(body); + + if (!parsedBody.success) { + return NextResponse.json({ success: false, error: parsedBody.error.flatten().fieldErrors }, { status: 400 }); + } if (!userAbility.can(PermissionAction.manage, PermissionResource.AdminApp)) { return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); } - const targetUser = await userService.updateUserRole(body.userId, body.role); + const { userId, roleId } = parsedBody.data; + + const targetUser = await userService.updateUserRoleToNewSystem(userId, roleId); return NextResponse.json({ success: true, user: targetUser }); } catch (error) { diff --git a/app/auth/auth-config.ts b/app/auth/auth-config.ts index 21c3371d..0bb43d5a 100644 --- a/app/auth/auth-config.ts +++ b/app/auth/auth-config.ts @@ -4,6 +4,7 @@ import { prisma } from '~/lib/prisma'; import { env } from '~/env'; import { createAuthMiddleware } from 'better-auth/plugins'; import { userService } from '~/lib/services/userService'; +import { inviteService } from '~/lib/services/inviteService'; const { BASE_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, LICENSE_KEY } = env.server; @@ -121,6 +122,19 @@ export const auth = betterAuth({ await userService.grantSystemAdminAccess(createdUser.id); } }); + + // Check for pending invites and auto-accept them + try { + const pendingInvite = await inviteService.getInviteByEmail(email); + + if (pendingInvite) { + await inviteService.acceptInvite(pendingInvite.id, createdUser.id); + console.log(`✅ Auto-accepted invite for user ${email}`); + } + } catch (error) { + console.error(`❌ Failed to auto-accept invite for user ${email}:`, error); + // Don't fail the auth flow if invite acceptance fails + } }), }, }); diff --git a/app/components/@settings/tabs/users/UsersDetails.tsx b/app/components/@settings/tabs/users/UsersDetails.tsx new file mode 100644 index 00000000..839e4b6f --- /dev/null +++ b/app/components/@settings/tabs/users/UsersDetails.tsx @@ -0,0 +1,550 @@ +import { useEffect, useState } from 'react'; +import * as ToggleGroup from '@radix-ui/react-toggle-group'; +import { Mail, Globe, UserPlus, Trash2, CircleX } from 'lucide-react'; +import { toast } from 'sonner'; +import { resetControlPanelHeader, setControlPanelHeader } from '~/lib/stores/settings'; +import { Dialog, DialogClose, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; +import { classNames } from '~/utils/classNames'; +import { format } from 'date-fns'; +import { isValidEmail } from '~/lib/utils/invite-utils'; +import { Badge } from '~/components/ui/Badge'; +import { Input } from '~/components/ui/Input'; +import { Label } from '~/components/ui/Label'; + +interface Role { + id: string; + name: string; + description: string; +} + +interface MemberInvite { + id: string; + email: string; + roleId: string; + roleName: string; + status: 'PENDING' | 'ACCEPTED' | 'EXPIRED'; + createdAt: string; +} + +interface DomainInvite { + id: string; + domain: string; + defaultRoleId: string; + defaultRoleName: string; + status: 'ACTIVE' | 'INACTIVE'; + createdAt: string; +} + +const USER_TABS = ['Invite Members', 'Domain Invites', 'Pending Invites']; + +interface MembersDetailsProps { + onBack(): void; +} + +export default function MembersDetails({ onBack }: MembersDetailsProps) { + const [activeTab, setActiveTab] = useState('Invite Members'); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [inviteToDelete, setInviteToDelete] = useState(null); + + // Roles state + const [roles, setRoles] = useState([]); + const [isLoadingRoles, setIsLoadingRoles] = useState(false); + + // Invite specific users state + const [inviteEmails, setInviteEmails] = useState(new Set()); + const [inviteEmail, setInviteEmail] = useState(''); + const [addEmailMessage, setAddEmailMessage] = useState(''); + const [inviteRoleId, setInviteRoleId] = useState(''); + const [isInviting, setIsInviting] = useState(false); + + // Domain invite state + // const [domainEmail, setDomainEmail] = useState(''); + // const [domainDefaultRoleId, setDomainDefaultRoleId] = useState(''); + // const [setIsAddingDomain] = useState(false); + + // Real data from API + const [pendingInvites, setPendingInvites] = useState([]); + const [domainInvites, setDomainInvites] = useState([]); + + useEffect(() => { + setControlPanelHeader({ + title: 'Invite Members', + onBack, + }); + + // Fetch available roles and invites + fetchRoles(); + fetchInvites(); + fetchDomainInvites(); + + return () => { + resetControlPanelHeader(); + }; + }, []); // Remove onBack dependency to prevent infinite loop + + const fetchRoles = async () => { + try { + setIsLoadingRoles(true); + + const response = await fetch('/api/roles'); + const data = (await response.json()) as { success: boolean; roles?: Role[] }; + + if (data.success && data.roles) { + setRoles(data.roles); + + // Set default role if available + if (data.roles.length > 0) { + setInviteRoleId(data.roles[0].id); + //setDomainDefaultRoleId(data.roles[0].id); + } + } + } catch (error) { + console.error('Error fetching roles:', error); + toast.error('Failed to fetch roles'); + } finally { + setIsLoadingRoles(false); + } + }; + + const fetchInvites = async () => { + try { + const response = await fetch('/api/invites?status=PENDING'); + const data = (await response.json()) as { success: boolean; invites?: MemberInvite[] }; + + if (data.success && data.invites) { + setPendingInvites(data.invites); + } + } catch (error) { + console.error('Error fetching invites:', error); + toast.error('Failed to fetch invites'); + } + }; + + const fetchDomainInvites = async () => { + try { + const response = await fetch('/api/domain-invites'); + const data = (await response.json()) as { success: boolean; domainInvites?: DomainInvite[] }; + + if (data.success && data.domainInvites) { + setDomainInvites(data.domainInvites); + } + } catch (error) { + console.error('Error fetching domain invites:', error); + toast.error('Failed to fetch domain invites'); + } + }; + + const handleAddEmail = () => { + const trimmedEmail = inviteEmail.trim(); + + if (!trimmedEmail) { + return; + } + + if (!isValidEmail(trimmedEmail)) { + setAddEmailMessage('Please enter a valid email address.'); + return; + } + + setInviteEmails((prev) => new Set(prev).add(trimmedEmail)); + setInviteEmail(''); + setAddEmailMessage(''); + }; + + const getTabComponent = (tab: string) => { + switch (tab) { + case 'Invite Members': + return ( +
+
+

Invite Specific Members

+
+
+ + ) => { + setInviteEmail(e.target.value); + + if (addEmailMessage) { + setAddEmailMessage(''); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddEmail(); + } + }} + placeholder="Enter email and press enter..." + /> + {addEmailMessage &&

{addEmailMessage}

} +
+ +
+ {Array.from(inviteEmails).map((email) => ( + + {email} + { + setInviteEmails((prev) => { + const newSet = new Set(prev); + newSet.delete(email); + + return newSet; + }); + }} + /> + + ))} +
+ +
+ + +
+ +
+ +
+
+
+
+ ); + case 'Domain Invites': + return ( +
+
+
+
+ +
+
+

Domain Invites

+

+ Invite users by domain to automatically grant access to anyone with an email from your organization. +

+
+ Coming Soon +
+
+
+
+
+ ); + case 'Pending Invites': + return ( +
+

Pending Member Invites

+ {pendingInvites.map((invite) => ( +
+
+ +
+
{invite.email}
+
+ Role: {invite.roleName} • Sent {format(new Date(invite.createdAt), 'PP')} +
+
+
+ +
+ ))} +
+ ); + default: + return null; + } + }; + + const handleInviteMember = async () => { + if (inviteEmails.size === 0 || !inviteRoleId) { + return; + } + + try { + setIsInviting(true); + + // Send invites for all emails + const invitePromises = Array.from(inviteEmails).map((email) => + fetch('/api/invites', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email, + roleId: inviteRoleId, + }), + }) + .then(async (response) => { + const data = (await response.json()) as { success: boolean; message?: string; error?: string }; + + if (!data.success) { + throw new Error(data.error || `Failed to invite ${email}`); + } + + return { email, success: true }; + }) + .catch((error) => { + console.error(`Error inviting ${email}:`, error); + return { email, success: false, error: error.message }; + }), + ); + + const results = await Promise.all(invitePromises); + + const successful = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + + if (successful.length > 0) { + setInviteEmails(new Set()); + toast.success(`${successful.length} invitation${successful.length !== 1 ? 's' : ''} sent successfully`); + // Refresh the invites list + fetchInvites(); + } + + if (failed.length > 0) { + toast.error(`${failed.length} invitation${failed.length !== 1 ? 's' : ''} failed to send`); + } + } catch (error) { + console.error('Error sending invites:', error); + toast.error('Failed to send invites'); + } finally { + setIsInviting(false); + } + }; + + // const handleAddDomain = async () => { + // if (!domainEmail.trim() || !domainDefaultRoleId) { + // return; + // } + + // try { + // setIsAddingDomain(true); + + // const response = await fetch('/api/domain-invites', { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // }, + // body: JSON.stringify({ + // domain: domainEmail, + // defaultRoleId: domainDefaultRoleId, + // }), + // }); + + // const data = (await response.json()) as { success: boolean; message?: string; error?: string }; + + // if (data.success) { + // setDomainEmail(''); + // toast.success(data.message || 'Domain invite created successfully'); + // // Refresh the domain invites list + // fetchDomainInvites(); + // } else { + // toast.error(data.error || 'Failed to add domain'); + // } + // } catch (error) { + // console.error('Error adding domain:', error); + // toast.error('Failed to add domain'); + // } finally { + // setIsAddingDomain(false); + // } + // }; + + const handleDeleteInvite = (invite: MemberInvite | DomainInvite) => { + setInviteToDelete(invite); + setShowDeleteConfirm(true); + }; + + const confirmDelete = async () => { + if (!inviteToDelete) { + return; + } + + try { + if ('domain' in inviteToDelete) { + // Delete domain invite + const response = await fetch(`/api/domain-invites/${inviteToDelete.id}`, { + method: 'DELETE', + }); + + const data = (await response.json()) as { success: boolean; message?: string; error?: string }; + + if (data.success) { + setDomainInvites(domainInvites.filter((d) => d.id !== inviteToDelete.id)); + toast.success(data.message || 'Domain invite removed successfully'); + } else { + toast.error(data.error || 'Failed to remove domain invite'); + } + } else { + // Delete user invite + const response = await fetch(`/api/invites/${inviteToDelete.id}`, { + method: 'DELETE', + }); + + const data = (await response.json()) as { success: boolean; message?: string; error?: string }; + + if (data.success) { + setPendingInvites(pendingInvites.filter((i) => i.id !== inviteToDelete.id)); + toast.success(data.message || 'Member invite removed successfully'); + } else { + toast.error(data.error || 'Failed to remove user invite'); + } + } + } catch (error) { + console.error('Error removing invite:', error); + toast.error('Failed to remove invite'); + } finally { + setShowDeleteConfirm(false); + setInviteToDelete(null); + } + }; + + return ( +
+
+ { + if (value) { + setActiveTab(value); + } + }} + aria-label="Member management tabs" + > + {USER_TABS.map((tab) => ( + + {tab === 'Invite Members' && } + {tab === 'Domain Invites' && } + {tab === 'Pending Invites' && } + {tab} + + ))} + +
+ +
{getTabComponent(activeTab)}
+ + + +
+
+
+
+
+ +
+
+ +
+
+
+ +
+

+ Are you sure you want to remove this invite? This action cannot be undone. +

+ {inviteToDelete && ( +
+

+ {'domain' in inviteToDelete + ? `Domain: ${inviteToDelete.domain}` + : `Email: ${inviteToDelete.email}`} +

+
+ )} +
+ +
+ + + + +
+
+
+
+
+
+ ); +} diff --git a/app/components/@settings/tabs/users/UsersTab.tsx b/app/components/@settings/tabs/users/UsersTab.tsx index cb77cc50..c822d9e1 100644 --- a/app/components/@settings/tabs/users/UsersTab.tsx +++ b/app/components/@settings/tabs/users/UsersTab.tsx @@ -1,28 +1,53 @@ -import { useState, useEffect, useMemo } from 'react'; -import { ChevronDown, Check, Search } from 'lucide-react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { ChevronDown, Check, Search, Filter, X, Plus, ChevronRight, Mail, Trash2, Clock } from 'lucide-react'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as Tooltip from '@radix-ui/react-tooltip'; import { toast } from 'sonner'; import { useUserStore } from '~/lib/stores/user'; +import UsersDetails from './UsersDetails'; +import { classNames } from '~/utils/classNames'; + +interface Role { + id: string; + name: string; + description: string; +} interface User { id: string; email: string; name: string; - role: 'ADMIN' | 'MEMBER'; + roleId: string; + roleName: string; +} + +interface PendingInvite { + id: string; + email: string; + roleId: string; + roleName: string; + status: string; + createdAt: string; + expiresAt: string; + acceptedAt?: string | null; + invitedBy: string; } type LoaderData = { users: User[]; + usersWithoutRoles: User[]; }; interface RoleDropdownProps { - value: 'ADMIN' | 'MEMBER'; - onChange: (role: 'ADMIN' | 'MEMBER') => void; + value: string; + onChange: (roleId: string) => void; triggerClassName?: string; + roles: Role[]; } -function RoleDropdown({ value, onChange, triggerClassName }: RoleDropdownProps) { +function RoleDropdown({ value, onChange, triggerClassName, roles }: RoleDropdownProps) { + const currentRole = roles.find((role) => role.id === value); + return ( @@ -32,7 +57,7 @@ function RoleDropdown({ value, onChange, triggerClassName }: RoleDropdownProps) 'px-3 py-1.5 text-sm rounded-lg bg-gray-600/70 text-white hover:bg-gray-600 transition-colors flex items-center gap-2 min-w-[102px] justify-between' } > - {value === 'ADMIN' ? 'Admin' : 'User'} + {currentRole?.name || 'Select Role'} @@ -43,15 +68,15 @@ function RoleDropdown({ value, onChange, triggerClassName }: RoleDropdownProps) sideOffset={5} align="end" > -
- + {roles.map((role) => ( + onChange('ADMIN')} + onSelect={() => onChange(role.id)} > - Admin - {value === 'ADMIN' && } + {role.name} + {value === role.id && } @@ -60,34 +85,12 @@ function RoleDropdown({ value, onChange, triggerClassName }: RoleDropdownProps) side="left" sideOffset={5} > - Admin – manages team, billing, and access + {role.description || `${role.name} role`} - - - - onChange('MEMBER')} - > - User - {value === 'MEMBER' && } - - - - - User – views and uses apps - - - - -
+ ))}
@@ -99,8 +102,15 @@ export default function UsersTab() { const currentUserId = user?.id; const [searchQuery, setSearchQuery] = useState(''); const [users, setUsers] = useState([]); + const [usersWithoutRoles, setUsersWithoutRoles] = useState([]); + const [pendingInvites, setPendingInvites] = useState([]); const [isLoading, setIsLoading] = useState(false); const [updatingUserId, setUpdatingUserId] = useState(null); + const [activeFilters, setActiveFilters] = useState([]); + const [showFilterDropdown, setShowFilterDropdown] = useState(false); + const [showDetails, setShowDetails] = useState(false); + const [roles, setRoles] = useState([]); + const [showUsersWithoutRoles, setShowUsersWithoutRoles] = useState(false); useEffect(() => { const fetchUsers = async () => { @@ -113,6 +123,10 @@ export default function UsersTab() { if (data.users) { setUsers(data.users); } + + if (data.usersWithoutRoles) { + setUsersWithoutRoles(data.usersWithoutRoles); + } } catch (error) { console.error('Error fetching users:', error); toast.error('Failed to fetch users'); @@ -121,10 +135,40 @@ export default function UsersTab() { } }; + const fetchRoles = async () => { + try { + const response = await fetch('/api/roles'); + const data = (await response.json()) as { success: boolean; roles?: Role[] }; + + if (data.success && data.roles) { + setRoles(data.roles); + } + } catch (error) { + console.error('Error fetching roles:', error); + toast.error('Failed to fetch roles'); + } + }; + + const fetchPendingInvites = async () => { + try { + const response = await fetch('/api/invites?status=PENDING'); + const data = (await response.json()) as { success: boolean; invites?: PendingInvite[] }; + + if (data.success && data.invites) { + setPendingInvites(data.invites); + } + } catch (error) { + console.error('Error fetching pending invites:', error); + toast.error('Failed to fetch pending invites'); + } + }; + fetchUsers(); + fetchRoles(); + fetchPendingInvites(); }, []); - const handleRoleChange = async (userId: string, newRole: 'ADMIN' | 'MEMBER') => { + const handleRoleChange = async (userId: string, newRoleId: string) => { try { setUpdatingUserId(userId); @@ -133,14 +177,34 @@ export default function UsersTab() { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ userId, role: newRole }), + body: JSON.stringify({ userId, roleId: newRoleId }), }); const data = (await response.json()) as { success: boolean; error?: string }; if (data.success) { // Update the user's role in the local state - setUsers((prevUsers) => prevUsers.map((user) => (user.id === userId ? { ...user, role: newRole } : user))); + const newRole = roles.find((role) => role.id === newRoleId); + + // Check if user was in the "without roles" list + const userWithoutRole = usersWithoutRoles.find((user) => user.id === userId); + + if (userWithoutRole) { + // Move user from "without roles" to "with roles" list + setUsersWithoutRoles((prev) => prev.filter((user) => user.id !== userId)); + setUsers((prev) => [ + ...prev, + { ...userWithoutRole, roleId: newRoleId, roleName: newRole?.name || 'Unknown Role' }, + ]); + } else { + // Update existing user in "with roles" list + setUsers((prevUsers) => + prevUsers.map((user) => + user.id === userId ? { ...user, roleId: newRoleId, roleName: newRole?.name || 'Unknown Role' } : user, + ), + ); + } + toast.success('User role updated successfully'); } else { toast.error(data.error || 'Failed to update user role'); @@ -153,15 +217,94 @@ export default function UsersTab() { } }; - const filteredUsers = useMemo( - () => - users.filter( - (user) => - user.name.toLowerCase().includes(searchQuery.toLowerCase()) || - user.email.toLowerCase().includes(searchQuery.toLowerCase()), - ), - [users, searchQuery], - ); + const addFilter = (filter: string) => { + if (!activeFilters.includes(filter)) { + setActiveFilters([...activeFilters, filter]); + } + + setShowFilterDropdown(false); + }; + + const removeFilter = (filter: string) => { + setActiveFilters(activeFilters.filter((f) => f !== filter)); + }; + + const clearAllFilters = () => { + setActiveFilters([]); + }; + + const handleDeleteInvite = async (inviteId: string) => { + try { + const response = await fetch(`/api/invites/${inviteId}`, { + method: 'DELETE', + }); + + const data = (await response.json()) as { success: boolean; error?: string }; + + if (data.success) { + setPendingInvites((prev) => prev.filter((invite) => invite.id !== inviteId)); + toast.success('Invite removed successfully'); + } else { + toast.error(data.error || 'Failed to remove invite'); + } + } catch (error) { + console.error('Error deleting invite:', error); + toast.error('Failed to remove invite'); + } + }; + + const formatExpirationDate = (expiresAt: string) => { + const date = new Date(expiresAt); + const now = new Date(); + const diffInHours = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60)); + + if (diffInHours <= 0) { + return 'Expired'; + } else if (diffInHours < 24) { + return `${diffInHours}h left`; + } else { + const diffInDays = Math.ceil(diffInHours / 24); + return `${diffInDays}d left`; + } + }; + + const filteredUsers = useMemo(() => { + // Combine users and pending invites into a single list + const allMembers = [ + ...users.map((user) => ({ ...user, type: 'user' as const })), + ...pendingInvites.map((invite) => ({ + id: invite.id, + email: invite.email, + name: invite.email, // Use email as name for invites + roleId: invite.roleId, + roleName: invite.roleName, + type: 'invite' as const, + expiresAt: invite.expiresAt, + })), + ]; + + return allMembers.filter((member) => { + const matchesSearch = + member.name.toLowerCase().includes(searchQuery.toLowerCase()) || + member.email.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesFilters = + activeFilters.length === 0 || + activeFilters.some((filter) => { + return member.roleName === filter; + }); + + return matchesSearch && matchesFilters; + }); + }, [users, pendingInvites, searchQuery, activeFilters]); + + const handleBack = useCallback(() => { + setShowDetails(false); + }, []); + + if (showDetails) { + return ; + } if (isLoading) { return ( @@ -180,24 +323,93 @@ export default function UsersTab() { return (
-
-

Members

- {filteredUsers.length} +
+
+

Members

+ + {filteredUsers.length} + {pendingInvites.length > 0 && ( + ({pendingInvites.length} pending) + )} + +
+
-
- +
+
+
+ +
+ setSearchQuery(e.target.value)} + className="w-full h-9 pl-10 pr-2.5 py-2 rounded-[50px] bg-gray-600/50 text-white placeholder-gray-400 focus:outline-none" + placeholder="Search..." + /> +
+ + + + + + + + + {roles.map((role) => ( + addFilter(role.name)} + > + {role.name} + + ))} + + +
- setSearchQuery(e.target.value)} - className="w-[566px] h-9 pl-10 pr-2.5 py-2 rounded-[50px] bg-gray-600/50 text-white placeholder-gray-400 focus:outline-none" - placeholder="Search..." - />
+ {/* Active Filters */} + {activeFilters.length > 0 && ( +
+ {activeFilters.map((filter) => ( + + {filter} + + + ))} + +
+ )} +
User @@ -205,29 +417,119 @@ export default function UsersTab() {
- {filteredUsers.map((user) => ( -
+ {filteredUsers.map((member) => ( +
-
{user.name}
-
{user.email}
+
+ {member.type === 'invite' && } + {member.name} +
+
+ {member.type === 'invite' && ( + <> + + {formatExpirationDate(member.expiresAt)} + + )} + {member.type === 'user' && member.email} +
-
- {user.id !== currentUserId ? ( - handleRoleChange(user.id, newRole)} - triggerClassName={ - updatingUserId === user.id - ? 'px-3 py-1.5 text-sm rounded-lg bg-gray-600/70 text-white hover:bg-gray-600 transition-colors flex items-center gap-2 min-w-[102px] justify-between opacity-50 cursor-not-allowed' - : undefined - } - /> +
+ {member.type === 'invite' ? ( + <> + {member.roleName} + + + + + + + Remove invite + + + + + ) : ( - {user.role === 'ADMIN' ? 'Admin' : 'User'} + <> + {member.id !== currentUserId ? ( + handleRoleChange(member.id, newRoleId)} + roles={roles} + triggerClassName={ + updatingUserId === member.id + ? 'px-3 py-1.5 text-sm rounded-lg bg-gray-600/70 text-white hover:bg-gray-600 transition-colors flex items-center gap-2 min-w-[102px] justify-between opacity-50 cursor-not-allowed' + : undefined + } + /> + ) : ( + {member.roleName} + )} + )}
))} + + {/* Users Without Roles - Collapsible Section */} + {usersWithoutRoles.length > 0 && ( +
+ + + {showUsersWithoutRoles && ( +
+ {usersWithoutRoles.map((user) => ( +
+
+
{user.name}
+
{user.email}
+
+
+ {user.id !== currentUserId ? ( + handleRoleChange(user.id, newRoleId)} + roles={roles} + triggerClassName={ + updatingUserId === user.id + ? 'px-3 py-1.5 text-sm rounded-lg bg-gray-600/70 text-white hover:bg-gray-600 transition-colors flex items-center gap-2 min-w-[102px] justify-between opacity-50 cursor-not-allowed' + : 'px-3 py-1.5 text-sm rounded-lg bg-orange-500/70 text-white hover:bg-orange-500 transition-colors flex items-center gap-2 min-w-[102px] justify-between' + } + /> + ) : ( + No Role + )} +
+
+ ))} +
+ )} +
+ )}
diff --git a/app/lib/services/inviteService.ts b/app/lib/services/inviteService.ts new file mode 100644 index 00000000..51c8bed6 --- /dev/null +++ b/app/lib/services/inviteService.ts @@ -0,0 +1,243 @@ +import { prisma } from '~/lib/prisma'; +import { InviteStatus } from '@prisma/client'; +import { v4 as uuidv4 } from 'uuid'; + +export interface UserInviteData { + id: string; + email: string; + roleId: string; + roleName: string; + status: InviteStatus; + createdAt: Date; + expiresAt: Date; + acceptedAt?: Date | null; + invitedBy: string; +} + +export const inviteService = { + async createInvite(email: string, roleId: string, invitedBy: string): Promise { + // Check if user already exists and has the role + const existingUser = await prisma.user.findUnique({ + where: { email }, + include: { + roles: { + include: { + role: true, + }, + }, + }, + }); + + if (existingUser) { + // Check if user already has this role + const hasRole = existingUser.roles.some((userRole) => userRole.role.id === roleId); + + if (hasRole) { + throw new Error('User already has this role'); + } + } + + // Check if there's already a pending invite for this email + const existingInvite = await prisma.userInvite.findFirst({ + where: { + email, + status: InviteStatus.PENDING, + }, + }); + + if (existingInvite) { + throw new Error('A pending invite already exists for this email'); + } + + // Get role information + const role = await prisma.role.findUnique({ + where: { id: roleId }, + }); + + if (!role) { + throw new Error('Role not found'); + } + + // Create invite with 7 days expiration + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + const invite = await prisma.userInvite.create({ + data: { + id: uuidv4(), + email, + roleId, + invitedBy, + expiresAt, + status: InviteStatus.PENDING, + updatedAt: new Date(), + }, + include: { + role: true, + }, + }); + + return { + id: invite.id, + email: invite.email, + roleId: invite.roleId, + roleName: role.name, + status: invite.status, + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + acceptedAt: invite.acceptedAt, + invitedBy: invite.invitedBy, + }; + }, + + async getInvitesByStatus(status: InviteStatus): Promise { + const invites = await prisma.userInvite.findMany({ + where: { status }, + include: { + role: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + return invites.map((invite) => ({ + id: invite.id, + email: invite.email, + roleId: invite.roleId, + roleName: invite.role.name, + status: invite.status, + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + acceptedAt: invite.acceptedAt, + invitedBy: invite.invitedBy, + })); + }, + + async getInviteByEmail(email: string): Promise { + const invite = await prisma.userInvite.findFirst({ + where: { + email, + status: InviteStatus.PENDING, + }, + include: { + role: true, + }, + }); + + if (!invite) { + return null; + } + + return { + id: invite.id, + email: invite.email, + roleId: invite.roleId, + roleName: invite.role.name, + status: invite.status, + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + acceptedAt: invite.acceptedAt, + invitedBy: invite.invitedBy, + }; + }, + + async acceptInvite(inviteId: string, userId: string): Promise { + const invite = await prisma.userInvite.findUnique({ + where: { id: inviteId }, + }); + + if (!invite) { + throw new Error('Invite not found'); + } + + if (invite.status !== InviteStatus.PENDING) { + throw new Error('Invite is no longer valid'); + } + + if (invite.expiresAt < new Date()) { + throw new Error('Invite has expired'); + } + + // Check if user exists + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new Error('User not found'); + } + + // Check if user email matches invite email + if (user.email !== invite.email) { + throw new Error('User email does not match invite email'); + } + + // Check if user already has this role + const existingUserRole = await prisma.userRole.findUnique({ + where: { + userId_roleId: { + userId, + roleId: invite.roleId, + }, + }, + }); + + if (existingUserRole) { + // User already has this role, just mark invite as accepted + await prisma.userInvite.update({ + where: { id: inviteId }, + data: { + status: InviteStatus.ACCEPTED, + acceptedAt: new Date(), + existingUserId: userId, + }, + }); + return; + } + + // Add user to role + await prisma.$transaction([ + prisma.userRole.create({ + data: { + userId, + roleId: invite.roleId, + }, + }), + prisma.userInvite.update({ + where: { id: inviteId }, + data: { + status: InviteStatus.ACCEPTED, + acceptedAt: new Date(), + existingUserId: userId, + }, + }), + ]); + }, + + async deleteInvite(inviteId: string): Promise { + const invite = await prisma.userInvite.findUnique({ + where: { id: inviteId }, + }); + + if (!invite) { + throw new Error('Invite not found'); + } + + await prisma.userInvite.delete({ + where: { id: inviteId }, + }); + }, + + async expireInvites(): Promise { + await prisma.userInvite.updateMany({ + where: { + status: InviteStatus.PENDING, + expiresAt: { + lt: new Date(), + }, + }, + data: { + status: InviteStatus.EXPIRED, + }, + }); + }, +}; diff --git a/app/lib/services/userService.ts b/app/lib/services/userService.ts index 65280ac8..8f10e70b 100644 --- a/app/lib/services/userService.ts +++ b/app/lib/services/userService.ts @@ -1,5 +1,6 @@ import { prisma } from '~/lib/prisma'; import { DeprecatedRole } from '@prisma/client'; +import type { User } from '@prisma/client'; import { createScopedLogger } from '~/utils/logger'; const logger = createScopedLogger('user-service'); @@ -11,123 +12,147 @@ export interface UserProfile { image?: string | null; role?: DeprecatedRole; telemetryEnabled?: boolean | null; + roles?: UserRole[]; +} + +export interface UserRole { + id: string; + name: string; + description?: string | null; +} + +export interface UserWithRole { + id: string; + name: string; + email: string; + image?: string | null; + roleId: string; + roleName: string; + telemetryEnabled?: boolean | null; +} + +type UserUpdateData = Partial>; + +const userSelect = { + id: true, + name: true, + email: true, + image: true, + role: true, + telemetryEnabled: true, +}; + +const userSelectWithRoles = { + ...userSelect, + roles: { + select: { + role: { + select: { + id: true, + name: true, + description: true, + }, + }, + }, + }, +}; + +type UserWithRoles = Partial & { + roles?: { role: { id: string; name: string; description: string | null } }[]; +}; + +function mapToUserProfile(user: UserWithRoles): UserProfile { + const userProfile = { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + role: user.role, + telemetryEnabled: user.telemetryEnabled, + } as UserProfile; + + if (user.roles) { + userProfile.roles = user.roles.map((r) => ({ + id: r.role.id, + name: r.role.name, + description: r.role.description || undefined, + })); + } + + return userProfile; } export const userService = { async getUser(userId: string): Promise { const user = await prisma.user.findUnique({ where: { id: userId }, + select: userSelectWithRoles, }); if (!user) { throw new Error('User not found'); } - return { - id: user.id, - name: user.name, - email: user.email, - image: user.image, - role: user.role, - telemetryEnabled: user.telemetryEnabled, - }; + return mapToUserProfile(user); }, async getAllUsers(): Promise { - return await prisma.user.findMany({ + const users = await prisma.user.findMany({ where: { OR: [{ isAnonymous: false }, { isAnonymous: null }], }, - select: { - id: true, - name: true, - email: true, - image: true, - role: true, - telemetryEnabled: true, - }, + select: userSelectWithRoles, }); + + return users.map((user) => mapToUserProfile(user)); }, async getUserByEmail(email: string): Promise { const user = await prisma.user.findUnique({ where: { email }, + select: userSelectWithRoles, }); if (!user) { throw new Error('User not found'); } - return { - id: user.id, - name: user.name, - email: user.email, - image: user.image, - role: user.role, - telemetryEnabled: user.telemetryEnabled, - }; + return mapToUserProfile(user); }, - async updateUser( - userId: string, - data: Partial>, - ): Promise { + async updateUser(userId: string, data: UserUpdateData): Promise { const user = await prisma.user.update({ where: { id: userId }, data, + select: userSelect, }); - return { - id: user.id, - name: user.name, - email: user.email, - image: user.image, - role: user.role, - telemetryEnabled: user.telemetryEnabled, - }; + return mapToUserProfile(user); }, async updateTelemetryConsent(userId: string, telemetryEnabled: boolean): Promise { const user = await prisma.user.update({ where: { id: userId }, data: { telemetryEnabled }, - select: { - id: true, - name: true, - email: true, - role: true, - telemetryEnabled: true, - }, + select: userSelect, }); - return { - id: user.id, - name: user.name, - email: user.email, - role: user.role, - telemetryEnabled: user.telemetryEnabled, - }; + return mapToUserProfile(user); }, async getUsersByRole(roleId: string): Promise { - const userRoles = await prisma.userRole.findMany({ + const users = await prisma.user.findMany({ where: { - roleId, - }, - select: { - user: { - select: { - id: true, - name: true, - email: true, - role: true, - telemetryEnabled: true, + roles: { + some: { + roleId, }, }, }, + select: userSelect, }); - return userRoles.map((userRole) => userRole.user); + return users.map((user) => mapToUserProfile(user)); }, async addUserToRole(userId: string, roleId: string): Promise { @@ -138,18 +163,12 @@ export const userService = { }, select: { user: { - select: { - id: true, - name: true, - email: true, - role: true, - telemetryEnabled: true, - }, + select: userSelectWithRoles, }, }, }); - return userRole.user; + return mapToUserProfile(userRole.user); }, async removeUserFromRole(userId: string, roleId: string): Promise { @@ -163,9 +182,9 @@ export const userService = { }); }, - async updateUserRole(_userId: string, role: DeprecatedRole) { + async updateUserRole(userId: string, role: DeprecatedRole) { return await prisma.user.update({ - where: { id: _userId }, + where: { id: userId }, data: { role }, }); }, @@ -257,4 +276,149 @@ export const userService = { ); } }, + + async getAllUsersWithRoles(): Promise { + const users = await prisma.user.findMany({ + where: { + OR: [{ isAnonymous: false }, { isAnonymous: null }], + }, + include: { + roles: { + include: { + role: true, + }, + }, + }, + }); + + return users + .filter((user) => user.roles.length > 0) // Only include users who have at least one role + .map((user) => { + // Get the highest scoped role (Admin > Member, etc.) + // Sort roles by name to get consistent ordering, with Admin first + const sortedRoles = user.roles.sort((a, b) => { + const roleA = a.role.name.toLowerCase(); + const roleB = b.role.name.toLowerCase(); + + if (roleA === 'admin') { + return -1; + } + + if (roleB === 'admin') { + return 1; + } + + return roleA.localeCompare(roleB); + }); + + const primaryRole = sortedRoles[0]?.role; + + return { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + roleId: primaryRole?.id || '', + roleName: primaryRole?.name || 'No Role', + telemetryEnabled: user.telemetryEnabled, + }; + }); + }, + + async getAllUsersWithoutRoles(): Promise { + const users = await prisma.user.findMany({ + where: { + OR: [{ isAnonymous: false }, { isAnonymous: null }], + }, + include: { + roles: { + include: { + role: true, + }, + }, + }, + }); + + return users + .filter((user) => user.roles.length === 0) // Only include users who have no roles + .map((user) => { + return { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + roleId: '', + roleName: 'No Role', + telemetryEnabled: user.telemetryEnabled, + }; + }); + }, + + async updateUserRoleToNewSystem(userId: string, roleId: string): Promise { + // First, verify the role exists + const role = await prisma.role.findUnique({ + where: { id: roleId }, + }); + + if (!role) { + throw new Error('Role not found'); + } + + // First, remove all existing roles for the user + await prisma.userRole.deleteMany({ + where: { userId }, + }); + + // Add the new role + await prisma.userRole.create({ + data: { + userId, + roleId, + }, + }); + + // Get the updated user with role information + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + roles: { + include: { + role: true, + }, + }, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + + // Get the highest scoped role (Admin > Member, etc.) + const sortedRoles = user.roles.sort((a, b) => { + const roleA = a.role.name.toLowerCase(); + const roleB = b.role.name.toLowerCase(); + + if (roleA === 'admin') { + return -1; + } + + if (roleB === 'admin') { + return 1; + } + + return roleA.localeCompare(roleB); + }); + + const primaryRole = sortedRoles[0]?.role; + + return { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + roleId: primaryRole?.id || '', + roleName: primaryRole?.name || 'No Role', + telemetryEnabled: user.telemetryEnabled, + }; + }, }; diff --git a/package.json b/package.json index 5ee963ae..86d831ec 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@t3-oss/env-nextjs": "^0.13.8", "@tailwindcss/postcss": "^4.1.11", "@tanstack/react-virtual": "^3.13.0", + "@types/uuid": "^10.0.0", "@uiw/codemirror-theme-tokyo-night": "^4.23.11", "@unocss/webpack": "^66.3.3", "@webcontainer/api": "1.5.3-internal.2", @@ -169,6 +170,7 @@ "tailwind-merge": "^2.2.1", "tailwindcss": "^4.1.11", "unist-util-visit": "^5.0.0", + "uuid": "^11.1.0", "vite-plugin-node-polyfills": "^0.22.0", "wrangler": "^4.14.1", "zod": "^3.24.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e36a0aa..216dc8f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.0 version: 3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 '@uiw/codemirror-theme-tokyo-night': specifier: ^4.23.11 version: 4.24.2(@codemirror/language@6.11.2)(@codemirror/state@6.5.2)(@codemirror/view@6.38.1) @@ -378,6 +381,9 @@ importers: unist-util-visit: specifier: ^5.0.0 version: 5.0.0 + uuid: + specifier: ^11.1.0 + version: 11.1.0 vite-plugin-node-polyfills: specifier: ^0.22.0 version: 0.22.0(rollup@4.46.2)(vite@6.3.5(@types/node@24.0.14)(jiti@2.5.1)(lightningcss@1.30.1)(sass-embedded@1.90.0)(sass@1.90.0)(terser@5.43.1)(tsx@4.20.4)(yaml@2.8.1)) @@ -4297,6 +4303,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} @@ -15717,6 +15726,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/uuid@10.0.0': {} + '@types/uuid@9.0.8': {} '@types/webidl-conversions@7.0.3': {} diff --git a/prisma/migrations/20250902160529_invite_system/migration.sql b/prisma/migrations/20250902160529_invite_system/migration.sql new file mode 100644 index 00000000..bec53b69 --- /dev/null +++ b/prisma/migrations/20250902160529_invite_system/migration.sql @@ -0,0 +1,30 @@ +-- CreateEnum +CREATE TYPE "public"."InviteStatus" AS ENUM ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED'); + +-- CreateTable +CREATE TABLE "public"."UserInvite" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "roleId" TEXT NOT NULL, + "status" "public"."InviteStatus" NOT NULL DEFAULT 'PENDING', + "invitedBy" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "acceptedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "existingUserId" TEXT, + + CONSTRAINT "UserInvite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "UserInvite_email_idx" ON "public"."UserInvite"("email"); + +-- CreateIndex +CREATE INDEX "UserInvite_status_idx" ON "public"."UserInvite"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserInvite_email_status_key" ON "public"."UserInvite"("email", "status"); + +-- AddForeignKey +ALTER TABLE "public"."UserInvite" ADD CONSTRAINT "UserInvite_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "public"."role"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cb5a22fd..cbcee9a4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,13 +16,13 @@ datasource db { } model DataSource { - id String @id @default(cuid()) - name String - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) - createdById String @map("created_by_id") - environments EnvironmentDataSource[] + id String @id @default(cuid()) + name String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdById String @map("created_by_id") + environments EnvironmentDataSource[] directPermissions Permission[] @@ -217,6 +217,7 @@ model Role { permissions Permission[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + UserInvite UserInvite[] @@map("role") } @@ -346,3 +347,29 @@ model Verification { @@map("verification") } + +model UserInvite { + id String @id + email String + roleId String + status InviteStatus @default(PENDING) + invitedBy String + expiresAt DateTime + acceptedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + existingUserId String? + + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + + @@unique([email, status]) + @@index([email]) + @@index([status]) +} + +enum InviteStatus { + PENDING + ACCEPTED + EXPIRED + REVOKED +}