Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions app/admin/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { redirect } from "next/navigation"
import { createClient } from "@/lib/supabase/server"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Users, UserPlus, Clock } from "lucide-react"
import { Users, UserPlus, Clock, UsersRound } from "lucide-react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { PendingRegistrationsTable } from "./pending-registrations-table"
import { PendingTeamMembersTable } from "./pending-team-members-table"
import { ExistingUsersTable } from "./existing-users-table"
import { getPendingRegistrations, getAllUsers } from "./user-management-actions"
import { getPendingRegistrations, getPendingTeamMembers, getAllUsers } from "./user-management-actions"

async function updateUserRole(formData: FormData) {
"use server"
Expand Down Expand Up @@ -47,6 +48,7 @@ export default async function AdminUsersPage() {
}

const pendingRegistrations = await getPendingRegistrations()
const pendingTeamMembers = await getPendingTeamMembers()
const allUsers = await getAllUsers()

return (
Expand Down Expand Up @@ -76,6 +78,26 @@ export default async function AdminUsersPage() {
</CardContent>
</Card>

<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
<UsersRound className="h-5 w-5 text-blue-600" />
Pending Team Members
</CardTitle>
<CardDescription>Approve team members from the Teams module and create user accounts</CardDescription>
</div>
<Badge variant="secondary" className="h-8 px-3">
{pendingTeamMembers.length} pending
</Badge>
</div>
</CardHeader>
<CardContent>
<PendingTeamMembersTable teamMembers={pendingTeamMembers} />
</CardContent>
</Card>

<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">All Users</h2>
<Button asChild>
Expand Down
150 changes: 150 additions & 0 deletions app/admin/users/pending-team-members-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"use client"

import { useState } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { CheckCircle, Copy } from "lucide-react"
import { approveTeamMember } from "./user-management-actions"
import { Alert, AlertDescription } from "@/components/ui/alert"

interface TeamMember {
id: string
email: string
first_name: string
last_name: string
role: string
invited_at: string
created_by?: string
}

export function PendingTeamMembersTable({ teamMembers }: { teamMembers: TeamMember[] }) {
const [selectedRoles, setSelectedRoles] = useState<Record<string, string>>({})
const [approvalDialog, setApprovalDialog] = useState<{ open: boolean; email?: string; password?: string }>({
open: false,
})
const [loading, setLoading] = useState<string | null>(null)

const handleApprove = async (teamMemberId: string) => {
const assignedRole = selectedRoles[teamMemberId] || teamMembers.find((tm) => tm.id === teamMemberId)?.role

setLoading(teamMemberId)
const result = await approveTeamMember(teamMemberId, assignedRole!)
setLoading(null)

if (result.success && result.tempPassword) {
setApprovalDialog({ open: true, email: result.email, password: result.tempPassword })
} else if (result.error) {
alert(result.error)
}
}

const copyCredentials = () => {
if (approvalDialog.email && approvalDialog.password) {
navigator.clipboard.writeText(`Email: ${approvalDialog.email}\nPassword: ${approvalDialog.password}`)
}
}

if (teamMembers.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
<p>No pending team members</p>
</div>
)
}

return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Requested Role</TableHead>
<TableHead>Assign Role</TableHead>
<TableHead>Invited</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teamMembers.map((member) => (
<TableRow key={member.id}>
<TableCell className="font-medium">
{member.first_name} {member.last_name}
</TableCell>
<TableCell>{member.email}</TableCell>
<TableCell>
<Badge variant="outline">{member.role.replace("_", " ")}</Badge>
</TableCell>
<TableCell>
<Select
value={selectedRoles[member.id] || member.role}
onValueChange={(value) => setSelectedRoles({ ...selectedRoles, [member.id]: value })}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="property_manager">Property Manager</SelectItem>
<SelectItem value="accountant">Accountant</SelectItem>
<SelectItem value="support_staff">Support Staff</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(member.invited_at).toLocaleDateString()}
</TableCell>
<TableCell>
<Button size="sm" onClick={() => handleApprove(member.id)} disabled={loading === member.id}>
<CheckCircle className="mr-1 h-4 w-4" />
Approve & Create Account
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>

{/* Approval Success Dialog */}
<Dialog open={approvalDialog.open} onOpenChange={(open) => setApprovalDialog({ open })}>
<DialogContent>
<DialogHeader>
<DialogTitle>Team Member Approved Successfully!</DialogTitle>
<DialogDescription>The user account has been created. Share these credentials with the user.</DialogDescription>
</DialogHeader>
<Alert>
<AlertDescription>
<div className="space-y-2">
<p className="font-medium">Login Credentials:</p>
<p>Email: {approvalDialog.email}</p>
<p>Temporary Password: {approvalDialog.password}</p>
<p className="text-xs text-muted-foreground mt-2">
User will be required to change password on first login.
</p>
</div>
</AlertDescription>
</Alert>
<DialogFooter>
<Button onClick={copyCredentials}>
<Copy className="mr-2 h-4 w-4" />
Copy Credentials
</Button>
<Button variant="outline" onClick={() => window.location.reload()}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
122 changes: 122 additions & 0 deletions app/admin/users/user-management-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,125 @@ export async function getAllUsers() {

return data || []
}

export async function getPendingTeamMembers() {
const supabase = await createClient()

// Get pending team members that don't have a user account yet
const { data: teamMembers, error: teamError } = await supabase
.from("team_members")
.select("*")
.eq("status", "pending")
.order("invited_at", { ascending: false })

if (teamError) {
console.error(" Error fetching team members:", teamError)
return []
}

if (!teamMembers || teamMembers.length === 0) {
return []
}

// Check which team members already have user accounts
const emails = teamMembers.map((tm) => tm.email)
const { data: existingUsers } = await supabase.from("profiles").select("email").in("email", emails)

const existingEmails = new Set(existingUsers?.map((u) => u.email) || [])

// Return only team members without user accounts
return teamMembers.filter((tm) => !existingEmails.has(tm.email))
}

export async function approveTeamMember(teamMemberId: string, assignedRole?: string) {
const supabase = await createClient()
const serviceClient = await getServiceClient()

// Get current user
const {
data: { user: currentUser },
} = await supabase.auth.getUser()
if (!currentUser) {
return { error: "Unauthorized" }
}

// Get team member details
const { data: teamMember, error: teamError } = await supabase
.from("team_members")
.select("*")
.eq("id", teamMemberId)
.single()

if (teamError || !teamMember) {
return { error: "Team member not found" }
}

// Check if user already exists
const { data: existingUser } = await supabase.from("profiles").select("id").eq("email", teamMember.email).single()

if (existingUser) {
return { error: "User account already exists for this email" }
}

// Use assigned role or team member's role
const role = assignedRole || teamMember.role

// Generate temporary password
const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!`

// Create auth user using service client
const { data: authUser, error: authError } = await serviceClient.auth.admin.createUser({
email: teamMember.email,
password: tempPassword,
email_confirm: true,
user_metadata: {
first_name: teamMember.first_name,
last_name: teamMember.last_name,
},
})

if (authError) {
console.error(" Auth creation error:", authError)
return { error: "Failed to create user account" }
}

// Create profile
const { error: profileError } = await supabase.from("profiles").insert({
id: authUser.user.id,
email: teamMember.email,
first_name: teamMember.first_name,
last_name: teamMember.last_name,
role: role,
is_admin: role === "admin",
requires_password_change: true,
is_active: true,
created_by: currentUser.id,
status: "active",
})

if (profileError) {
console.error(" Profile creation error:", profileError)
return { error: "Failed to create user profile" }
}

// Update team member status to active
await supabase
.from("team_members")
.update({
status: "active",
invitation_token: null,
})
.eq("id", teamMemberId)

// Log activity
await logActivity(currentUser.id, "approve_team_member", "users", authUser.user.id, {
email: teamMember.email,
assigned_role: role,
team_member_id: teamMemberId,
})

revalidatePath("/admin/users")
revalidatePath("/team")

return { success: true, tempPassword, email: teamMember.email }
}
54 changes: 35 additions & 19 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,48 @@
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import { ThemeProvider } from "@/components/theme-provider"
import ClientLayout from "./client-layout"
import type { Metadata, Viewport } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import "./globals.css"
import ClientLayout from "./client-layout"

const inter = Inter({ subsets: ["latin"] })
const geistSans = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
})

const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
})

export const metadata: Metadata = {
title: "Exela PMS - Property Management System",
description: "Complete property management solution for landlords",
title: "Exela Property Management Software",
description:
"Complete property management solution for landlords. Track tenants, manage maintenance requests, collect payments, and streamline operations.",
icons: {
icon: [
{ url: "/Exela-Logo.png", sizes: "32x32", type: "image/png" },
{ url: "/Exela-Logo.png", sizes: "192x192", type: "image/png" },
],
apple: "/Exela-Logo.png", // white background is okay for Apple
}

}

export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
],
}

export default function RootLayout({
children,
}: Readonly<{
}: {
children: React.ReactNode
}>) {
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
>
<ClientLayout>{children}</ClientLayout>
</ThemeProvider>
<html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}>
<body className="font-sans antialiased" suppressHydrationWarning>
<ClientLayout>{children}</ClientLayout>
</body>
</html>
)
Expand Down
Loading