Skip to content

Last1.app Admin Panel - Prompt

Copy this prompt to implement the system admin panel on Last1.app.


Task: Implement System Admin Panel for Last1.org Staff

Create a comprehensive admin panel that allows Last1.org staff to manage users, organizations, subscriptions, and perform administrative tasks across both Last1 and RadioCheck platforms.

Admin Access Control

Who can access the admin panel: - Users with email domain @last1.org - Stored flag in database: users.is_system_admin = true

Implementation:

// server/adminGuard.ts
export function isSystemAdmin(user: User): boolean {
  // Check email domain
  if (user.email?.endsWith('@last1.org')) return true;
  // Check explicit admin flag
  if (user.is_system_admin) return true;
  return false;
}

// Middleware for admin routes
export function requireSystemAdmin(req: Request, res: Response, next: NextFunction) {
  const user = req.user;
  if (!user || !isSystemAdmin(user)) {
    return res.status(403).json({ error: 'System admin access required' });
  }
  next();
}

Database Changes

Add to your migration:

-- Add system admin flag to users
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_system_admin BOOLEAN DEFAULT false;

-- Create admin audit log
CREATE TABLE IF NOT EXISTS admin_audit_log (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  admin_user_id TEXT NOT NULL,
  action TEXT NOT NULL,
  target_type TEXT NOT NULL, -- 'user', 'organization', 'subscription'
  target_id TEXT NOT NULL,
  details JSONB,
  ip_address TEXT,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_admin_audit_admin ON admin_audit_log(admin_user_id);
CREATE INDEX idx_admin_audit_target ON admin_audit_log(target_type, target_id);
CREATE INDEX idx_admin_audit_created ON admin_audit_log(created_at DESC);

-- Grant system admin to last1.org users (run manually or via trigger)
-- UPDATE users SET is_system_admin = true WHERE email LIKE '%@last1.org';

Admin Routes

Create these API endpoints:

// server/routes/admin.ts
import express from 'express';
import { requireSystemAdmin } from '../adminGuard';

const router = express.Router();

// All admin routes require system admin
router.use(requireSystemAdmin);

// ==========================================
// USER MANAGEMENT
// ==========================================

// List all users with pagination and search
router.get('/users', async (req, res) => {
  const { page = 1, limit = 50, search, platform } = req.query;
  // platform: 'all' | 'last1' | 'radiocheck'

  const users = await db.query(`
    SELECT 
      u.*,
      (SELECT COUNT(*) FROM organization_members om WHERE om.user_id = u.id) as org_count,
      (SELECT json_agg(o.name) FROM organizations o 
       JOIN organization_members om ON o.id = om.organization_id 
       WHERE om.user_id = u.id) as organizations
    FROM users u
    WHERE ($1::text IS NULL OR u.email ILIKE '%' || $1 || '%' OR u.id::text = $1)
    ORDER BY u.created_at DESC
    LIMIT $2 OFFSET $3
  `, [search, limit, (page - 1) * limit]);

  res.json({ users, page, limit });
});

// Get single user details
router.get('/users/:userId', async (req, res) => {
  const { userId } = req.params;
  // Fetch user from both Last1 and RadioCheck databases
  const user = await getUserWithFullDetails(userId);
  res.json(user);
});

// Update user
router.patch('/users/:userId', async (req, res) => {
  const { userId } = req.params;
  const updates = req.body;

  // Audit log
  await logAdminAction(req.user.id, 'user.updated', 'user', userId, updates);

  const user = await db.query('UPDATE users SET ... WHERE id = $1', [userId]);
  res.json(user);
});

// Delete/deactivate user
router.delete('/users/:userId', async (req, res) => {
  const { userId } = req.params;
  const { hard = false } = req.query;

  if (hard) {
    // Hard delete - remove from database
    await db.query('DELETE FROM users WHERE id = $1', [userId]);
  } else {
    // Soft delete - mark as inactive
    await db.query('UPDATE users SET status = $1, deleted_at = NOW() WHERE id = $2', ['deleted', userId]);
  }

  await logAdminAction(req.user.id, hard ? 'user.hard_deleted' : 'user.soft_deleted', 'user', userId);
  res.json({ success: true });
});

// Reset user password (send reset email)
router.post('/users/:userId/reset-password', async (req, res) => {
  const { userId } = req.params;

  // Get user email
  const user = await db.query('SELECT email FROM users WHERE id = $1', [userId]);

  // Trigger password reset via Supabase Admin API
  const { error } = await supabaseAdmin.auth.admin.generateLink({
    type: 'recovery',
    email: user.email,
  });

  if (error) throw error;

  await logAdminAction(req.user.id, 'user.password_reset', 'user', userId);
  res.json({ success: true, message: 'Password reset email sent' });
});

// Force set password (for emergencies)
router.post('/users/:userId/set-password', async (req, res) => {
  const { userId } = req.params;
  const { password } = req.body;

  // Requires Supabase service role key
  const { error } = await supabaseAdmin.auth.admin.updateUserById(userId, {
    password: password,
  });

  if (error) throw error;

  await logAdminAction(req.user.id, 'user.password_forced', 'user', userId);
  res.json({ success: true });
});

// ==========================================
// ORGANIZATION MANAGEMENT
// ==========================================

// List all organizations
router.get('/organizations', async (req, res) => {
  const { page = 1, limit = 50, search, status, plan } = req.query;

  const orgs = await db.query(`
    SELECT 
      o.*,
      (SELECT COUNT(*) FROM organization_members om WHERE om.organization_id = o.id) as member_count,
      (SELECT COUNT(*) FROM beneficiaries b WHERE b.organization_id = o.id) as beneficiary_count
    FROM organizations o
    WHERE ($1::text IS NULL OR o.name ILIKE '%' || $1 || '%')
      AND ($2::text IS NULL OR o.status = $2)
      AND ($3::text IS NULL OR o.plan = $3)
    ORDER BY o.created_at DESC
    LIMIT $4 OFFSET $5
  `, [search, status, plan, limit, (page - 1) * limit]);

  res.json({ organizations: orgs, page, limit });
});

// Get single organization details
router.get('/organizations/:orgId', async (req, res) => {
  const { orgId } = req.params;
  const org = await getOrgWithFullDetails(orgId);
  res.json(org);
});

// Update organization
router.patch('/organizations/:orgId', async (req, res) => {
  const { orgId } = req.params;
  const updates = req.body;

  await logAdminAction(req.user.id, 'organization.updated', 'organization', orgId, updates);

  const org = await db.query('UPDATE organizations SET ... WHERE id = $1', [orgId]);
  res.json(org);
});

// Approve/reject organization
router.post('/organizations/:orgId/status', async (req, res) => {
  const { orgId } = req.params;
  const { status, reason } = req.body; // status: 'active' | 'suspended' | 'rejected'

  await db.query('UPDATE organizations SET status = $1 WHERE id = $2', [status, orgId]);

  await logAdminAction(req.user.id, `organization.${status}`, 'organization', orgId, { reason });
  res.json({ success: true });
});

// Add member to organization
router.post('/organizations/:orgId/members', async (req, res) => {
  const { orgId } = req.params;
  const { userId, role } = req.body;

  await db.query(`
    INSERT INTO organization_members (organization_id, user_id, role)
    VALUES ($1, $2, $3)
    ON CONFLICT (organization_id, user_id) DO UPDATE SET role = $3
  `, [orgId, userId, role]);

  await logAdminAction(req.user.id, 'organization.member_added', 'organization', orgId, { userId, role });
  res.json({ success: true });
});

// Remove member from organization
router.delete('/organizations/:orgId/members/:userId', async (req, res) => {
  const { orgId, userId } = req.params;

  await db.query('DELETE FROM organization_members WHERE organization_id = $1 AND user_id = $2', [orgId, userId]);

  await logAdminAction(req.user.id, 'organization.member_removed', 'organization', orgId, { userId });
  res.json({ success: true });
});

// ==========================================
// SUBSCRIPTION MANAGEMENT
// ==========================================

// List all subscriptions
router.get('/subscriptions', async (req, res) => {
  const subscriptions = await db.query(`
    SELECT 
      o.id,
      o.name,
      o.plan,
      o.stripe_customer_id,
      o.stripe_subscription_id,
      o.bundle_type,
      o.created_at
    FROM organizations o
    WHERE o.stripe_subscription_id IS NOT NULL
    ORDER BY o.created_at DESC
  `);

  res.json({ subscriptions });
});

// Manually change subscription/plan
router.post('/organizations/:orgId/subscription', async (req, res) => {
  const { orgId } = req.params;
  const { plan, reason } = req.body; // plan: 'free' | 'pro' | 'enterprise'

  // Update in database
  await db.query('UPDATE organizations SET plan = $1 WHERE id = $2', [plan, orgId]);

  // If they have Stripe subscription, this is a manual override
  // Log it clearly for billing reconciliation
  await logAdminAction(req.user.id, 'subscription.manual_override', 'organization', orgId, { 
    plan, 
    reason,
    warning: 'Manual plan change - may not match Stripe'
  });

  res.json({ success: true });
});

// Grant free trial/extension
router.post('/organizations/:orgId/trial', async (req, res) => {
  const { orgId } = req.params;
  const { days, plan } = req.body;

  const trialEnds = new Date();
  trialEnds.setDate(trialEnds.getDate() + days);

  await db.query(`
    UPDATE organizations 
    SET plan = $1, trial_ends_at = $2 
    WHERE id = $3
  `, [plan, trialEnds, orgId]);

  await logAdminAction(req.user.id, 'subscription.trial_granted', 'organization', orgId, { days, plan });
  res.json({ success: true, trialEnds });
});

// Sync subscription from Stripe (if out of sync)
router.post('/organizations/:orgId/sync-stripe', async (req, res) => {
  const { orgId } = req.params;

  const org = await db.query('SELECT stripe_subscription_id FROM organizations WHERE id = $1', [orgId]);

  if (org.stripe_subscription_id) {
    const subscription = await stripe.subscriptions.retrieve(org.stripe_subscription_id);
    // Update local database to match Stripe
    // ...
  }

  await logAdminAction(req.user.id, 'subscription.synced', 'organization', orgId);
  res.json({ success: true });
});

// ==========================================
// RADIOCHECK INTEGRATION
// ==========================================

// List RadioCheck users (from bridge table)
router.get('/radiocheck/users', async (req, res) => {
  const users = await db.query(`
    SELECT 
      uib.*,
      u.email,
      u.created_at
    FROM user_identity_bridge uib
    LEFT JOIN users u ON uib.last1_user_id = u.id
    ORDER BY uib.created_at DESC
  `);

  res.json({ users });
});

// List RadioCheck organizations (from bridge table)
router.get('/radiocheck/organizations', async (req, res) => {
  const orgs = await db.query(`
    SELECT 
      ob.*,
      o.name,
      o.plan
    FROM org_bridge ob
    LEFT JOIN organizations o ON ob.last1_org_id = o.id
    ORDER BY ob.created_at DESC
  `);

  res.json({ organizations: orgs });
});

// ==========================================
// AUDIT LOG
// ==========================================

router.get('/audit-log', async (req, res) => {
  const { page = 1, limit = 100, adminId, action, targetType } = req.query;

  const logs = await db.query(`
    SELECT 
      al.*,
      u.email as admin_email
    FROM admin_audit_log al
    LEFT JOIN users u ON al.admin_user_id = u.id
    WHERE ($1::text IS NULL OR al.admin_user_id = $1)
      AND ($2::text IS NULL OR al.action LIKE $2 || '%')
      AND ($3::text IS NULL OR al.target_type = $3)
    ORDER BY al.created_at DESC
    LIMIT $4 OFFSET $5
  `, [adminId, action, targetType, limit, (page - 1) * limit]);

  res.json({ logs, page, limit });
});

// ==========================================
// DASHBOARD STATS
// ==========================================

router.get('/stats', async (req, res) => {
  const stats = await db.query(`
    SELECT
      (SELECT COUNT(*) FROM users) as total_users,
      (SELECT COUNT(*) FROM users WHERE created_at > NOW() - INTERVAL '7 days') as new_users_7d,
      (SELECT COUNT(*) FROM organizations) as total_orgs,
      (SELECT COUNT(*) FROM organizations WHERE status = 'active') as active_orgs,
      (SELECT COUNT(*) FROM organizations WHERE plan = 'pro') as pro_orgs,
      (SELECT COUNT(*) FROM organizations WHERE plan = 'enterprise') as enterprise_orgs,
      (SELECT COUNT(*) FROM beneficiaries) as total_beneficiaries,
      (SELECT COUNT(*) FROM radio_check_instances WHERE created_at > NOW() - INTERVAL '24 hours') as checkins_24h
  `);

  res.json(stats);
});

export default router;

Admin UI Pages

Create these pages in your client:

1. Admin Layout (/admin)

// client/pages/admin/AdminLayout.tsx
import { useAuth } from '@/contexts/AuthContext';
import { Navigate, Outlet, Link } from 'react-router-dom';

export function AdminLayout() {
  const { user } = useAuth();

  // Check if user is system admin
  if (!user?.email?.endsWith('@last1.org') && !user?.is_system_admin) {
    return <Navigate to="/" replace />;
  }

  return (
    <div className="flex min-h-screen">
      {/* Sidebar */}
      <aside className="w-64 bg-gray-900 text-white p-4">
        <h1 className="text-xl font-bold mb-8">Last1 Admin</h1>
        <nav className="space-y-2">
          <Link to="/admin" className="block p-2 hover:bg-gray-800 rounded">Dashboard</Link>
          <Link to="/admin/users" className="block p-2 hover:bg-gray-800 rounded">Users</Link>
          <Link to="/admin/organizations" className="block p-2 hover:bg-gray-800 rounded">Organizations</Link>
          <Link to="/admin/subscriptions" className="block p-2 hover:bg-gray-800 rounded">Subscriptions</Link>
          <Link to="/admin/radiocheck" className="block p-2 hover:bg-gray-800 rounded">RadioCheck</Link>
          <Link to="/admin/audit-log" className="block p-2 hover:bg-gray-800 rounded">Audit Log</Link>
        </nav>
      </aside>

      {/* Main content */}
      <main className="flex-1 p-8 bg-gray-100">
        <Outlet />
      </main>
    </div>
  );
}

2. Admin Dashboard (/admin)

// client/pages/admin/Dashboard.tsx
export function AdminDashboard() {
  const { data: stats } = useQuery(['admin-stats'], fetchAdminStats);

  return (
    <div>
      <h1 className="text-2xl font-bold mb-8">Admin Dashboard</h1>

      <div className="grid grid-cols-4 gap-4 mb-8">
        <StatCard title="Total Users" value={stats?.total_users} />
        <StatCard title="New Users (7d)" value={stats?.new_users_7d} />
        <StatCard title="Active Orgs" value={stats?.active_orgs} />
        <StatCard title="Pro Subscribers" value={stats?.pro_orgs} />
      </div>

      <div className="grid grid-cols-2 gap-4">
        <RecentUsersCard />
        <RecentOrgsCard />
      </div>
    </div>
  );
}

3. Users Management (/admin/users)

// client/pages/admin/Users.tsx
export function AdminUsers() {
  const [search, setSearch] = useState('');
  const { data, isLoading } = useQuery(['admin-users', search], () => fetchAdminUsers({ search }));

  return (
    <div>
      <h1 className="text-2xl font-bold mb-4">User Management</h1>

      <input
        type="text"
        placeholder="Search by email or ID..."
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        className="w-full p-2 border rounded mb-4"
      />

      <table className="w-full bg-white rounded shadow">
        <thead>
          <tr className="bg-gray-50">
            <th className="p-3 text-left">Email</th>
            <th className="p-3 text-left">Created</th>
            <th className="p-3 text-left">Orgs</th>
            <th className="p-3 text-left">Actions</th>
          </tr>
        </thead>
        <tbody>
          {data?.users.map(user => (
            <tr key={user.id} className="border-t">
              <td className="p-3">{user.email}</td>
              <td className="p-3">{formatDate(user.created_at)}</td>
              <td className="p-3">{user.org_count}</td>
              <td className="p-3">
                <Link to={`/admin/users/${user.id}`} className="text-blue-600">View</Link>
                <button onClick={() => resetPassword(user.id)} className="ml-2 text-orange-600">Reset PW</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

4. User Detail (/admin/users/:id)

// client/pages/admin/UserDetail.tsx
export function AdminUserDetail() {
  const { id } = useParams();
  const { data: user } = useQuery(['admin-user', id], () => fetchAdminUser(id));

  const resetPassword = useMutation(/* ... */);
  const updateUser = useMutation(/* ... */);
  const deleteUser = useMutation(/* ... */);

  return (
    <div>
      <h1 className="text-2xl font-bold mb-4">User: {user?.email}</h1>

      <div className="grid grid-cols-2 gap-8">
        {/* User Info */}
        <div className="bg-white p-4 rounded shadow">
          <h2 className="font-bold mb-4">User Information</h2>
          <dl className="space-y-2">
            <div><dt className="font-medium">ID:</dt><dd>{user?.id}</dd></div>
            <div><dt className="font-medium">Email:</dt><dd>{user?.email}</dd></div>
            <div><dt className="font-medium">Created:</dt><dd>{formatDate(user?.created_at)}</dd></div>
          </dl>
        </div>

        {/* Actions */}
        <div className="bg-white p-4 rounded shadow">
          <h2 className="font-bold mb-4">Actions</h2>
          <div className="space-y-2">
            <button 
              onClick={() => resetPassword.mutate(id)}
              className="w-full p-2 bg-orange-500 text-white rounded"
            >
              Send Password Reset Email
            </button>
            <button 
              onClick={() => setShowForcePassword(true)}
              className="w-full p-2 bg-red-500 text-white rounded"
            >
              Force Set Password
            </button>
            <button 
              onClick={() => deleteUser.mutate(id)}
              className="w-full p-2 bg-gray-500 text-white rounded"
            >
              Deactivate User
            </button>
          </div>
        </div>

        {/* Organizations */}
        <div className="bg-white p-4 rounded shadow col-span-2">
          <h2 className="font-bold mb-4">Organizations</h2>
          {/* List user's organizations */}
        </div>
      </div>
    </div>
  );
}

Security Considerations

  1. Rate limiting on admin routes
  2. IP allowlisting (optional) for admin access
  3. 2FA requirement for admin users
  4. Audit logging for all admin actions
  5. Session timeout shorter for admin sessions
// Additional security middleware
router.use(rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100 // limit each IP to 100 requests per windowMs
}));

Routes Summary

Route Method Description
/admin/stats GET Dashboard statistics
/admin/users GET List all users
/admin/users/:id GET Get user details
/admin/users/:id PATCH Update user
/admin/users/:id DELETE Delete/deactivate user
/admin/users/:id/reset-password POST Send password reset
/admin/users/:id/set-password POST Force set password
/admin/organizations GET List all organizations
/admin/organizations/:id GET Get org details
/admin/organizations/:id PATCH Update org
/admin/organizations/:id/status POST Approve/suspend org
/admin/organizations/:id/members POST Add member
/admin/organizations/:id/members/:userId DELETE Remove member
/admin/organizations/:id/subscription POST Manual plan change
/admin/organizations/:id/trial POST Grant trial
/admin/subscriptions GET List all subscriptions
/admin/radiocheck/users GET List RadioCheck users
/admin/radiocheck/organizations GET List RadioCheck orgs
/admin/audit-log GET View admin actions

Environment Variables

Add to your Last1 environment:

# Supabase Admin (service role for password management)
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key

# Admin settings
ADMIN_EMAIL_DOMAIN=last1.org
ADMIN_SESSION_TIMEOUT=3600  # 1 hour