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
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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)',
},
};