feat: implement comprehensive admin dashboard for server management and user oversight #5

Merged
nvrl merged 2 commits from release/v1.3.0 into main 2026-02-21 03:35:23 +01:00
5 changed files with 203 additions and 6 deletions
Showing only changes of commit 6dc940bb3a - Show all commits

View File

@@ -401,9 +401,14 @@ ipcMain.handle('login-to-server', async () => {
const saveServerAuth = (token: string) => { const saveServerAuth = (token: string) => {
if (captured) return; captured = true; if (captured) return; captured = true;
let serverSteamId = undefined; let serverSteamId = undefined;
try { const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64').toString()); serverSteamId = payload.steamId; } catch (e) {} let isAdmin = false;
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64').toString());
serverSteamId = payload.steamId;
isAdmin = !!payload.isAdmin;
} catch (e) {}
const current = store.get('serverConfig'); const current = store.get('serverConfig');
store.set('serverConfig', { ...current, token, serverSteamId, enabled: true }); store.set('serverConfig', { ...current, token, serverSteamId, isAdmin, enabled: true });
initBackend(); initBackend();
authWindow.close(); authWindow.close();
resolve(true); resolve(true);
@@ -502,6 +507,14 @@ ipcMain.handle('revoke-all-account-access', async (event, steamId: string) => {
ipcMain.handle('get-community-accounts', async () => { initBackend(); return backend ? await backend.getCommunityAccounts() : []; }); ipcMain.handle('get-community-accounts', async () => { initBackend(); return backend ? await backend.getCommunityAccounts() : []; });
ipcMain.handle('get-server-users', async () => { initBackend(); return backend ? await backend.getServerUsers() : []; }); ipcMain.handle('get-server-users', async () => { initBackend(); return backend ? await backend.getServerUsers() : []; });
// --- Admin IPC ---
ipcMain.handle('admin-get-stats', async () => { initBackend(); return backend ? await backend.getAdminStats() : null; });
ipcMain.handle('admin-get-users', async () => { initBackend(); return backend ? await backend.getAdminUsers() : []; });
ipcMain.handle('admin-delete-user', async (event, userId: string) => { initBackend(); if (backend) await backend.deleteUser(userId); return true; });
ipcMain.handle('admin-get-accounts', async () => { initBackend(); return backend ? await backend.getAdminAccounts() : []; });
ipcMain.handle('admin-remove-account', async (event, steamId: string) => { initBackend(); if (backend) await backend.forceRemoveAccount(steamId); return true; });
ipcMain.handle('switch-account', async (event, loginName: string) => await handleSwitchAccount(loginName)); ipcMain.handle('switch-account', async (event, loginName: string) => await handleSwitchAccount(loginName));
ipcMain.handle('open-external', (event, url: string) => shell.openExternal(url)); ipcMain.handle('open-external', (event, url: string) => shell.openExternal(url));

View File

@@ -22,6 +22,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
getCommunityAccounts: () => ipcRenderer.invoke('get-community-accounts'), getCommunityAccounts: () => ipcRenderer.invoke('get-community-accounts'),
getServerUsers: () => ipcRenderer.invoke('get-server-users'), getServerUsers: () => ipcRenderer.invoke('get-server-users'),
// Admin API
adminGetStats: () => ipcRenderer.invoke('admin-get-stats'),
adminGetUsers: () => ipcRenderer.invoke('admin-get-users'),
adminDeleteUser: (userId: string) => ipcRenderer.invoke('admin-delete-user', userId),
adminGetAccounts: () => ipcRenderer.invoke('admin-get-accounts'),
adminRemoveAccount: (steamId: string) => ipcRenderer.invoke('admin-remove-account', steamId),
onAccountsUpdated: (callback: (accounts: any[]) => void) => { onAccountsUpdated: (callback: (accounts: any[]) => void) => {
const subscription = (_event: IpcRendererEvent, accounts: any[]) => callback(accounts); const subscription = (_event: IpcRendererEvent, accounts: any[]) => callback(accounts);
ipcRenderer.on('accounts-updated', subscription); ipcRenderer.on('accounts-updated', subscription);

View File

@@ -119,4 +119,48 @@ export class BackendService {
throw new Error(e.response?.data?.message || 'Failed to revoke all access'); throw new Error(e.response?.data?.message || 'Failed to revoke all access');
} }
} }
// --- Admin API ---
public async getAdminStats() {
if (!this.token) return null;
try {
const response = await axios.get(`${this.url}/api/admin/stats`, { headers: this.headers });
return response.data;
} catch (e) { return null; }
}
public async getAdminUsers() {
if (!this.token) return [];
try {
const response = await axios.get(`${this.url}/api/admin/users`, { headers: this.headers });
return response.data;
} catch (e) { return []; }
}
public async deleteUser(userId: string) {
if (!this.token) return;
try {
await axios.delete(`${this.url}/api/admin/users/${userId}`, { headers: this.headers });
} catch (e: any) {
throw new Error(e.response?.data?.message || 'Failed to delete user');
}
}
public async getAdminAccounts() {
if (!this.token) return [];
try {
const response = await axios.get(`${this.url}/api/admin/accounts`, { headers: this.headers });
return response.data;
} catch (e) { return []; }
}
public async forceRemoveAccount(steamId: string) {
if (!this.token) return;
try {
await axios.delete(`${this.url}/api/admin/accounts/${steamId}`, { headers: this.headers });
} catch (e: any) {
throw new Error(e.response?.data?.message || 'Failed to remove account');
}
}
} }

View File

@@ -27,6 +27,7 @@ export interface ServerConfig {
token?: string; token?: string;
serverSteamId?: string; serverSteamId?: string;
enabled: boolean; enabled: boolean;
isAdmin?: boolean;
} }
interface AccountsContextType { interface AccountsContextType {
@@ -51,6 +52,13 @@ interface AccountsContextType {
getCommunityAccounts: () => Promise<any[]>; getCommunityAccounts: () => Promise<any[]>;
getServerUsers: () => Promise<any[]>; getServerUsers: () => Promise<any[]>;
refreshAccounts: (showLoading?: boolean) => Promise<void>; refreshAccounts: (showLoading?: boolean) => Promise<void>;
// Admin Methods
adminGetStats: () => Promise<any>;
adminGetUsers: () => Promise<any[]>;
adminDeleteUser: (userId: string) => Promise<void>;
adminGetAccounts: () => Promise<any[]>;
adminRemoveAccount: (steamId: string) => Promise<void>;
} }
const AccountsContext = createContext<AccountsContextType | undefined>(undefined); const AccountsContext = createContext<AccountsContextType | undefined>(undefined);
@@ -174,11 +182,19 @@ export const AccountsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
return await (window as any).electronAPI.getServerUsers(); return await (window as any).electronAPI.getServerUsers();
}; };
// --- Admin Methods ---
const adminGetStats = async () => (window as any).electronAPI.adminGetStats();
const adminGetUsers = async () => (window as any).electronAPI.adminGetUsers();
const adminDeleteUser = async (userId: string) => (window as any).electronAPI.adminDeleteUser(userId);
const adminGetAccounts = async () => (window as any).electronAPI.adminGetAccounts();
const adminRemoveAccount = async (steamId: string) => (window as any).electronAPI.adminRemoveAccount(steamId);
return ( return (
<AccountsContext.Provider value={{ <AccountsContext.Provider value={{
accounts, serverConfig, isLoading, isSyncing, addAccount, updateAccount, deleteAccount, accounts, serverConfig, isLoading, isSyncing, addAccount, updateAccount, deleteAccount,
switchAccount, openSteamAppLogin, openSteamLogin, updateServerConfig, loginToServer, switchAccount, openSteamAppLogin, openSteamLogin, updateServerConfig, loginToServer,
getCommunityAccounts, getServerUsers, shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, syncNow, refreshAccounts getCommunityAccounts, getServerUsers, shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, syncNow, refreshAccounts,
adminGetStats, adminGetUsers, adminDeleteUser, adminGetAccounts, adminRemoveAccount
}}> }}>
{children} {children}
</AccountsContext.Provider> </AccountsContext.Provider>

View File

@@ -6,7 +6,7 @@ import {
DialogActions, CircularProgress, Paper, Chip, DialogActions, CircularProgress, Paper, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
Switch, FormControlLabel, Divider, List, ListItem, ListItemText, ListItemSecondaryAction, Switch, FormControlLabel, Divider, List, ListItem, ListItemText, ListItemSecondaryAction,
Select, MenuItem, FormControl, InputLabel Select, MenuItem, FormControl, InputLabel, Tabs, Tab
} from '@mui/material'; } from '@mui/material';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
@@ -25,11 +25,116 @@ import GppBadIcon from '@mui/icons-material/GppBad';
import PeopleIcon from '@mui/icons-material/People'; import PeopleIcon from '@mui/icons-material/People';
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
import WorkspacePremiumIcon from '@mui/icons-material/WorkspacePremium'; import WorkspacePremiumIcon from '@mui/icons-material/WorkspacePremium';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import StorageIcon from '@mui/icons-material/Storage';
import GroupIcon from '@mui/icons-material/Group';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import { useAccounts, type Account } from '../hooks/useAccounts'; import { useAccounts, type Account } from '../hooks/useAccounts';
import { useAppTheme } from '../theme/ThemeContext'; import { useAppTheme } from '../theme/ThemeContext';
import type { ThemeType } from '../theme/SteamTheme'; import type { ThemeType } from '../theme/SteamTheme';
import NebulaBanner from '../components/NebulaBanner'; import NebulaBanner from '../components/NebulaBanner';
const AdminPanel: React.FC<{ open: boolean, onClose: () => void }> = ({ open, onClose }) => {
const { adminGetStats, adminGetUsers, adminDeleteUser, adminGetAccounts, adminRemoveAccount } = useAccounts();
const [tab, setTab] = useState(0);
const [stats, setStats] = useState<any>(null);
const [users, setUsers] = useState<any[]>([]);
const [accounts, setAccounts] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const loadData = async () => {
setLoading(true);
try {
if (tab === 0) setStats(await adminGetStats());
if (tab === 1) setUsers(await adminGetUsers());
if (tab === 2) setAccounts(await adminGetAccounts());
} catch (e) {}
setLoading(false);
};
useEffect(() => { if (open) loadData(); }, [open, tab]);
const handleDeleteUser = async (id: string) => {
if (window.confirm("Wipe this user and all their accounts?")) {
await adminDeleteUser(id);
loadData();
}
};
const handleForceRemove = async (steamId: string) => {
if (window.confirm("Force remove this account from server?")) {
await adminRemoveAccount(steamId);
loadData();
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ bgcolor: 'background.paper', color: 'text.primary', display: 'flex', alignItems: 'center', gap: 1 }}>
<AdminPanelSettingsIcon color="primary" /> Server Administration
</DialogTitle>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}>
<Tab icon={<StorageIcon />} label="Overview" />
<Tab icon={<GroupIcon />} label="Users" />
<Tab icon={<AccountTreeIcon />} label="Global Accounts" />
</Tabs>
<DialogContent sx={{ bgcolor: 'background.paper', minHeight: 400, pt: 2 }}>
{loading ? <Box sx={{ display: 'flex', justifyContent: 'center', mt: 10 }}><CircularProgress /></Box> : (
<>
{tab === 0 && stats && (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mt: 2 }}>
{[
{ label: 'Total Users', value: stats.users },
{ label: 'Total Accounts', value: stats.accounts },
{ label: 'Active Cooldowns', value: stats.activeCooldowns }
].map((s) => (
<Paper key={s.label} sx={{ p: 3, textAlign: 'center', bgcolor: 'rgba(0,0,0,0.1)' }}>
<Typography variant="h4" color="primary" sx={{ fontWeight: 'bold' }}>{s.value}</Typography>
<Typography variant="caption" color="textSecondary">{s.label}</Typography>
</Paper>
))}
</Box>
)}
{tab === 1 && (
<List>
{users.map(u => (
<ListItem key={u._id} divider sx={{ borderColor: 'divider' }}>
<Avatar src={u.avatar} sx={{ mr: 2 }} />
<ListItemText primary={u.personaName} secondary={u.steamId} primaryTypographyProps={{ color: 'text.primary' }} />
<ListItemSecondaryAction>
<IconButton color="error" onClick={() => handleDeleteUser(u._id)}><DeleteIcon /></IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{tab === 2 && (
<List>
{accounts.map(a => (
<ListItem key={a.steamId} divider sx={{ borderColor: 'divider' }}>
<Avatar src={a.avatar} variant="square" sx={{ mr: 2 }} />
<ListItemText
primary={a.personaName}
secondary={`Owned by: ${a.addedBy?.personaName || 'Unknown'} (${a.steamId})`}
primaryTypographyProps={{ color: 'text.primary' }}
/>
<ListItemSecondaryAction>
<IconButton color="error" onClick={() => handleForceRemove(a.steamId)}><DeleteIcon /></IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
</>
)}
</DialogContent>
<DialogActions sx={{ bgcolor: 'background.paper', p: 2 }}>
<Button onClick={onClose} variant="contained" color="inherit">Close Panel</Button>
</DialogActions>
</Dialog>
);
};
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const { currentTheme, setTheme } = useAppTheme(); const { currentTheme, setTheme } = useAppTheme();
const { const {
@@ -39,6 +144,7 @@ const Dashboard: React.FC = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAdminPanelOpen, setIsAdminPanelOpen] = useState(false);
const [serverUrl, setServerUrl] = useState(''); const [serverUrl, setServerUrl] = useState('');
useEffect(() => { useEffect(() => {
@@ -70,6 +176,15 @@ const Dashboard: React.FC = () => {
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, WebkitAppRegion: 'no-drag' } as any}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, WebkitAppRegion: 'no-drag' } as any}>
{/* Admin Button - Only visible if isAdmin is true */}
{serverConfig?.isAdmin && (
<Tooltip title="Open Admin Panel">
<IconButton color="primary" onClick={() => setIsAdminPanelOpen(true)}>
<AdminPanelSettingsIcon />
</IconButton>
</Tooltip>
)}
<Box sx={{ display: 'flex', alignItems: 'center', mr: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', mr: 1 }}>
{isSyncing ? ( {isSyncing ? (
<CircularProgress size={16} sx={{ color: 'primary.main', mr: 1 }} /> <CircularProgress size={16} sx={{ color: 'primary.main', mr: 1 }} />
@@ -246,6 +361,9 @@ const Dashboard: React.FC = () => {
<Button onClick={() => setIsSettingsOpen(false)} color="inherit" variant="contained">Done</Button> <Button onClick={() => setIsSettingsOpen(false)} color="inherit" variant="contained">Done</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Admin Panel */}
<AdminPanel open={isAdminPanelOpen} onClose={() => setIsAdminPanelOpen(false)} />
</Box> </Box>
); );
}; };
@@ -294,8 +412,7 @@ const AccountRow: React.FC<{
(window as any).electronAPI.getServerUserInfo() (window as any).electronAPI.getServerUserInfo()
]); ]);
const filtered = (Array.isArray(users) ? users : []).filter(u => const filtered = (Array.isArray(users) ? users : []).filter(u =>
u.steamId !== selfInfo.steamId && u.steamId !== selfInfo.steamId && u.steamId !== account.steamId
u.steamId !== account.steamId
); );
setServerUsers(filtered); setServerUsers(filtered);
} catch (e) {} } catch (e) {}