-
Notifications
You must be signed in to change notification settings - Fork 1.3k
[dashboard] Admin dashboard #3723
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,256 @@ | ||
/** | ||
* Copyright (c) 2021 Gitpod GmbH. All rights reserved. | ||
* Licensed under the GNU Affero General Public License (AGPL). | ||
* See License-AGPL.txt in the project root for license information. | ||
*/ | ||
|
||
import { NamedWorkspaceFeatureFlag, Permissions, RoleOrPermission, Roles, User, WorkspaceFeatureFlags } from "@gitpod/gitpod-protocol" | ||
import { AccountStatement, Subscription } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; | ||
import { Plans } from "@gitpod/gitpod-protocol/lib/plans"; | ||
import moment from "moment"; | ||
import { ReactChild, useEffect, useRef, useState } from "react"; | ||
import CheckBox from "../components/CheckBox"; | ||
import Modal from "../components/Modal"; | ||
import { PageWithSubMenu } from "../components/PageWithSubMenu" | ||
import { getGitpodService } from "../service/service"; | ||
import { adminMenu } from "./admin-menu" | ||
import { WorkspaceSearch } from "./WorkspacesSearch"; | ||
|
||
|
||
export default function UserDetail(p: { user: User }) { | ||
const [activity, setActivity] = useState(false); | ||
const [user, setUser] = useState(p.user); | ||
const [accountStatement, setAccountStatement] = useState<AccountStatement>(); | ||
const [viewAccountStatement, setViewAccountStatement] = useState(false); | ||
const [isStudent, setIsStudent] = useState<boolean>(); | ||
const [editFeatureFlags, setEditFeatureFlags] = useState(false); | ||
const [editRoles, setEditRoles] = useState(false); | ||
const userRef = useRef(user); | ||
|
||
const isProfessionalOpenSource = accountStatement && accountStatement.subscriptions.some(s => s.planId === Plans.FREE_OPEN_SOURCE.chargebeeId) | ||
|
||
useEffect(() => { | ||
setUser(p.user); | ||
getGitpodService().server.adminGetAccountStatement(p.user.id).then( | ||
as => | ||
setAccountStatement(as) | ||
).catch(e => { | ||
console.error(e); | ||
}); | ||
getGitpodService().server.adminIsStudent(p.user.id).then( | ||
isStud => setIsStudent(isStud) | ||
); | ||
}, [p.user]); | ||
|
||
const email = User.getPrimaryEmail(p.user); | ||
const emailDomain = email.split('@')[ email.split('@').length - 1]; | ||
|
||
const updateUser: UpdateUserFunction = async fun => { | ||
setActivity(true); | ||
try { | ||
setUser(await fun(userRef.current)); | ||
} finally { | ||
setActivity(false); | ||
} | ||
}; | ||
|
||
const addStudentDomain = async () => { | ||
await updateUser(async u => { | ||
await getGitpodService().server.adminAddStudentEmailDomain(u.id, emailDomain); | ||
await getGitpodService().server.adminIsStudent(u.id).then( | ||
isStud => setIsStudent(isStud) | ||
); | ||
return u; | ||
}); | ||
}; | ||
|
||
const toggleBlockUser = async () => { | ||
await updateUser(async u => { | ||
u.blocked = !u.blocked; | ||
await getGitpodService().server.adminBlockUser({ | ||
blocked: u.blocked, | ||
id: u.id | ||
}); | ||
return u; | ||
}); | ||
} | ||
|
||
const deleteUser = async () => { | ||
await updateUser(async u => { | ||
u.markedDeleted = !u.markedDeleted; | ||
await getGitpodService().server.adminDeleteUser(u.id); | ||
return u; | ||
}); | ||
} | ||
|
||
const flags = getFlags(user, updateUser); | ||
const rop = getRopEntries(user, updateUser); | ||
|
||
return <> | ||
<PageWithSubMenu subMenu={adminMenu} title="Users" subtitle="Search and manage all users."> | ||
<div className="flex"> | ||
<div className="flex-1"> | ||
<div className="flex"><h3>{user.fullName}</h3>{user.blocked ? <Label text='Blocked' color="red" /> : null} {user.markedDeleted ? <Label text='Deleted' color="red" /> : null}</div> | ||
<p>{user.identities.map(i => i.primaryEmail).filter(e => !!e).join(', ')}</p> | ||
</div> | ||
<button className="secondary danger ml-3" disabled={activity} onClick={toggleBlockUser}>{user.blocked ? 'Unblock' : 'Block'} User</button> | ||
<button className="danger ml-3" disabled={activity} onClick={deleteUser}>Delete User</button> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: What do you think of reverting the actions here so that Block User is the primary action instead of Delete User. Feel free to leave this as is. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I switched that because I felt like "Delete User" is the more severe (not revertable) action and therefore needs more danger (red). Maybe we should not have that action at all? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could hide less prominent actions in a more actions dropdown button as we do for Workspaces. It should be fine to ship it as is and improve later. ➿ |
||
</div> | ||
<div className="flex mt-6"> | ||
<div className="w-40"> | ||
<img className="rounded-full h-28 w-28" alt={user.fullName} src={user.avatarUrl} /> | ||
</div> | ||
<div className="flex flex-col w-full"> | ||
<div className="flex w-full mt-6"> | ||
<Property name="Sign Up Date">{moment(user.creationDate).format('MMM D, YYYY')}</Property> | ||
<Property name="Remaining Hours" | ||
action={ | ||
accountStatement && { | ||
label: 'View Account Statement', | ||
onClick: () => setViewAccountStatement(true) | ||
} | ||
} | ||
>{accountStatement?.remainingHours ? accountStatement?.remainingHours.toString() : '---'}</Property> | ||
<Property | ||
name="Plan" | ||
action={accountStatement && { | ||
label: (isProfessionalOpenSource ? 'Disable' : 'Enable') + ' Professional OSS', | ||
onClick: () => { | ||
getGitpodService().server.adminSetProfessionalOpenSource(user.id, !isProfessionalOpenSource); | ||
} | ||
}} | ||
>{accountStatement?.subscriptions ? accountStatement.subscriptions.filter(s => Subscription.isActive(s, new Date().toISOString())).map(s => Plans.getById(s.planId)?.name).join(', ') : '---'}</Property> | ||
</div> | ||
<div className="flex w-full mt-6"> | ||
<Property name="Feature Flags" | ||
action={{ | ||
label: 'Edit Feature Flags', | ||
onClick: () => { | ||
setEditFeatureFlags(true); | ||
} | ||
}} | ||
>{user.featureFlags?.permanentWSFeatureFlags?.join(', ') || '---'}</Property> | ||
<Property name="Roles" | ||
action={{ | ||
label: 'Edit Roles', | ||
onClick: () => { | ||
setEditRoles(true); | ||
} | ||
}} | ||
>{user.rolesOrPermissions?.join(', ') || '---'}</Property> | ||
<Property name="Student" | ||
action={ !isStudent ? { | ||
label: `Make '${emailDomain}' a student domain`, | ||
onClick: addStudentDomain | ||
} : undefined} | ||
>{isStudent === undefined ? '---' : (isStudent ? 'Enabled' : 'Disabled')}</Property> | ||
</div> | ||
</div> | ||
</div> | ||
<WorkspaceSearch user={user} /> | ||
</PageWithSubMenu> | ||
<Modal visible={editFeatureFlags} onClose={() => setEditFeatureFlags(false)} title="Edit Feature Flags" buttons={[ | ||
<button className="secondary" onClick={() => setEditFeatureFlags(false)}>Done</button> | ||
]}> | ||
<p>Edit feature access by adding or removing feature flags for this user.</p> | ||
<div className="flex flex-col"> | ||
{ | ||
flags.map(e => <CheckBox key={e.title} title={e.title} desc="" checked={!!e.checked} onChange={e.onClick} />) | ||
} | ||
</div> | ||
</Modal> | ||
<Modal visible={editRoles} onClose={() => setEditRoles(false)} title="Edit Roles" buttons={[ | ||
<button className="secondary" onClick={() => setEditRoles(false)}>Done</button> | ||
]}> | ||
<p>Edit user permissions by adding or removing roles for this user.</p> | ||
<div className="flex flex-col"> | ||
{ | ||
rop.map(e => <CheckBox key={e.title} title={e.title} desc="" checked={!!e.checked} onChange={e.onClick} />) | ||
} | ||
</div> | ||
</Modal> | ||
<Modal visible={viewAccountStatement} onClose={() => setViewAccountStatement(false)} title="Edit Roles" buttons={[ | ||
<button className="secondary" onClick={() => setViewAccountStatement(false)}>Done</button> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Could we use the same flow we already have in Integrations page? For example, Permissions and Feature Flags could be disabled and only become active when you alter some of the permissions above. It should be fine to ship as is for now. Currently, the page flashing on the background also slightly degrades the UX of this page when updating roles or feature flags. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't introduce a dirty state that would need to be saved. It would introduce some more complexity so I opted for this simple auto-save approach. |
||
]}> | ||
<div className="flex flex-col"> | ||
{ | ||
JSON.stringify(accountStatement, null, ' ') | ||
} | ||
</div> | ||
</Modal> | ||
</>; | ||
} | ||
|
||
function Label(p: { text: string, color: string }) { | ||
return <div className={`ml-3 text-sm text-${p.color}-600 truncate bg-${p.color}-100 px-1.5 py-0.5 rounded-md my-auto`}>{p.text}</div>; | ||
} | ||
|
||
export function Property(p: { name: string, children: string | ReactChild, action?: { label: string, onClick: () => void } }) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. praise: Nice abstraction here! |
||
return <div className="ml-3 flex flex-col w-4/12 truncate"> | ||
<div className="text-base text-gray-500 truncate"> | ||
{p.name} | ||
</div> | ||
<div className="text-lg text-gray-600 font-semibold truncate"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hey George 👋 you look so good these days 😁 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feels comfortable over here! 😅 |
||
{p.children} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: Latest Instance ID bring in some There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it is on purpose so I can actually see and copy the whole ID. |
||
</div> | ||
<div className="cursor-pointer text-sm text-blue-400 hover:text-blue-500 truncate" onClick={p.action?.onClick}> | ||
{p.action?.label || ''} | ||
</div> | ||
</div>; | ||
} | ||
|
||
interface Entry { | ||
title: string, | ||
checked: boolean, | ||
onClick: () => void | ||
} | ||
|
||
type UpdateUserFunction = (fun: (u: User) => Promise<User>) => Promise<void>; | ||
|
||
function getFlags(user: User, updateUser: UpdateUserFunction): Entry[] { | ||
return Object.entries(WorkspaceFeatureFlags).map(e => e[0] as NamedWorkspaceFeatureFlag).map(name => { | ||
const checked = !!user.featureFlags?.permanentWSFeatureFlags?.includes(name); | ||
return { | ||
title: name, | ||
checked, | ||
onClick: async () => { | ||
await updateUser(async u => { | ||
return await getGitpodService().server.adminModifyPermanentWorkspaceFeatureFlag({ | ||
id: user.id, | ||
changes: [ | ||
{ | ||
featureFlag: name, | ||
add: !checked | ||
} | ||
] | ||
}); | ||
}) | ||
} | ||
}; | ||
}); | ||
} | ||
|
||
function getRopEntries(user: User, updateUser: UpdateUserFunction): Entry[] { | ||
const createRopEntry = (name: RoleOrPermission, role?: boolean) => { | ||
const checked = user.rolesOrPermissions?.includes(name)!!; | ||
return { | ||
title: (role ? 'Role: ' : 'Permission: ') + name, | ||
checked, | ||
onClick: async () => { | ||
await updateUser(async u => { | ||
return await getGitpodService().server.adminModifyRoleOrPermission({ | ||
id: user.id, | ||
rpp: [{ | ||
r: name, | ||
add: !checked | ||
}] | ||
}); | ||
}) | ||
} | ||
}; | ||
}; | ||
return [ | ||
...Object.entries(Permissions).map(e => createRopEntry(e[0] as RoleOrPermission)), | ||
...Object.entries(Roles).map(e => createRopEntry(e[0] as RoleOrPermission, true)) | ||
]; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thought: I assume this hides the Admin menu for unauthorized users, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, you can test it by logging in with another user and then remove the
admin
role from it.