Skip to content

[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

Merged
merged 1 commit into from
Apr 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions components/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { UserContext } from './user-context';
import { getGitpodService } from './service/service';
import { shouldSeeWhatsNew, WhatsNew } from './WhatsNew';
import settingsMenu from './settings/settings-menu';
import { User } from '@gitpod/gitpod-protocol';
import { adminMenu } from './admin/admin-menu';

const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ './workspaces/Workspaces'));
const Account = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Account'));
Expand All @@ -27,6 +29,8 @@ const StartWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './st
const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/CreateWorkspace'));
const InstallGitHubApp = React.lazy(() => import(/* webpackPrefetch: true */ './prebuilds/InstallGitHubApp'));
const FromReferrer = React.lazy(() => import(/* webpackPrefetch: true */ './FromReferrer'));
const UserSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/UserSearch'));
const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/WorkspacesSearch'));

function Loading() {
return <>
Expand All @@ -50,7 +54,7 @@ function App() {
setLoading(false);
})();
}, []);

if (loading) {
return <Loading />
}
Expand All @@ -71,7 +75,7 @@ function App() {

let toRender: React.ReactElement = <Route>
<div className="container">
{renderMenu()}
{renderMenu(user)}
<Switch>
<Route path="/workspaces" exact component={Workspaces} />
<Route path="/account" exact component={Account} />
Expand All @@ -84,6 +88,9 @@ function App() {
<Route path="/install-github-app" exact component={InstallGitHubApp} />
<Route path="/from-referrer" exact component={FromReferrer} />

<Route path="/admin/users" component={UserSearch} />
<Route path="/admin/workspaces" component={WorkspacesSearch} />

<Route path={["/", "/login"]} exact>
<Redirect to="/workspaces"/>
</Route>
Expand All @@ -96,6 +103,9 @@ function App() {
<Route path={["/subscription", "/usage"]} exact>
<Redirect to="/plans"/>
</Route>
<Route path={["/admin"]} exact>
<Redirect to="/admin/users"/>
</Route>
</Switch>
</div>
</Route>;
Expand Down Expand Up @@ -124,8 +134,8 @@ function getURLHash() {
return window.location.hash.replace(/^[#/]+/, '');
}

const renderMenu = () => (
<Menu left={[
const renderMenu = (user?: User) => {
const left = [
{
title: 'Workspaces',
link: '/workspaces',
Expand All @@ -135,8 +145,19 @@ const renderMenu = () => (
title: 'Settings',
link: '/settings',
alternatives: settingsMenu.flatMap(e => e.link)
},
]}
}
];

if (user && user?.rolesOrPermissions?.includes('admin')) {
left.push({
title: 'Admin',
link: '/admin',
alternatives: adminMenu.flatMap(e => e.link)
Copy link
Contributor

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?

Copy link
Contributor Author

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.

});
}

return <Menu
left={left}
right={[
{
title: 'Docs',
Expand All @@ -147,6 +168,7 @@ const renderMenu = () => (
link: 'https://community.gitpod.io/',
}
]}
/>)
/>;
}

export default App;
256 changes: 256 additions & 0 deletions components/dashboard/src/admin/UserDetail.tsx
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>
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

@svenefftinge svenefftinge Apr 5, 2021

Choose a reason for hiding this comment

The 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?

Copy link
Contributor

Choose a reason for hiding this comment

The 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>
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 } }) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: We could truncate this sooner so that it does not break the rest of the interface when the list is long.

Screenshot 2021-04-05 at 11 30 33 AM

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey George 👋 you look so good these days 😁

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels comfortable over here! 😅

{p.children}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Latest Instance ID bring in some overflow-scroll. Known?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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))
];
};
Loading