Sergei ec63a2c1e2 Add admin panel, optimized API, OTP auth, migrations
Admin Panel (Next.js):
- Dashboard with stats
- Users list with relationships (watches/watched_by)
- User detail pages
- Deployments list and detail pages
- Devices, Orders, Subscriptions pages
- OTP-based admin authentication

Backend Optimizations:
- Fixed N+1 query problem in admin APIs
- Added pagination support
- Added .range() and count support to Supabase wrapper
- Optimized batch queries with lookup maps

Database:
- Added migrations for schema evolution
- New tables: push_tokens, notification_settings
- Updated access model

iOS Build Scripts:
- build-ios.sh, clear-apple-cache.sh
- EAS configuration updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-20 11:05:39 -08:00

397 lines
10 KiB
JavaScript

'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import AdminLayout from '../../components/AdminLayout';
import { getUsers } from '../../lib/api';
export default function UsersPage() {
const router = useRouter();
const [users, setUsers] = useState([]);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('all');
const [loading, setLoading] = useState(true);
const [expandedUser, setExpandedUser] = useState(null);
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
const data = await getUsers();
setUsers(data.users || []);
} catch (err) {
console.error('Failed to load users:', err);
} finally {
setLoading(false);
}
};
const filteredUsers = users.filter((user) => {
// Search filter
if (search) {
const s = search.toLowerCase();
const matchesSearch =
user.email?.toLowerCase().includes(s) ||
user.first_name?.toLowerCase().includes(s) ||
user.last_name?.toLowerCase().includes(s);
if (!matchesSearch) return false;
}
// Type filter
if (filter === 'beneficiaries') return user.is_beneficiary;
if (filter === 'caretakers') return user.is_caretaker;
if (filter === 'admins') return user.role === 'admin';
return true;
});
const stats = {
total: users.length,
beneficiaries: users.filter(u => u.is_beneficiary).length,
caretakers: users.filter(u => u.is_caretaker).length,
admins: users.filter(u => u.role === 'admin').length,
};
return (
<AdminLayout>
<div style={styles.header}>
<h1 style={styles.title}>Users</h1>
<div style={styles.controls}>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
style={styles.filterSelect}
>
<option value="all">All Users</option>
<option value="beneficiaries">Beneficiaries</option>
<option value="caretakers">Caretakers</option>
<option value="admins">Admins</option>
</select>
<input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={styles.search}
/>
</div>
</div>
{/* Stats */}
<div style={styles.statsGrid}>
<StatCard label="Total Users" value={stats.total} icon="👥" />
<StatCard label="Beneficiaries" value={stats.beneficiaries} icon="🧓" color="#10B981" />
<StatCard label="Caretakers" value={stats.caretakers} icon="👨‍⚕️" color="#6366F1" />
<StatCard label="Admins" value={stats.admins} icon="🛡️" color="#F59E0B" />
</div>
{loading ? (
<p style={styles.loading}>Loading...</p>
) : filteredUsers.length === 0 ? (
<div style={styles.empty}>
<p>No users found</p>
</div>
) : (
<div style={styles.userList}>
{filteredUsers.map((user) => (
<UserCard
key={user.id}
user={user}
expanded={expandedUser === user.id}
onToggle={() => setExpandedUser(expandedUser === user.id ? null : user.id)}
onView={() => router.push(`/users/${user.id}`)}
/>
))}
</div>
)}
</AdminLayout>
);
}
function StatCard({ label, value, icon, color = '#3B82F6' }) {
return (
<div style={styles.statCard}>
<div style={styles.statIcon}>{icon}</div>
<div style={styles.statInfo}>
<div style={{ ...styles.statValue, color }}>{value}</div>
<div style={styles.statLabel}>{label}</div>
</div>
</div>
);
}
function UserCard({ user, expanded, onToggle, onView }) {
const userName = user.first_name || user.last_name
? `${user.first_name || ''} ${user.last_name || ''}`.trim()
: 'Unnamed User';
const hasRelationships = (user.watches?.length > 0) || (user.watched_by?.length > 0);
return (
<div style={styles.userCard}>
<div style={styles.userMain}>
<div style={styles.userInfo} onClick={hasRelationships ? onToggle : undefined}>
<div style={styles.userName}>{userName}</div>
<div style={styles.userEmail}>{user.email}</div>
{user.phone && <div style={styles.userPhone}>{user.phone}</div>}
</div>
<div style={styles.badges}>
{user.role === 'admin' && <Badge text="Admin" color="#F59E0B" />}
{user.is_beneficiary && <Badge text="Beneficiary" color="#10B981" />}
{user.is_caretaker && <Badge text="Caretaker" color="#6366F1" />}
</div>
<div style={styles.userMeta}>
<span style={styles.date}>
Joined {new Date(user.created_at).toLocaleDateString()}
</span>
{hasRelationships && (
<span style={styles.expandIcon} onClick={onToggle}>{expanded ? '▼' : '▶'}</span>
)}
<button onClick={onView} style={styles.viewBtn}>View</button>
</div>
</div>
{expanded && hasRelationships && (
<div style={styles.relationships}>
{user.watched_by?.length > 0 && (
<div style={styles.relationSection}>
<div style={styles.relationTitle}>👀 Watched by:</div>
<div style={styles.relationList}>
{user.watched_by.map((rel, idx) => (
<div key={idx} style={styles.relationItem}>
<span style={styles.relationEmail}>{rel.accessor_email}</span>
<RoleBadge role={rel.role} />
</div>
))}
</div>
</div>
)}
{user.watches?.length > 0 && (
<div style={styles.relationSection}>
<div style={styles.relationTitle}>🔍 Watches:</div>
<div style={styles.relationList}>
{user.watches.map((rel, idx) => (
<div key={idx} style={styles.relationItem}>
<span style={styles.relationEmail}>{rel.beneficiary_email}</span>
<RoleBadge role={rel.role} />
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
function Badge({ text, color }) {
return (
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: '600',
background: `${color}20`,
color: color,
}}>
{text}
</span>
);
}
function RoleBadge({ role }) {
const colors = {
owner: '#EF4444',
caretaker: '#6366F1',
installer: '#8B5CF6',
};
const color = colors[role] || '#6B7280';
return (
<span style={{
padding: '2px 8px',
borderRadius: '8px',
fontSize: '10px',
fontWeight: '500',
background: `${color}15`,
color: color,
textTransform: 'capitalize',
}}>
{role}
</span>
);
}
const styles = {
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px',
flexWrap: 'wrap',
gap: '16px',
},
title: {
fontSize: '24px',
fontWeight: '600',
},
controls: {
display: 'flex',
gap: '12px',
alignItems: 'center',
},
filterSelect: {
padding: '10px 16px',
fontSize: '14px',
border: '1px solid var(--border)',
borderRadius: '8px',
background: 'white',
cursor: 'pointer',
outline: 'none',
},
search: {
padding: '10px 16px',
fontSize: '14px',
border: '1px solid var(--border)',
borderRadius: '8px',
width: '240px',
outline: 'none',
},
statsGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: '16px',
marginBottom: '24px',
},
statCard: {
background: 'white',
borderRadius: '12px',
padding: '20px',
display: 'flex',
alignItems: 'center',
gap: '16px',
},
statIcon: {
fontSize: '28px',
},
statInfo: {
flex: 1,
},
statValue: {
fontSize: '28px',
fontWeight: '700',
},
statLabel: {
fontSize: '13px',
color: 'var(--text-muted)',
},
loading: {
color: 'var(--text-muted)',
textAlign: 'center',
padding: '48px',
},
empty: {
background: 'white',
borderRadius: '12px',
padding: '48px',
textAlign: 'center',
color: 'var(--text-muted)',
},
userList: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
},
userCard: {
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
},
userMain: {
display: 'grid',
gridTemplateColumns: '1fr auto auto',
gap: '20px',
padding: '20px',
alignItems: 'center',
cursor: 'pointer',
},
userInfo: {
minWidth: 0,
},
userName: {
fontSize: '15px',
fontWeight: '600',
marginBottom: '4px',
},
userEmail: {
fontSize: '14px',
color: 'var(--text-secondary)',
},
userPhone: {
fontSize: '13px',
color: 'var(--text-muted)',
marginTop: '2px',
},
badges: {
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
},
userMeta: {
display: 'flex',
alignItems: 'center',
gap: '12px',
color: 'var(--text-muted)',
},
date: {
fontSize: '13px',
},
expandIcon: {
fontSize: '12px',
color: 'var(--text-muted)',
cursor: 'pointer',
},
viewBtn: {
padding: '6px 12px',
background: 'var(--primary)',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
},
relationships: {
background: 'var(--surface)',
padding: '16px 20px',
borderTop: '1px solid var(--border)',
},
relationSection: {
marginBottom: '12px',
},
relationTitle: {
fontSize: '12px',
fontWeight: '600',
color: 'var(--text-muted)',
marginBottom: '8px',
},
relationList: {
display: 'flex',
flexDirection: 'column',
gap: '6px',
},
relationItem: {
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '13px',
},
relationEmail: {
color: 'var(--text-secondary)',
},
};