setNewProvider({ ...newProvider, redirect_uri: e.target.value })}
/>
@@ -341,7 +341,7 @@ export default function TenantSocialPage() {
Redirect URI
setEditForm({ ...editForm, redirect_uri: e.target.value })}
/>
diff --git a/src/app/(dashboard)/tenant/users/page.tsx b/src/app/(dashboard)/tenant/users/page.tsx
index 05c3666..77b3c34 100644
--- a/src/app/(dashboard)/tenant/users/page.tsx
+++ b/src/app/(dashboard)/tenant/users/page.tsx
@@ -1,410 +1,781 @@
"use client";
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useMemo, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ Loader2,
+ MoreVertical,
+ Search,
+ ShieldCheck,
+ Trash2,
+ UserPlus,
+ UserRound,
+} from "lucide-react";
+import { toast } from "sonner";
+
import { apiClient } from "@/lib/api-client";
import { getApiErrorMessage } from "@/lib/errors";
import { Role, TenantResponse, UserResponse } from "@/lib/types";
import { useAuthStore } from "@/stores/auth-store";
import { useActiveTenant } from "@/hooks/use-active-tenant";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { ConfirmDialog } from "@/components/confirm-dialog";
import {
- UserPlus,
- Trash2,
- MoreVertical,
- Loader2,
- Search,
-} from "lucide-react";
-
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import {
- Card,
-} from "@/components/ui/card";
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { Badge } from "@/components/ui/badge";
-import { toast } from "sonner";
-import { useState, useMemo } from "react";
-import { ConfirmDialog } from "@/components/confirm-dialog";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
const selectClassName =
- "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
+ "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
+
+interface UserRoleAssignment {
+ role: Role;
+ tenant_id?: string | null;
+}
export default function TenantUsersPage() {
- const queryClient = useQueryClient();
- const { activeTenantId, user } = useAuthStore();
- const { tenants: myTenants } = useActiveTenant();
- const [inviteTenantId, setInviteTenantId] = useState("");
- const [inviteEmail, setInviteEmail] = useState("");
- const [inviteRoleId, setInviteRoleId] = useState("");
- const [isInviteOpen, setIsInviteOpen] = useState(false);
- const [searchTerm, setSearchTerm] = useState("");
- const [removeTarget, setRemoveTarget] = useState<{ id: string; email: string } | null>(null);
-
- const hasPlatformScope =
- user?.roles?.some((r) => r.role.scope === "PLATFORM") ?? false;
-
- const { data: platformTenants = [] } = useQuery
({
- queryKey: ["platformTenants"],
- queryFn: async () => {
- const { data } = await apiClient.get("/platform/tenants");
- return data;
- },
- enabled: hasPlatformScope,
- });
-
- const inviteTenants = useMemo(
- () => (hasPlatformScope ? platformTenants : myTenants),
- [hasPlatformScope, platformTenants, myTenants]
- );
-
- const { data: inviteTenantRoles = [], isLoading: isLoadingInviteRoles } = useQuery({
- queryKey: ["inviteTenantRoles", inviteTenantId, hasPlatformScope],
- queryFn: async () => {
- const url = hasPlatformScope
- ? `/platform/tenants/${inviteTenantId}/roles`
- : `/tenants/${inviteTenantId}/roles`;
- const { data } = await apiClient.get(url);
- return data;
- },
- enabled: !!inviteTenantId,
- });
-
- const defaultInviteRoleId = useMemo(() => {
- if (inviteTenantRoles.length === 0) return "";
- return (
- inviteTenantRoles.find((r) => r.name === "TENANT_USER")?.id ??
- inviteTenantRoles[0]?.id ??
- ""
- );
- }, [inviteTenantRoles]);
-
- const resolvedInviteRoleId = inviteRoleId || defaultInviteRoleId;
-
- const resetInviteForm = () => {
- setInviteEmail("");
- setInviteRoleId("");
- setInviteTenantId(activeTenantId ?? "");
- };
-
- const handleInviteOpenChange = (open: boolean) => {
- setIsInviteOpen(open);
- if (open) {
- const defaultTenant =
- inviteTenants.find((t) => t.id === activeTenantId)?.id ??
- inviteTenants[0]?.id ??
- "";
- setInviteTenantId(defaultTenant);
- setInviteEmail("");
- setInviteRoleId("");
- } else {
- resetInviteForm();
- }
- };
-
- // 1. Fetch Users
- const { data: users, isLoading } = useQuery({
- queryKey: ["tenantUsers", activeTenantId],
- queryFn: async () => {
- if (!activeTenantId) return [];
- const { data } = await apiClient.get(`/tenants/${activeTenantId}/users`);
- return data;
- },
- enabled: !!activeTenantId,
- });
-
- // 2. Invite User
- const inviteMutation = useMutation({
- mutationFn: async () => {
- const selectedRole = inviteTenantRoles.find((r) => r.id === resolvedInviteRoleId);
- await apiClient.post(`/tenants/${inviteTenantId}/users`, {
- email: inviteEmail.trim(),
- role_id: resolvedInviteRoleId || undefined,
- role_name: selectedRole?.name ?? "TENANT_USER",
- });
- },
- onSuccess: () => {
- const tenantName =
- inviteTenants.find((t) => t.id === inviteTenantId)?.name ?? "organization";
- toast.success(`Invite sent to ${inviteEmail.trim()} for ${tenantName}`);
- setIsInviteOpen(false);
- resetInviteForm();
- queryClient.invalidateQueries({ queryKey: ["tenantUsers", inviteTenantId] });
- if (inviteTenantId !== activeTenantId) {
- queryClient.invalidateQueries({ queryKey: ["tenantUsers", activeTenantId] });
- }
- },
- onError: (error: unknown) => {
- toast.error(getApiErrorMessage(error, "Failed to send invitation"));
- },
- });
-
- const canSendInvite =
- !!inviteTenantId &&
- !!resolvedInviteRoleId &&
- inviteEmail.includes("@") &&
- !inviteMutation.isPending;
-
- const filteredUsers = useMemo(() => {
- if (!users) return [];
- const query = searchTerm.trim().toLowerCase();
- if (!query) return users;
- return users.filter(
- (u) =>
- u.email.toLowerCase().includes(query) ||
- u.id.toLowerCase().includes(query) ||
- u.username?.toLowerCase().includes(query)
- );
- }, [users, searchTerm]);
-
- // 3. Remove User
- const removeMutation = useMutation({
- mutationFn: async (userId: string) => {
- await apiClient.delete(`/tenants/${activeTenantId}/users/${userId}`);
- },
- onSuccess: () => {
- toast.success("User removed from organization");
- queryClient.invalidateQueries({ queryKey: ["tenantUsers", activeTenantId] });
- },
- onError: (error: unknown) => {
- toast.error(getApiErrorMessage(error, "Failed to remove user"));
- },
- });
-
- if (!activeTenantId) {
- return Please select an organization first.
;
- }
-
- return (
-
-
-
-
User Management
-
- Members who have access to this organization.
-
-
-
-
-
-
- Invite User
-
-
-
-
- Invite a team member
-
- Choose an organization and role, then enter their email. They will
- receive an email with instructions.
-
-
-
-
- Organization
- {
- setInviteTenantId(e.target.value);
- setInviteRoleId("");
- }}
- >
- Select organization...
- {inviteTenants.map((tenant) => (
-
- {tenant.name}
-
- ))}
-
-
-
- Role
- setInviteRoleId(e.target.value)}
- disabled={!inviteTenantId || isLoadingInviteRoles}
- >
-
- {!inviteTenantId
- ? "Select an organization first"
- : isLoadingInviteRoles
- ? "Loading roles..."
- : inviteTenantRoles.length === 0
- ? "No roles available"
- : "Select role..."}
-
- {inviteTenantRoles.map((role) => (
-
- {role.name}
- {role.description ? ` — ${role.description}` : ""}
-
- ))}
-
-
-
- Email address
- setInviteEmail(e.target.value)}
- disabled={!inviteTenantId || !resolvedInviteRoleId}
- />
-
-
-
- inviteMutation.mutate()}
- disabled={!canSendInvite}
- className="w-full sm:w-auto"
- >
- {inviteMutation.isPending && (
-
- )}
- Send Invitation
-
-
-
-
-
-
-
-
-
- setSearchTerm(e.target.value)}
- />
-
-
-
-
- {isLoading ? (
-
-
-
- ) : (
-
-
-
- User
- Status
- Date Added
- Action
-
-
-
- {filteredUsers.map((user) => (
-
-
-
-
- {user.email.charAt(0).toUpperCase()}
-
-
- {user.email}
- {user.id}
-
-
-
-
-
- {user.status}
-
-
-
- {new Date(user.created_at).toLocaleDateString()}
-
-
-
-
-
-
-
-
-
- Actions
- View profile
- Edit permissions
-
-
- setRemoveTarget({
- id: user.id,
- email: user.email,
- })
- }
- >
- Remove User
-
-
-
-
-
- ))}
- {filteredUsers.length === 0 && (
-
-
- {users?.length
- ? "No users match your search."
- : "No users found in this organization."}
-
-
- )}
-
-
- )}
-
-
-
!open && setRemoveTarget(null)}
- title="Remove user from organization?"
- description={
- removeTarget
- ? `${removeTarget.email} will lose access to this organization.`
- : undefined
- }
- confirmLabel="Remove"
- loading={removeMutation.isPending}
- onConfirm={() => {
- if (removeTarget) {
- removeMutation.mutate(removeTarget.id);
- setRemoveTarget(null);
- }
- }}
- />
-
- );
+ const queryClient = useQueryClient();
+ const { activeTenantId, user } = useAuthStore();
+ const { tenants: myTenants } = useActiveTenant();
+ const [inviteTenantId, setInviteTenantId] = useState("");
+ const [inviteEmail, setInviteEmail] = useState("");
+ const [inviteRoleId, setInviteRoleId] = useState("");
+ const [isInviteOpen, setIsInviteOpen] = useState(false);
+ const [searchTerm, setSearchTerm] = useState("");
+ const [removeTarget, setRemoveTarget] = useState<{
+ id: string;
+ email: string;
+ } | null>(null);
+ const [selectedUser, setSelectedUser] = useState(null);
+ const [isProfileOpen, setIsProfileOpen] = useState(false);
+ const [roleTarget, setRoleTarget] = useState(null);
+ const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false);
+ const [selectedRoleId, setSelectedRoleId] = useState("");
+
+ const hasPlatformScope =
+ user?.roles?.some((r) => r.role.scope === "PLATFORM") ?? false;
+
+ const { data: platformTenants = [] } = useQuery({
+ queryKey: ["platformTenants"],
+ queryFn: async () => {
+ const { data } =
+ await apiClient.get("/platform/tenants");
+ return data;
+ },
+ enabled: hasPlatformScope,
+ });
+
+ const inviteTenants = useMemo(
+ () => (hasPlatformScope ? platformTenants : myTenants),
+ [hasPlatformScope, platformTenants, myTenants],
+ );
+
+ const { data: inviteTenantRoles = [], isLoading: isLoadingInviteRoles } =
+ useQuery({
+ queryKey: ["inviteTenantRoles", inviteTenantId, hasPlatformScope],
+ queryFn: async () => {
+ const url = hasPlatformScope
+ ? `/platform/tenants/${inviteTenantId}/roles`
+ : `/tenants/${inviteTenantId}/roles`;
+ const { data } = await apiClient.get(url);
+ return data;
+ },
+ enabled: !!inviteTenantId,
+ });
+
+ const defaultInviteRoleId = useMemo(() => {
+ if (inviteTenantRoles.length === 0) return "";
+ return (
+ inviteTenantRoles.find((r) => r.name === "TENANT_USER")?.id ??
+ inviteTenantRoles[0]?.id ??
+ ""
+ );
+ }, [inviteTenantRoles]);
+
+ const resolvedInviteRoleId = inviteRoleId || defaultInviteRoleId;
+
+ const { data: tenantRoles = [], isLoading: isLoadingTenantRoles } = useQuery<
+ Role[]
+ >({
+ queryKey: ["tenantRolesForMemberDialog", activeTenantId],
+ queryFn: async () => {
+ const { data } = await apiClient.get(
+ `/tenants/${activeTenantId}/roles`,
+ );
+ return data;
+ },
+ enabled: !!activeTenantId,
+ });
+
+ const { data: users, isLoading } = useQuery({
+ queryKey: ["tenantUsers", activeTenantId],
+ queryFn: async () => {
+ if (!activeTenantId) return [];
+ const { data } = await apiClient.get(
+ `/tenants/${activeTenantId}/users`,
+ );
+ return data;
+ },
+ enabled: !!activeTenantId,
+ });
+
+ const { data: currentAssignments = [], isLoading: isLoadingAssignments } =
+ useQuery({
+ queryKey: ["tenantUserAssignments", activeTenantId, roleTarget?.id],
+ queryFn: async () => {
+ const { data } = await apiClient.get(
+ `/tenants/${activeTenantId}/users/${roleTarget?.id}/roles`,
+ );
+ return data;
+ },
+ enabled: !!activeTenantId && !!roleTarget?.id && isRoleDialogOpen,
+ });
+
+ const resetInviteForm = () => {
+ setInviteEmail("");
+ setInviteRoleId("");
+ setInviteTenantId(activeTenantId ?? "");
+ };
+
+ const handleInviteOpenChange = (open: boolean) => {
+ setIsInviteOpen(open);
+ if (open) {
+ const defaultTenant =
+ inviteTenants.find((t) => t.id === activeTenantId)?.id ??
+ inviteTenants[0]?.id ??
+ "";
+ setInviteTenantId(defaultTenant);
+ setInviteEmail("");
+ setInviteRoleId("");
+ } else {
+ resetInviteForm();
+ }
+ };
+
+ const inviteMutation = useMutation({
+ mutationFn: async () => {
+ const selectedRole = inviteTenantRoles.find(
+ (r) => r.id === resolvedInviteRoleId,
+ );
+ await apiClient.post(`/tenants/${inviteTenantId}/users`, {
+ email: inviteEmail.trim(),
+ role_id: resolvedInviteRoleId || undefined,
+ role_name: selectedRole?.name ?? "TENANT_USER",
+ });
+ },
+ onSuccess: () => {
+ const tenantName =
+ inviteTenants.find((t) => t.id === inviteTenantId)?.name ??
+ "organization";
+ toast.success(`Invite sent to ${inviteEmail.trim()} for ${tenantName}`);
+ setIsInviteOpen(false);
+ resetInviteForm();
+ queryClient.invalidateQueries({
+ queryKey: ["tenantUsers", inviteTenantId],
+ });
+ if (inviteTenantId !== activeTenantId) {
+ queryClient.invalidateQueries({
+ queryKey: ["tenantUsers", activeTenantId],
+ });
+ }
+ },
+ onError: (error: unknown) => {
+ toast.error(getApiErrorMessage(error, "Failed to send invitation"));
+ },
+ });
+
+ const removeMutation = useMutation({
+ mutationFn: async (userId: string) => {
+ await apiClient.delete(`/tenants/${activeTenantId}/users/${userId}`);
+ },
+ onSuccess: () => {
+ toast.success("User removed from organization");
+ queryClient.invalidateQueries({
+ queryKey: ["tenantUsers", activeTenantId],
+ });
+ },
+ onError: (error: unknown) => {
+ toast.error(getApiErrorMessage(error, "Failed to remove user"));
+ },
+ });
+
+ const assignRoleMutation = useMutation({
+ mutationFn: async () => {
+ const selectedRole = tenantRoles.find(
+ (role) => role.id === selectedRoleId,
+ );
+ await apiClient.post(
+ `/tenants/${activeTenantId}/users/${roleTarget?.id}/roles`,
+ {
+ role_id: selectedRoleId,
+ role_name: selectedRole?.name,
+ },
+ );
+ },
+ onSuccess: () => {
+ toast.success("Role assigned");
+ setSelectedRoleId("");
+ queryClient.invalidateQueries({
+ queryKey: ["tenantUserAssignments", activeTenantId, roleTarget?.id],
+ });
+ queryClient.invalidateQueries({
+ queryKey: ["tenantUsers", activeTenantId],
+ });
+ },
+ onError: (error: unknown) => {
+ toast.error(getApiErrorMessage(error, "Failed to assign role"));
+ },
+ });
+
+ const removeRoleMutation = useMutation({
+ mutationFn: async (roleName: string) => {
+ await apiClient.delete(
+ `/tenants/${activeTenantId}/users/${roleTarget?.id}/roles/${roleName}`,
+ );
+ },
+ onSuccess: () => {
+ toast.success("Role removed");
+ queryClient.invalidateQueries({
+ queryKey: ["tenantUserAssignments", activeTenantId, roleTarget?.id],
+ });
+ queryClient.invalidateQueries({
+ queryKey: ["tenantUsers", activeTenantId],
+ });
+ },
+ onError: (error: unknown) => {
+ toast.error(getApiErrorMessage(error, "Failed to remove role"));
+ },
+ });
+
+ const canSendInvite =
+ !!inviteTenantId &&
+ !!resolvedInviteRoleId &&
+ inviteEmail.includes("@") &&
+ !inviteMutation.isPending;
+
+ const filteredUsers = useMemo(() => {
+ if (!users) return [];
+ const query = searchTerm.trim().toLowerCase();
+ if (!query) return users;
+ return users.filter(
+ (u) =>
+ u.email.toLowerCase().includes(query) ||
+ u.id.toLowerCase().includes(query) ||
+ u.username?.toLowerCase().includes(query),
+ );
+ }, [users, searchTerm]);
+
+ const assignableRoles = useMemo(() => {
+ const assignedIds = new Set(
+ currentAssignments.map((assignment) => assignment.role.id),
+ );
+ return tenantRoles.filter((role) => !assignedIds.has(role.id));
+ }, [currentAssignments, tenantRoles]);
+
+ const tenantScopedRolesForUser = useMemo(() => {
+ if (!selectedUser || !activeTenantId) return [];
+ return (selectedUser.roles || []).filter(
+ (assignment) => assignment.tenant_id === activeTenantId,
+ );
+ }, [selectedUser, activeTenantId]);
+
+ const openProfile = (targetUser: UserResponse) => {
+ setSelectedUser(targetUser);
+ setIsProfileOpen(true);
+ };
+
+ const openRoleDialog = (targetUser: UserResponse) => {
+ setRoleTarget(targetUser);
+ setSelectedRoleId("");
+ setIsRoleDialogOpen(true);
+ };
+
+ if (!activeTenantId) {
+ return (
+
+ Please select an organization first.
+
+ );
+ }
+
+ return (
+
+
+
+
User Management
+
+ Members who have access to this organization.
+
+
+
+
+
+
+ Invite User
+
+
+
+
+ Invite a team member
+
+ Choose an organization and role, then enter their email. They
+ will receive an email with instructions.
+
+
+
+
+ Organization
+ {
+ setInviteTenantId(e.target.value);
+ setInviteRoleId("");
+ }}
+ >
+ Select organization...
+ {inviteTenants.map((tenant) => (
+
+ {tenant.name}
+
+ ))}
+
+
+
+ Role
+ setInviteRoleId(e.target.value)}
+ disabled={!inviteTenantId || isLoadingInviteRoles}
+ >
+
+ {!inviteTenantId
+ ? "Select an organization first"
+ : isLoadingInviteRoles
+ ? "Loading roles..."
+ : inviteTenantRoles.length === 0
+ ? "No roles available"
+ : "Select role..."}
+
+ {inviteTenantRoles.map((role) => (
+
+ {role.name}
+ {role.description ? ` — ${role.description}` : ""}
+
+ ))}
+
+
+
+ Email address
+ setInviteEmail(e.target.value)}
+ disabled={!inviteTenantId || !resolvedInviteRoleId}
+ />
+
+
+
+ inviteMutation.mutate()}
+ disabled={!canSendInvite}
+ className="w-full sm:w-auto"
+ >
+ {inviteMutation.isPending && (
+
+ )}
+ Send Invitation
+
+
+
+
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+
+ User
+ Status
+ Date Added
+ Action
+
+
+
+ {filteredUsers.map((member) => (
+
+
+
+
+ {member.email.charAt(0).toUpperCase()}
+
+
+
+ {member.email}
+
+
+ {member.id}
+
+
+
+
+
+
+ {member.status}
+
+
+
+ {new Date(member.created_at).toLocaleDateString()}
+
+
+
+
+
+
+
+
+
+ Actions
+ openProfile(member)}
+ >
+ View profile
+
+ openRoleDialog(member)}
+ >
+ Edit permissions
+
+
+
+ setRemoveTarget({
+ id: member.id,
+ email: member.email,
+ })
+ }
+ >
+ Remove User
+
+
+
+
+
+ ))}
+ {filteredUsers.length === 0 && (
+
+
+ {users?.length
+ ? "No users match your search."
+ : "No users found in this organization."}
+
+
+ )}
+
+
+ )}
+
+
+
+
+
+ Member profile
+
+ Review the selected member’s status and organization roles.
+
+
+ {selectedUser ? (
+
+
+
+ {selectedUser.email.charAt(0).toUpperCase()}
+
+
+
+ {selectedUser.first_name || selectedUser.last_name
+ ? `${selectedUser.first_name || ""} ${selectedUser.last_name || ""}`.trim()
+ : selectedUser.email}
+
+
+ {selectedUser.email}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Roles in this organization
+
+ {tenantScopedRolesForUser.length === 0 ? (
+
+ No tenant-specific roles found.
+
+ ) : (
+
+ {tenantScopedRolesForUser.map((assignment) => (
+
+ {assignment.role.name}
+
+ ))}
+
+ )}
+
+
+
+ ) : null}
+
+
+
+
{
+ setIsRoleDialogOpen(open);
+ if (!open) {
+ setRoleTarget(null);
+ setSelectedRoleId("");
+ }
+ }}
+ >
+
+
+ Edit organization roles
+
+ Assign or remove tenant roles for the selected member.
+
+
+
+ {roleTarget ? (
+
+
+
+
+
{roleTarget.email}
+
+ {roleTarget.id}
+
+
+
+
+
+
Assigned roles
+
+ {isLoadingAssignments ? (
+
+
+
+ ) : currentAssignments.length === 0 ? (
+
+ No roles assigned yet.
+
+ ) : (
+ currentAssignments.map((assignment) => (
+
+
+
+ {assignment.role.name}
+
+
+ {assignment.role.description || "No description"}
+
+
+
+ removeRoleMutation.mutate(assignment.role.name)
+ }
+ >
+ Remove
+
+
+ ))
+ )}
+
+
+
+
+
Assign another role
+
+ setSelectedRoleId(event.target.value)}
+ disabled={
+ isLoadingTenantRoles || assignableRoles.length === 0
+ }
+ >
+
+ {isLoadingTenantRoles
+ ? "Loading roles..."
+ : assignableRoles.length === 0
+ ? "No additional roles available"
+ : "Select role..."}
+
+ {assignableRoles.map((role) => (
+
+ {role.name}
+ {role.description ? ` — ${role.description}` : ""}
+
+ ))}
+
+ assignRoleMutation.mutate()}
+ disabled={assignRoleMutation.isPending || !selectedRoleId}
+ >
+ {assignRoleMutation.isPending ? (
+
+ ) : (
+
+ )}
+ Assign Role
+
+
+
+
+ ) : null}
+
+
+ setIsRoleDialogOpen(false)}
+ >
+ Close
+
+
+
+
+
+
!open && setRemoveTarget(null)}
+ title="Remove user from organization?"
+ description={
+ removeTarget
+ ? `${removeTarget.email} will lose access to this organization.`
+ : undefined
+ }
+ confirmLabel="Remove"
+ loading={removeMutation.isPending}
+ onConfirm={() => {
+ if (removeTarget) {
+ removeMutation.mutate(removeTarget.id);
+ setRemoveTarget(null);
+ }
+ }}
+ />
+
+ );
+}
+
+function InfoRow({
+ label,
+ value,
+ monospace = false,
+}: {
+ label: string;
+ value: string;
+ monospace?: boolean;
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index dfdb471..4e92a24 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -1,246 +1,259 @@
export interface UserResponse {
- id: string;
- email: string;
- username?: string;
- phone_number?: string | null;
- first_name?: string;
- last_name?: string;
- avatar_url?: string | null;
- status: "ACTIVE" | "INACTIVE" | "SUSPENDED";
- is_email_verified: boolean;
- is_phone_verified: boolean;
- mfa_enabled: boolean;
- auth_strategies: string[];
- created_at: string;
- last_login_at?: string | null;
- roles: Array<{
- role: {
- id: string;
- name: string;
- description: string;
- scope: string;
- level: number;
- created_at: string;
- permissions: Array<{
- id: string;
- name: string;
- description?: string;
- }>;
- permission_ids: string[];
- };
- tenant_id: string;
- }>;
+ id: string;
+ email: string;
+ username?: string;
+ phone_number?: string | null;
+ first_name?: string;
+ last_name?: string;
+ avatar_url?: string | null;
+ status: "ACTIVE" | "INACTIVE" | "SUSPENDED";
+ is_email_verified: boolean;
+ is_phone_verified: boolean;
+ mfa_enabled: boolean;
+ auth_strategies: string[];
+ created_at: string;
+ last_login_at?: string | null;
+ roles: Array<{
+ role: {
+ id: string;
+ name: string;
+ description: string;
+ scope: string;
+ level: number;
+ created_at: string;
+ permissions: Array<{
+ id: string;
+ name: string;
+ description?: string;
+ }>;
+ permission_ids: string[];
+ };
+ tenant_id: string;
+ }>;
}
export interface AuthResponse {
- access_token: string;
- refresh_token: string;
- token_type: string;
- expires_in: number;
- user?: UserResponse;
+ access_token: string;
+ refresh_token: string;
+ token_type: string;
+ expires_in: number;
+ user?: UserResponse;
}
export interface TenantOwner {
- id: string;
- email: string;
- first_name?: string;
- last_name?: string;
+ id: string;
+ email: string;
+ first_name?: string;
+ last_name?: string;
}
export interface TenantResponse {
- id: string;
- name: string;
- description?: string;
- type: string;
- owner_id: string;
- created_at?: string;
- updated_at?: string;
- owner?: TenantOwner;
+ id: string;
+ name: string;
+ description?: string;
+ type: string;
+ owner_id: string;
+ created_at?: string;
+ updated_at?: string;
+ owner?: TenantOwner;
}
export interface OAuthLoginResponse {
- access_token: string;
- refresh_token: string;
- token_type: string;
- expires_in: number;
- is_new_user: boolean;
- provider: "google" | "github" | "microsoft" | "authengine";
+ access_token: string;
+ refresh_token: string;
+ token_type: string;
+ expires_in: number;
+ is_new_user: boolean;
+ provider: "google" | "github" | "microsoft" | "authengine";
}
export interface MFAChallengeResponse {
- mfa_pending_token: string;
- message: string;
+ mfa_pending_token: string;
+ message: string;
}
export interface MFAEnrollmentRequiredResponse {
- mfa_enrollment_token: string;
- message: string;
+ mfa_enrollment_token: string;
+ message: string;
}
export interface MFAEnrollResponse {
- provisioning_uri: string;
- secret: string;
- message?: string;
+ provisioning_uri: string;
+ secret: string;
+ message?: string;
}
export interface ServiceKeyResponse {
- id: string;
- service_name: string;
- key_prefix: string;
- tenant_id?: string | null;
- is_active: boolean;
- created_by?: string | null;
- creator?: {
- id: string;
- email: string;
- } | null;
- created_at: string;
- last_used_at?: string | null;
- expires_at?: string | null;
- raw_key?: string; // Only present once on creation
+ id: string;
+ service_name: string;
+ key_prefix: string;
+ tenant_id?: string | null;
+ is_active: boolean;
+ created_by?: string | null;
+ creator?: {
+ id: string;
+ email: string;
+ } | null;
+ created_at: string;
+ last_used_at?: string | null;
+ expires_at?: string | null;
+ raw_key?: string; // Only present once on creation
}
export interface AuditLogEntry {
- id: string;
- _id?: string;
- action: string;
- actor_id?: string | null;
- target_user_id?: string | null;
- tenant_id?: string | null;
- resource?: string;
- resource_id?: string | null;
- resource_type?: string;
- ip_address?: string | null;
- user_agent?: string | null;
- status?: string;
- created_at: string;
- metadata?: Record | null;
- details?: Record;
+ id: string;
+ _id?: string;
+ action: string;
+ actor_id?: string | null;
+ target_user_id?: string | null;
+ tenant_id?: string | null;
+ resource?: string;
+ resource_id?: string | null;
+ resource_type?: string;
+ ip_address?: string | null;
+ user_agent?: string | null;
+ status?: string;
+ created_at: string;
+ metadata?: Record | null;
+ details?: Record;
}
export interface ContactLead {
- id: string;
- first_name: string;
- last_name: string;
- email: string;
- phone?: string;
- job_title?: string;
- company: string;
- company_size?: string;
- country?: string;
- mau?: string;
- interest?: string;
- message?: string;
- consent: boolean;
- created_at: string;
- source?: string;
- ip_address?: string;
- user_agent?: string;
+ id: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+ phone?: string;
+ job_title?: string;
+ company: string;
+ company_size?: string;
+ country?: string;
+ mau?: string;
+ interest?: string;
+ message?: string;
+ consent: boolean;
+ created_at: string;
+ source?: string;
+ ip_address?: string;
+ user_agent?: string;
}
export interface Permission {
- id: string;
- name: string;
- description?: string;
+ id: string;
+ name: string;
+ description?: string;
}
export interface Role {
- id: string;
- name: string;
- description: string;
- scope: string;
- level: number;
- tenant_id?: string | null;
- is_template?: boolean;
- template_role_id?: string | null;
- is_protected_tenant_role?: boolean;
- is_system_role?: boolean;
- is_name_locked?: boolean;
- created_at: string;
- permissions: Permission[];
- permission_ids: string[];
+ id: string;
+ name: string;
+ description: string;
+ scope: string;
+ level: number;
+ tenant_id?: string | null;
+ is_template?: boolean;
+ template_role_id?: string | null;
+ is_protected_tenant_role?: boolean;
+ is_system_role?: boolean;
+ is_name_locked?: boolean;
+ created_at: string;
+ permissions: Permission[];
+ permission_ids: string[];
+}
+
+export interface PasswordPolicy {
+ min_length: number;
+ require_uppercase: boolean;
+ require_lowercase: boolean;
+ require_digit: boolean;
+ require_special: boolean;
}
export interface TenantAuthConfig {
- allowed_methods: string[];
- mfa_required: boolean;
- session_ttl_seconds: number;
- allowed_domains: string[];
- oidc_client_id?: string;
+ id?: string;
+ tenant_id?: string;
+ allowed_methods: string[];
+ mfa_required: boolean;
+ password_policy: PasswordPolicy;
+ session_ttl_seconds: number;
+ allowed_domains: string[];
+ oidc_client_id?: string | null;
+ created_at?: string;
+ updated_at?: string;
}
export interface PublicTenantAuthConfig {
- tenant_id: string;
- allowed_methods: string[];
+ tenant_id: string;
+ allowed_methods: string[];
}
export interface EmailConfigItem {
- id: string;
- tenant_id: string;
- provider: string;
- from_email: string;
- credential_hint: string;
- is_active: boolean;
+ id: string;
+ tenant_id: string;
+ provider: string;
+ from_email: string;
+ credential_hint: string;
+ is_active: boolean;
}
export interface EmailConfigListResponse {
- items: EmailConfigItem[];
- available_providers: string[];
- using_platform_default: boolean;
- platform_provider?: string | null;
- platform_from_email?: string | null;
+ items: EmailConfigItem[];
+ available_providers: string[];
+ using_platform_default: boolean;
+ platform_provider?: string | null;
+ platform_from_email?: string | null;
}
export interface SmsConfigItem {
- id: string;
- tenant_id: string;
- provider: string;
- from_number: string;
- credential_hint: string;
- account_sid?: string | null;
- is_active: boolean;
+ id: string;
+ tenant_id: string;
+ provider: string;
+ from_number: string;
+ credential_hint: string;
+ account_sid?: string | null;
+ is_active: boolean;
}
export interface SmsConfigListResponse {
- items: SmsConfigItem[];
- available_providers: string[];
- using_platform_default: boolean;
- platform_provider?: string | null;
- platform_from_number?: string | null;
+ items: SmsConfigItem[];
+ available_providers: string[];
+ using_platform_default: boolean;
+ platform_provider?: string | null;
+ platform_from_number?: string | null;
}
export interface EmailConfigForm {
- provider: string;
- from_email: string;
- api_key: string;
- set_active: boolean;
+ provider: string;
+ from_email: string;
+ api_key: string;
+ set_active: boolean;
}
export interface SmsConfigForm {
- provider: string;
- from_number: string;
- api_key: string;
- account_sid: string;
- set_active: boolean;
+ provider: string;
+ from_number: string;
+ api_key: string;
+ account_sid: string;
+ set_active: boolean;
}
export interface OAuthProvider {
- id: string;
- name: string;
- enabled: boolean;
- client_id?: string;
- client_secret?: string;
+ id: string;
+ name: string;
+ enabled: boolean;
+ client_id?: string;
+ client_secret?: string;
}
export interface SocialProviderConfig {
- provider: string;
- client_id: string;
- client_secret: string;
- is_enabled: boolean;
+ provider: string;
+ client_id: string;
+ client_secret: string;
+ is_enabled: boolean;
}
export interface LinkedOAuthAccount {
- provider: string;
- provider_user_id: string;
- email?: string;
- linked_at: string;
+ provider: string;
+ provider_user_id: string;
+ email?: string;
+ linked_at: string;
}