From 1f5d2e08e57b1699d94eacb7553f3e3648d1be2a Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 21 Feb 2026 02:45:01 +0100 Subject: [PATCH] feat: implement granular access revocation and global unsharing in the account permissions dialog --- frontend/electron/main.ts | 12 ++ frontend/electron/preload.ts | 2 + frontend/electron/services/backend.ts | 30 +++- frontend/src/hooks/useAccounts.tsx | 16 +- frontend/src/pages/Dashboard.tsx | 204 +++++++++------------- frontend/src/pages/DashboardRow.tsx | 234 ++++++++++++++++++++++++++ 6 files changed, 372 insertions(+), 126 deletions(-) create mode 100644 frontend/src/pages/DashboardRow.tsx diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index a009e28..481850d 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -488,6 +488,18 @@ ipcMain.handle('share-account-with-user', async (event, steamId: string, targetS throw new Error('Backend not configured'); }); +ipcMain.handle('revoke-account-access', async (event, steamId: string, targetSteamId: string) => { + initBackend(); + if (backend) return await backend.revokeAccess(steamId, targetSteamId); + throw new Error('Backend not configured'); +}); + +ipcMain.handle('revoke-all-account-access', async (event, steamId: string) => { + initBackend(); + if (backend) return await backend.revokeAllAccess(steamId); + throw new Error('Backend not configured'); +}); + 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('switch-account', async (event, loginName: string) => await handleSwitchAccount(loginName)); diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts index 4be24f8..510657b 100644 --- a/frontend/electron/preload.ts +++ b/frontend/electron/preload.ts @@ -7,6 +7,8 @@ contextBridge.exposeInMainWorld('electronAPI', { deleteAccount: (id: string) => ipcRenderer.invoke('delete-account', id), switchAccount: (loginName: string) => ipcRenderer.invoke('switch-account', loginName), shareAccountWithUser: (steamId: string, targetSteamId: string) => ipcRenderer.invoke('share-account-with-user', steamId, targetSteamId), + revokeAccountAccess: (steamId: string, targetSteamId: string) => ipcRenderer.invoke('revoke-account-access', steamId, targetSteamId), + revokeAllAccountAccess: (steamId: string) => ipcRenderer.invoke('revoke-all-account-access', steamId), openExternal: (url: string) => ipcRenderer.invoke('open-external', url), openSteamLogin: (steamId: string) => ipcRenderer.invoke('open-steam-login', steamId), diff --git a/frontend/electron/services/backend.ts b/frontend/electron/services/backend.ts index d838010..2a12fe2 100644 --- a/frontend/electron/services/backend.ts +++ b/frontend/electron/services/backend.ts @@ -61,7 +61,8 @@ export class BackendService { gameBans: account.gameBans, loginName: account.loginName, steamLoginSecure: account.steamLoginSecure, - loginConfig: account.loginConfig + loginConfig: account.loginConfig, + sessionUpdatedAt: account.sessionUpdatedAt }, { headers: this.headers }); } catch (e) { console.error('[Backend] Failed to share account'); @@ -91,4 +92,31 @@ export class BackendService { throw new Error(e.response?.data?.message || 'Failed to share account'); } } + + public async revokeAccess(steamId: string, targetSteamId: string) { + if (!this.token) return; + try { + const response = await axios.delete(`${this.url}/api/sync/${steamId}/share`, { + headers: this.headers, + data: { targetSteamId } + }); + return response.data; + } catch (e: any) { + console.error(`[Backend] Failed to revoke access for ${steamId} from ${targetSteamId}`); + throw new Error(e.response?.data?.message || 'Failed to revoke access'); + } + } + + public async revokeAllAccess(steamId: string) { + if (!this.token) return; + try { + const response = await axios.delete(`${this.url}/api/sync/${steamId}/share/all`, { + headers: this.headers + }); + return response.data; + } catch (e: any) { + console.error(`[Backend] Failed to revoke all access for ${steamId}`); + throw new Error(e.response?.data?.message || 'Failed to revoke all access'); + } + } } diff --git a/frontend/src/hooks/useAccounts.tsx b/frontend/src/hooks/useAccounts.tsx index b055eaa..bf27a08 100644 --- a/frontend/src/hooks/useAccounts.tsx +++ b/frontend/src/hooks/useAccounts.tsx @@ -40,6 +40,8 @@ interface AccountsContextType { switchAccount: (loginName: string) => Promise; openSteamLogin: (steamId: string) => Promise; shareAccountWithUser: (steamId: string, targetSteamId: string) => Promise; + revokeAccountAccess: (steamId: string, targetSteamId: string) => Promise; + revokeAllAccountAccess: (steamId: string) => Promise; // Server Methods updateServerConfig: (config: Partial) => Promise; @@ -136,6 +138,18 @@ export const AccountsProvider: React.FC<{ children: React.ReactNode }> = ({ chil return res; }; + const revokeAccountAccess = async (steamId: string, targetSteamId: string) => { + const res = await (window as any).electronAPI.revokeAccountAccess(steamId, targetSteamId); + await syncNow(); + return res; + }; + + const revokeAllAccountAccess = async (steamId: string) => { + const res = await (window as any).electronAPI.revokeAllAccountAccess(steamId); + await syncNow(); + return res; + }; + const updateServerConfig = async (config: Partial) => { const updated = await (window as any).electronAPI.updateServerConfig(config); setServerConfig(updated); @@ -159,7 +173,7 @@ export const AccountsProvider: React.FC<{ children: React.ReactNode }> = ({ chil {children} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 5ec57ae..293fa51 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -49,9 +49,7 @@ const Dashboard: React.FC = () => { const [serverUrl, setServerUrl] = useState(''); useEffect(() => { - if (serverConfig?.url) { - setServerUrl(serverConfig.url); - } + if (serverConfig?.url) setServerUrl(serverConfig.url); }, [serverConfig?.url]); const loadCommunity = async () => { @@ -59,16 +57,11 @@ const Dashboard: React.FC = () => { try { const data = await getCommunityAccounts(); setCommunityAccounts(Array.isArray(data) ? data : []); - } catch (e) { - } finally { - setIsCommunityLoading(false); - } + } catch (e) { } finally { setIsCommunityLoading(false); } }; useEffect(() => { - if (isAddDialogOpen && addTab === 1) { - loadCommunity(); - } + if (isAddDialogOpen && addTab === 1) loadCommunity(); }, [isAddDialogOpen, addTab]); const handleAddAccount = async () => { @@ -77,9 +70,7 @@ const Dashboard: React.FC = () => { await addAccount({ identifier }); setIsAddDialogOpen(false); setIdentifier(''); - } catch (e) { - console.error("[Dashboard] Add failed:", e); - } + } catch (e) { console.error("[Dashboard] Add failed:", e); } }; const handleAddFromCommunity = async (commAcc: any) => { @@ -235,14 +226,7 @@ const Dashboard: React.FC = () => { InputProps={{ endAdornment: ( - + ), }} @@ -365,7 +349,7 @@ const AccountRow: React.FC<{ onSwitch: (login: string) => void, onAuth: () => void }> = ({ account, onDelete, onSwitch, onAuth }) => { - const { shareAccountWithUser, getServerUsers, serverConfig } = useAccounts(); + const { shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, getServerUsers, serverConfig } = useAccounts(); const [timeLeft, setTimeLeft] = useState(null); const [isShareOpen, setIsShareOpen] = useState(false); const [targetUserId, setTargetUserId] = useState(''); @@ -376,10 +360,7 @@ const AccountRow: React.FC<{ const isCooldownActive = cooldownDate && !isNaN(cooldownDate.getTime()) && cooldownDate.getTime() > Date.now(); useEffect(() => { - if (!isCooldownActive || !cooldownDate) { - setTimeLeft(null); - return; - } + if (!isCooldownActive || !cooldownDate) { setTimeLeft(null); return; } const targetTime = cooldownDate.getTime(); const timer = setInterval(() => { const diff = targetTime - Date.now(); @@ -392,14 +373,9 @@ const AccountRow: React.FC<{ return () => clearInterval(timer); }, [account?.cooldownExpiresAt, isCooldownActive]); - const avatarSrc = account?.localAvatar - ? `steam-resource://${account.localAvatar}` - : (account?.avatar || ''); + const avatarSrc = account?.localAvatar ? `steam-resource://${account.localAvatar}` : (account?.avatar || ''); const [imgSrc, setImgSrc] = useState(avatarSrc); - - useEffect(() => { - setImgSrc(avatarSrc); - }, [avatarSrc]); + useEffect(() => { setImgSrc(avatarSrc); }, [avatarSrc]); const handleOpenShare = async () => { setIsShareOpen(true); @@ -409,8 +385,7 @@ const AccountRow: React.FC<{ (window as any).electronAPI.getServerUserInfo() ]); const filtered = (Array.isArray(users) ? users : []).filter(u => - u.steamId !== selfInfo.steamId && - u.steamId !== account.steamId + u.steamId !== selfInfo.steamId && u.steamId !== account.steamId ); setServerUsers(filtered); } catch (e) {} @@ -421,14 +396,21 @@ const AccountRow: React.FC<{ setIsSharing(true); try { await shareAccountWithUser(account.steamId, targetUserId); - alert(`Account shared successfully!`); - setIsShareOpen(false); setTargetUserId(''); - } catch (e: any) { - alert(e.message || "Failed to share account"); - } finally { - setIsSharing(false); - } + } catch (e: any) { alert(e.message || "Failed to share account"); + } finally { setIsSharing(false); } + }; + + const handleRevoke = async (targetSteamId: string) => { + if (!window.confirm("Revoke access for this user?")) return; + try { await revokeAccountAccess(account.steamId, targetSteamId); + } catch (e: any) { alert(e.message); } + }; + + const handleRevokeAll = async () => { + if (!window.confirm("Completely stop sharing this account?")) return; + try { await revokeAllAccountAccess(account.steamId); setIsShareOpen(false); + } catch (e: any) { alert(e.message); } }; const isBanned = account?.vacBanned || (account?.gameBans && account.gameBans > 0); @@ -447,44 +429,34 @@ const AccountRow: React.FC<{ - - {account?.personaName || 'Unknown'} - + {account?.personaName || 'Unknown'} {account?.steamId} {isBanned ? ( - - ACCOUNT BANNED + BANNED - {account?.vacBanned && ( - - )} - {account?.gameBans ? account.gameBans > 0 && ( - - ) : null} + {account?.vacBanned && } + {account?.gameBans ? account.gameBans > 0 && : null} ) : ( - - SECURE + SECURE )} {account?.authError ? ( - - Needs Re-auth + Needs Re-auth ) : isCooldownActive ? ( - - {timeLeft} + {timeLeft} ) : ( Available @@ -492,95 +464,79 @@ const AccountRow: React.FC<{ - {/* Fast Switcher Button - Always available if we have a login name */} {account.loginName && ( + variant="contained" size="small" onClick={() => onSwitch(account.loginName || '')} + sx={{ height: 28, fontSize: '0.7rem', bgcolor: 'secondary.main', '&:hover': { opacity: 0.9 }, minWidth: 60 }} + >LOGIN )} - - {/* Scraper Auth Button - Controls the optional cooldown tracking */} - + {account.steamLoginSecure && !account.authError ? : (account.authError ? : )} {account.steamLoginSecure && !account.authError && ( - - TRACKING - + TRACKING )} - - (window as any).electronAPI.openExternal(account?.profileUrl || '')}> onDelete(account?._id || '')}> - {/* Share Dialog */} setIsShareOpen(false)} maxWidth="xs" fullWidth> - Share Account + Permissions - - Select a community member to share this account with. - - - Select User - - + GRANT ACCESS + + + Select User + + + + + + CURRENT ACCESS + + {(account as any).sharedWith?.map((sw: any) => ( + + + + + handleRevoke(sw.steamId)}> + + + ))} + {(!(account as any).sharedWith || (account as any).sharedWith.length === 0) && ( + Not shared with anyone yet. + )} + + {(account as any).sharedWith?.length > 0 && ( + + )} - - - - + diff --git a/frontend/src/pages/DashboardRow.tsx b/frontend/src/pages/DashboardRow.tsx new file mode 100644 index 0000000..0301fc5 --- /dev/null +++ b/frontend/src/pages/DashboardRow.tsx @@ -0,0 +1,234 @@ +const AccountRow: React.FC<{ + account: Account, + onDelete: (id: string) => void, + onSwitch: (login: string) => void, + onAuth: () => void +}> = ({ account, onDelete, onSwitch, onAuth }) => { + const { shareAccountWithUser, revokeAccountAccess, revokeAllAccountAccess, getServerUsers, serverConfig } = useAccounts(); + const [timeLeft, setTimeLeft] = useState(null); + const [isShareOpen, setIsShareOpen] = useState(false); + const [targetUserId, setTargetUserId] = useState(''); + const [isSharing, setIsSharing] = useState(false); + const [serverUsers, setServerUsers] = useState([]); + + const cooldownDate = account?.cooldownExpiresAt ? new Date(account.cooldownExpiresAt) : null; + const isCooldownActive = cooldownDate && !isNaN(cooldownDate.getTime()) && cooldownDate.getTime() > Date.now(); + + useEffect(() => { + if (!isCooldownActive || !cooldownDate) { + setTimeLeft(null); + return; + } + const targetTime = cooldownDate.getTime(); + const timer = setInterval(() => { + const diff = targetTime - Date.now(); + if (diff <= 0) { setTimeLeft(null); clearInterval(timer); return; } + const hours = Math.floor(diff / 3600000); + const mins = Math.floor((diff % 3600000) / 60000); + const secs = Math.floor((diff % 60000) / 1000); + setTimeLeft(`${hours}h ${mins}m ${secs}s`); + }, 1000); + return () => clearInterval(timer); + }, [account?.cooldownExpiresAt, isCooldownActive]); + + const avatarSrc = account?.localAvatar + ? `steam-resource://${account.localAvatar}` + : (account?.avatar || ''); + const [imgSrc, setImgSrc] = useState(avatarSrc); + + useEffect(() => { + setImgSrc(avatarSrc); + }, [avatarSrc]); + + const handleOpenShare = async () => { + setIsShareOpen(true); + try { + const [users, selfInfo] = await Promise.all([ + getServerUsers(), + (window as any).electronAPI.getServerUserInfo() + ]); + const filtered = (Array.isArray(users) ? users : []).filter(u => + u.steamId !== selfInfo.steamId && + u.steamId !== account.steamId + ); + setServerUsers(filtered); + } catch (e) {} + }; + + const handleShare = async () => { + if (!targetUserId) return; + setIsSharing(true); + try { + await shareAccountWithUser(account.steamId, targetUserId); + setTargetUserId(''); + } catch (e: any) { + alert(e.message || "Failed to share account"); + } finally { + setIsSharing(false); + } + }; + + const handleRevoke = async (targetSteamId: string) => { + if (!window.confirm("Revoke access for this user?")) return; + try { + await revokeAccountAccess(account.steamId, targetSteamId); + } catch (e: any) { alert(e.message); } + }; + + const handleRevokeAll = async () => { + if (!window.confirm("Completely stop sharing this account with the community?")) return; + try { + await revokeAllAccountAccess(account.steamId); + setIsShareOpen(false); + } catch (e: any) { alert(e.message); } + }; + + const isBanned = account?.vacBanned || (account?.gameBans && account.gameBans > 0); + const isShared = account?._id.startsWith('shared_'); + + return ( + + + + + {isShared && ( + + + + )} + + + + + {account?.personaName || 'Unknown'} + + {account?.steamId} + + + {isBanned ? ( + + + + ACCOUNT BANNED + + + {account?.vacBanned && ( + + )} + {account?.gameBans ? account.gameBans > 0 && ( + + ) : null} + + + ) : ( + + + SECURE + + )} + + + {account?.authError ? ( + + + Needs Re-auth + + ) : isCooldownActive ? ( + + + {timeLeft} + + ) : ( + Available + )} + + + + {account.loginName && ( + + )} + + + + + {account.steamLoginSecure && !account.authError ? : (account.authError ? : )} + + {account.steamLoginSecure && !account.authError && ( + + TRACKING + + )} + + + + + + + (window as any).electronAPI.openExternal(account?.profileUrl || '')}> + onDelete(account?._id || '')}> + + + setIsShareOpen(false)} maxWidth="xs" fullWidth> + Account Permissions + + GRANT ACCESS + + + Select User + + + + + + CURRENT ACCESS + + {(account as any).sharedWith?.map((sw: any) => ( + + + + + handleRevoke(sw.steamId)}> + + + ))} + {(!(account as any).sharedWith || (account as any).sharedWith.length === 0) && ( + Not shared with anyone yet. + )} + + {(account as any).sharedWith?.length > 0 && ( + + )} + + + + + + ); +};