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¶
- Rate limiting on admin routes
- IP allowlisting (optional) for admin access
- 2FA requirement for admin users
- Audit logging for all admin actions
- 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: