Skip to content

Admin Dashboard

The admin dashboard is a full-featured management panel served at admin.myapp.test. It provides CRUD interfaces for every resource in the system, permission-gated navigation, and a dashboard with real-time statistics. The entire UI is built with shadcn/ui components and follows a consistent sidebar + header + content layout.

Layout Structure

Every authenticated page in the admin panel renders within a three-part layout:

┌──────────────────────────────────────────────────────────┐
│ ┌──────────┐ ┌─────────────────────────────────────────┐ │
│ │          │ │              Header                     │ │
│ │          │ │  (Theme toggle, Language, User menu)     │ │
│ │          │ ├─────────────────────────────────────────┤ │
│ │ Sidebar  │ │                                         │ │
│ │          │ │            Main Content                  │ │
│ │ (Nav)    │ │                                         │ │
│ │          │ │  ┌─────────────────────────────────┐     │ │
│ │          │ │  │      Page Component             │     │ │
│ │          │ │  │                                 │     │ │
│ │          │ │  │  (DataTable, Forms, etc.)       │     │ │
│ │          │ │  │                                 │     │ │
│ │          │ │  └─────────────────────────────────┘     │ │
│ └──────────┘ └─────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘

The layout is defined in app/(dashboard)/layout.tsx and wraps all dashboard routes:

tsx
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex h-screen">
      <Sidebar />
      <div className="flex flex-1 flex-col overflow-hidden">
        <Header />
        <main className="flex-1 overflow-y-auto p-6">
          {children}
        </main>
      </div>
    </div>
  );
}

The sidebar renders navigation items based on the authenticated user's permissions. Each item maps to a permission string -- if the user lacks the required permission, the item is not rendered at all:

typescript
const navItems: NavItem[] = [
  {
    titleKey: "admin.nav.Dashboard",
    href: "/",
    icon: LayoutDashboard,
    permission: "dashboard.view",
  },
  {
    titleKey: "admin.nav.Users",
    icon: Users,
    permission: "users.view",
    children: [
      { titleKey: "admin.nav.All_Users", href: "/users", permission: "users.view" },
      { titleKey: "admin.nav.Create_User", href: "/users/create", permission: "users.create" },
    ],
  },
  {
    titleKey: "admin.nav.Contents",
    href: "/contents",
    icon: Newspaper,
    permission: "contents.view",
  },
  {
    titleKey: "admin.nav.Menus",
    href: "/menus",
    icon: Navigation,
    permission: "menus.view",
  },
  {
    titleKey: "admin.nav.Lookups",
    href: "/lookups",
    icon: ListTree,
    permission: "lookups.view",
  },
  {
    titleKey: "admin.nav.Media",
    href: "/media",
    icon: ImageIcon,
    permission: "media.view",
  },
  // ...additional items
];

Each NavItem uses a usePermission hook that checks against the user's permission array:

typescript
const hasPermission = usePermission(item.permission || "");

// If permission is required and user lacks it, hide the item
if (item.permission && !hasPermission) {
  return null;
}

TIP

Navigation items with children render as collapsible sections using Radix UI's Collapsible component. The sidebar itself is collapsible on desktop (icon-only mode) and slides in as an overlay on mobile.

Login Page

The admin login page (app/(auth)/login/page.tsx) provides credential-based authentication with optional OTP support:

┌─────────────────────────────────┐
│           Admin Login            │
│                                 │
│  ┌───────────────────────────┐  │
│  │ Email                     │  │
│  └───────────────────────────┘  │
│  ┌───────────────────────────┐  │
│  │ Password                  │  │
│  └───────────────────────────┘  │
│                                 │
│  [ Login ]                      │
│                                 │
│  ── or ──                       │
│                                 │
│  [ Login with OTP ]             │
└─────────────────────────────────┘

On successful login, the JWT access token is stored in localStorage and the user is redirected to the dashboard. The AuthProvider then calls /auth/me to load the user's profile, roles, and permissions.

Dashboard Page

The dashboard page (app/(dashboard)/page.tsx) displays statistics and recent activity:

SectionContent
Stats CardsTotal users, active roles, content pages, recent audit events
Recent ActivityLatest audit log entries with user, action, resource, and timestamp
tsx
import { Stats } from "@/components/dashboard/stats";
import { RecentActivity } from "@/components/dashboard/recent-activity";

export default function DashboardPage() {
  return (
    <div className="space-y-8">
      <div>
        <h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
        <p className="text-muted-foreground">
          Overview of your application
        </p>
      </div>
      <Stats />
      <RecentActivity />
    </div>
  );
}

CRUD Pages

Every resource follows the same page structure pattern. Here is the complete list of admin CRUD modules:

ModuleRoutePermissionsFeatures
Users/usersusers.view/create/edit/deleteList, create, edit, delete, role assignment
Roles/rolesroles.view/create/edit/deleteList, create, edit, permission assignment
Permissions/permissionspermissions.viewRead-only hierarchical view
Contents/contentscontents.view/create/edit/deleteRich text editor, translations, SEO, media
Menus/menusmenus.view/create/edit/deleteHierarchical navigation builder
Lookups/lookupslookups.view/create/edit/deleteReference data with icons and colors
Media/mediamedia.view/upload/deleteDrag-drop upload, file browser, copy URL
Translations/translationstranslations.view/editKey-value translation editor
Settings/settingssettings.view/editGrouped application settings
API Keys/api-keysapi_keys.view/create/deleteKey generation with permission scoping
Audit Logs/auditaudit.viewRead-only activity log with filters
Profile/profile(any authenticated user)Edit name, email, password, avatar

Page Structure Convention

Each CRUD module follows a consistent file layout:

app/(dashboard)/users/
├── page.tsx           # List page with DataTable
├── columns.tsx        # Column definitions for DataTable
├── create/
│   └── page.tsx       # Create form
└── [id]/
    ├── page.tsx       # View/detail page
    └── edit/
        └── page.tsx   # Edit form

Users List Page

The users list page demonstrates the standard CRUD pattern used throughout the admin panel:

tsx
"use client";

import * as React from "react";
import Link from "next/link";
import { toast } from "sonner";
import { DataTable } from "@/components/ui/data-table";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { usePermission } from "@/components/providers/auth-provider";
import { useTranslation } from "@/components/providers/i18n-provider";
import { api, getErrorMessage } from "@/lib/api";
import { getColumns } from "./columns";
import type { User } from "@/lib/types";
import { Plus } from "lucide-react";

export default function UsersPage() {
  const { t } = useTranslation();
  const canCreate = usePermission("users.create");
  const canEdit = usePermission("users.edit");
  const canDelete = usePermission("users.delete");

  const [users, setUsers] = React.useState<User[]>([]);
  const [isLoading, setIsLoading] = React.useState(true);
  const [deleteUser, setDeleteUser] = React.useState<User | null>(null);
  const [isDeleting, setIsDeleting] = React.useState(false);

  const fetchUsers = React.useCallback(async () => {
    setIsLoading(true);
    try {
      const response = await api.get<{
        data: { data: User[]; meta: unknown };
      }>("/admin/users");
      setUsers(response.data.data);
    } catch (error) {
      toast.error(getErrorMessage(error));
    } finally {
      setIsLoading(false);
    }
  }, []);

  React.useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);

  const handleDelete = async () => {
    if (!deleteUser) return;
    setIsDeleting(true);
    try {
      await api.delete(`/admin/users/${deleteUser.id}`);
      toast.success(t("admin.users.User_deleted_successfully"));
      setDeleteUser(null);
      fetchUsers();
    } catch (error) {
      toast.error(getErrorMessage(error));
    } finally {
      setIsDeleting(false);
    }
  };

  const columns = React.useMemo(
    () => getColumns({ onDelete: setDeleteUser, canEdit, canDelete }),
    [canEdit, canDelete]
  );

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-bold tracking-tight">
            {t("admin.users.Users")}
          </h1>
        </div>
        {canCreate && (
          <Button asChild>
            <Link href="/users/create">
              <Plus className="mr-2 h-4 w-4" />
              {t("admin.users.Add_User")}
            </Link>
          </Button>
        )}
      </div>

      <DataTable
        columns={columns}
        data={users}
        searchKey="name"
        searchPlaceholder={t("admin.users.Filter_users")}
      />

      <ConfirmDialog
        open={!!deleteUser}
        onOpenChange={(open) => !open && setDeleteUser(null)}
        title={t("admin.users.Delete_User")}
        description={t("admin.users.Are_you_sure_you_want_to_delete_this_user?")}
        confirmText={t("common.Delete")}
        variant="destructive"
        isLoading={isDeleting}
        onConfirm={handleDelete}
      />
    </div>
  );
}

WARNING

Every action button and menu item checks permissions before rendering. The usePermission hook returns false if the user lacks the specified permission, allowing you to conditionally show or hide create, edit, and delete controls.

Role Management

The role management page includes a permission matrix for assigning permissions to roles. The PermissionSelector component displays permissions grouped by category with select-all controls:

tsx
import { PermissionSelector } from "@/components/ui/permission-selector";

<PermissionSelector
  permissions={allPermissions}
  selected={selectedPermissionIds}
  onChange={setSelectedPermissionIds}
/>

Permissions are displayed in a hierarchical tree:

Users
  ├── [x] View Users
  ├── [x] Create Users
  ├── [x] Edit Users
  └── [ ] Delete Users

Contents
  ├── [x] View Contents
  ├── [x] Create Contents
  ├── [x] Edit Contents
  └── [x] Delete Contents

Content Management

The content editor provides a rich editing experience with multi-language support, SEO fields, and media management:

┌─────────────────────────────────────────────────┐
│  Create Content                                  │
│                                                  │
│  ┌─────────────────────────────────────────────┐ │
│  │ [English] [Arabic]                          │ │
│  │                                             │ │
│  │ Title:    [________________________]        │ │
│  │ Summary:  [________________________]        │ │
│  │ Details:  ┌─────────────────────────┐       │ │
│  │           │  Rich Text Editor       │       │ │
│  │           │  (TinyMCE)              │       │ │
│  │           └─────────────────────────┘       │ │
│  │ SEO Title: [_______________________]        │ │
│  │ SEO Desc:  [_______________________]        │ │
│  └─────────────────────────────────────────────┘ │
│                                                  │
│  Slug:       [________________________]          │
│  Status:     [Published v]                       │
│  Featured:   [Upload Image]                      │
│                                                  │
│  [Save Content]                                  │
└─────────────────────────────────────────────────┘

The ContentTranslationsInput component handles per-language tabs for title, summary, details (rich text), button text, and SEO metadata.

The menu builder provides a tree interface for managing navigation menus. Each menu item supports:

FieldDescription
titleDisplay text (translatable)
urlLink destination
target_self or _blank
iconLucide icon name
visibilitypublic, auth, or guest
parent_idFor nesting (hierarchical)
sort_orderDisplay order

Menu items can be reordered via drag-and-drop and nested to create multi-level navigation structures.

Settings Page

The settings page displays application settings grouped by category. Each group renders as a card with its fields:

tsx
// Settings are organized by group
const groups = [
  { key: "general", label: "General Settings" },
  { key: "auth", label: "Authentication" },
  { key: "email", label: "Email Settings" },
  { key: "storage", label: "Storage Settings" },
];

Settings support various input types (text, number, boolean, select) and are saved as key-value pairs via the Settings API.

Audit Logs

The audit log viewer provides a read-only view of system activity with filtering:

FilterOptions
Actioncreate, update, delete, login, logout
Resourceusers, roles, contents, settings, etc.
UserFilter by acting user
Date RangeStart and end date pickers

Each log entry shows the user who performed the action, the resource affected, the action type, timestamp, and optionally the changed data (before/after diff).

Permission Gating

Permission checks happen at two levels in the admin dashboard:

  1. Navigation level -- Sidebar items are hidden if the user lacks the required permission
  2. Page level -- Action buttons (create, edit, delete) are conditionally rendered
typescript
// Check a single permission
const canEdit = usePermission("users.edit");

// Use in JSX
{canEdit && (
  <Button asChild>
    <Link href={`/users/${user.id}/edit`}>Edit</Link>
  </Button>
)}

The usePermission hook reads from the AuthProvider context, which stores the user's complete permission list after login:

typescript
export function usePermission(permission: string): boolean {
  const { user } = useAuth();

  if (!user) return false;
  if (user.is_super_admin) return true;

  return user.permissions?.includes(permission) ?? false;
}

TIP

Super admins (is_super_admin: true) bypass all permission checks. This is handled at both the frontend (all UI is visible) and backend (middleware skips RBAC) layers.

Authentication Flow

The admin app uses a dedicated AuthProvider that wraps all pages. On mount, it calls /auth/me to verify the current JWT token:

┌──────────┐    ┌──────────────┐    ┌──────────────┐
│  Mount   │───>│ Check Token  │───>│  /auth/me    │
└──────────┘    └──────┬───────┘    └──────┬───────┘
                       │                   │
                ┌──────┴──────┐     ┌──────┴──────┐
                │ No Token    │     │ Valid Token  │
                │ (public     │     │ Set user     │
                │  route? OK) │     │ state        │
                └──────┬──────┘     └─────────────┘

                ┌──────┴──────┐
                │ Protected   │
                │ route?      │
                │ Redirect to │
                │ /login      │
                └─────────────┘

Theme Support

The admin dashboard supports dark and light themes via ThemeProvider (powered by next-themes). A ThemeToggle button in the header switches between modes. Theme preference is persisted in localStorage.

API Key Management

The API key management page allows administrators to create and revoke API keys for programmatic access:

  • Create key -- Set a name, select permissions, and optionally set an expiration date
  • View keys -- List all keys with their last used timestamp and status
  • Revoke key -- Permanently disable a key (cannot be re-enabled)

WARNING

API key secrets are displayed only once at creation time. After the creation dialog closes, the secret cannot be retrieved again. Advise users to copy the key immediately.

What's Next

Released under the MIT License.