readysite / website / frontend / pages / Audit.jsx
9.9 KB
Audit.jsx
import * as React from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiGet } from '../hooks/useAPI.js';
import { useChat } from '../layouts/AdminLayout.jsx';

export function Audit() {
  const { toggleChat } = useChat() || {};
  const [logs, setLogs] = useState([]);
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [loadingMore, setLoadingMore] = useState(false);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const [totalItems, setTotalItems] = useState(0);
  const [action, setAction] = useState('');
  const [resourceType, setResourceType] = useState('');
  const [userId, setUserId] = useState('');
  const scrollRef = useRef(null);
  const observerRef = useRef(null);

  // Fetch users for filter dropdown
  useEffect(() => {
    apiGet('/api/users?limit=100').then((data) => {
      setUsers(data?.items || []);
    });
  }, []);

  const fetchLogs = useCallback((pageNum, append = false) => {
    if (append) {
      setLoadingMore(true);
    } else {
      setLoading(true);
    }

    const params = new URLSearchParams();
    params.set('page', pageNum.toString());
    if (action) params.set('action', action);
    if (resourceType) params.set('resourceType', resourceType);
    if (userId) params.set('userId', userId);

    apiGet(`/api/admin/audit?${params.toString()}`)
      .then((data) => {
        const items = data?.items || [];
        if (append) {
          setLogs((prev) => [...prev, ...items]);
        } else {
          setLogs(items);
        }
        setTotalItems(data?.totalItems || 0);
        setHasMore(pageNum < (data?.totalPages || 1));
      })
      .finally(() => {
        setLoading(false);
        setLoadingMore(false);
      });
  }, [action, resourceType, userId]);

  // Initial load and filter changes
  useEffect(() => {
    setPage(1);
    setLogs([]);
    fetchLogs(1, false);
  }, [action, resourceType, userId]);

  // Infinite scroll observer
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
          setPage((prev) => {
            const nextPage = prev + 1;
            fetchLogs(nextPage, true);
            return nextPage;
          });
        }
      },
      { threshold: 0.1 }
    );

    if (observerRef.current) {
      observer.observe(observerRef.current);
    }

    return () => observer.disconnect();
  }, [hasMore, loading, loadingMore, fetchLogs]);

  const clearFilters = () => {
    setAction('');
    setResourceType('');
    setUserId('');
  };

  const hasFilters = action || resourceType || userId;

  return (
    <div className="flex flex-col h-full">
      {/* Header */}
      <div className="flex items-center justify-between px-4 py-3 border-b border-base-300 bg-base-200/50">
        <div>
          <h2 className="font-semibold">Audit</h2>
          <p className="text-xs text-base-content/50">Activity history</p>
        </div>
        <div className="flex items-center gap-2">
          {toggleChat && (
            <button onClick={toggleChat} className="btn btn-soft btn-primary btn-sm gap-2">
              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
              </svg>
              AI Assistant
            </button>
          )}
        </div>
      </div>

      {/* Filters */}
      <div className="px-4 py-2 border-b border-base-300 bg-base-100/50">
        <div className="flex gap-2 items-center justify-between">
          <div className="flex gap-2 items-center">
            <select
              value={action}
              onChange={(e) => setAction(e.target.value)}
              className="select select-sm select-bordered w-36"
            >
              <option value="">All Actions</option>
              <option value="create">Create</option>
              <option value="update">Update</option>
              <option value="delete">Delete</option>
            </select>
            <select
              value={resourceType}
              onChange={(e) => setResourceType(e.target.value)}
              className="select select-sm select-bordered w-40"
            >
              <option value="">All Resources</option>
              <option value="page">Pages</option>
              <option value="partial">Partials</option>
              <option value="collection">Collections</option>
              <option value="document">Documents</option>
              <option value="file">Files</option>
              <option value="user">Users</option>
            </select>
            <select
              value={userId}
              onChange={(e) => setUserId(e.target.value)}
              className="select select-sm select-bordered w-44"
            >
              <option value="">All Users</option>
              {users.map((user) => (
                <option key={user.id} value={user.id}>
                  {user.name || user.email}
                </option>
              ))}
            </select>
            {hasFilters && (
              <button onClick={clearFilters} className="btn btn-ghost btn-sm">
                Clear
              </button>
            )}
          </div>
          <span className="text-xs text-base-content/50">
            {totalItems} {totalItems === 1 ? 'entry' : 'entries'}
          </span>
        </div>
      </div>

      {/* Content */}
      <div className="flex-1 overflow-auto" ref={scrollRef}>
        {loading && logs.length === 0 ? (
          <div className="flex justify-center py-12">
            <span className="loading loading-spinner loading-lg" />
          </div>
        ) : logs.length ? (
          <>
            <table className="table table-zebra table-pin-rows">
              <thead>
                <tr className="text-base-content/70 bg-base-200">
                  <th>Time</th>
                  <th>User</th>
                  <th>Action</th>
                  <th>Resource</th>
                  <th>Name</th>
                  <th>IP</th>
                </tr>
              </thead>
              <tbody>
                {logs.map((log) => (
                  <tr key={log.id} className="hover">
                    <td className="whitespace-nowrap text-sm">
                      <span title={new Date(log.created).toLocaleString()}>
                        {new Date(log.created).toLocaleDateString('en-US', {
                          month: 'short',
                          day: 'numeric',
                          hour: 'numeric',
                          minute: '2-digit',
                        })}
                      </span>
                    </td>
                    <td className="text-sm">
                      {log.userName ? (
                        <>
                          <span className="font-medium">{log.userName}</span>
                          <span className="text-base-content/50"> ({log.userEmail})</span>
                        </>
                      ) : (
                        <span className="text-base-content/50">Unknown</span>
                      )}
                    </td>
                    <td>
                      {log.action === 'create' && (
                        <span className="badge badge-soft badge-success badge-sm">Create</span>
                      )}
                      {log.action === 'update' && (
                        <span className="badge badge-soft badge-warning badge-sm">Update</span>
                      )}
                      {log.action === 'delete' && (
                        <span className="badge badge-soft badge-error badge-sm">Delete</span>
                      )}
                      {!['create', 'update', 'delete'].includes(log.action) && (
                        <span className="badge badge-soft badge-ghost badge-sm">{log.action}</span>
                      )}
                    </td>
                    <td className="text-sm capitalize">{log.resourceType}</td>
                    <td className="text-sm max-w-xs truncate" title={log.resourceName || log.resourceId}>
                      {log.resourceName || (
                        <span className="text-base-content/30">{log.resourceId}</span>
                      )}
                    </td>
                    <td className="text-sm text-base-content/50">{log.ip}</td>
                  </tr>
                ))}
              </tbody>
            </table>

            {/* Infinite scroll trigger */}
            <div ref={observerRef} className="py-4 flex justify-center">
              {loadingMore && <span className="loading loading-spinner loading-md" />}
              {!hasMore && logs.length > 0 && (
                <span className="text-xs text-base-content/40">No more entries</span>
              )}
            </div>
          </>
        ) : (
          <div className="flex flex-col items-center justify-center h-full text-center px-6">
            <div className="w-16 h-16 rounded-2xl bg-base-200 flex items-center justify-center mb-4">
              <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-base-content/30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
              </svg>
            </div>
            <h3 className="text-lg font-semibold mb-1">No audit logs yet</h3>
            <p className="text-base-content/50 text-sm">Activity will appear here as changes are made.</p>
          </div>
        )}
      </div>
    </div>
  );
}
← Back