Skip to content

Commit

Permalink
[dashboard] Admin dashboard
Browse files Browse the repository at this point in the history
  • Loading branch information
svenefftinge committed Apr 3, 2021
1 parent cd90f26 commit e4ec8bb
Show file tree
Hide file tree
Showing 20 changed files with 686 additions and 96 deletions.
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 @@ -26,6 +28,8 @@ const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ './setti
const StartWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/StartWorkspace'));
const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/CreateWorkspace'));
const InstallGitHubApp = React.lazy(() => import(/* webpackPrefetch: true */ './prebuilds/InstallGitHubApp'));
const UserSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/UserSearch'));
const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/WorkspacesSearch'));

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

if (loading) {
return <Loading />
}
Expand All @@ -70,7 +74,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 @@ -82,6 +86,9 @@ function App() {
<Route path="/preferences" exact component={Preferences} />
<Route path="/install-github-app" exact component={InstallGitHubApp} />

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

<Route path={["/", "/login"]} exact>
<Redirect to="/workspaces"/>
</Route>
Expand All @@ -94,6 +101,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 @@ -122,8 +132,8 @@ function getURLHash() {
return window.location.hash.replace(/^[#/]+/, '');
}

const renderMenu = () => (
<Menu left={[
const renderMenu = (user?: User) => {
const left = [
{
title: 'Workspaces',
link: '/workspaces',
Expand All @@ -133,8 +143,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)
});
}

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

export default App;
213 changes: 213 additions & 0 deletions components/dashboard/src/admin/UserDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/**
* 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 { 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 [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 updateUser: UpdateUserFunction = async fun => {
setActivity(true);
try {
setUser(await fun(userRef.current));
} finally {
setActivity(false);
}
};

const toggleBlockUser = async () => {
await updateUser(async u => {
u.blocked = !u.blocked;
await getGitpodService().server.adminBlockUser({
blocked: u.blocked,
id: 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}</div>
<p>{user.identities.map(i => i.primaryEmail).filter(e => !!e).join(', ')}</p>
</div>
<button className="danger ml-3" disabled={activity} onClick={toggleBlockUser}>{user.blocked ? 'Unblock' : 'Block'} User</button>
</div>
<div className="flex mt-6">
<div className="w-40">
<img className="rounded-full h-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" value={moment(user.creationDate).format('MMM D, YYYY')} />
<Property name="Remaining Hours" value={accountStatement?.remainingHours ? accountStatement?.remainingHours.toString() : '---'} />
<Property
name="Plan"
value={accountStatement?.subscriptions ? accountStatement.subscriptions.filter(s => Subscription.isActive(s, new Date().toISOString())).map(s => Plans.getById(s.planId)?.name).join(', ') : '---'}
action={accountStatement && {
label: (isProfessionalOpenSource ? 'Disable' : 'Enable') + ' Professional OSS',
onClick: () => {
getGitpodService().server.adminSetProfessionalOpenSource(user.id, !isProfessionalOpenSource);
}
}}
/>
</div>
<div className="flex w-full mt-6">
<Property name="Feature Flags" value={user.featureFlags?.permanentWSFeatureFlags?.join(', ') || '---'}
action={{
label: 'Edit Feature Flags',
onClick: () => {
setEditFeatureFlags(true);
}
}}
/>
<Property name="Roles" value={user.rolesOrPermissions?.join(', ') || '---'}
action={{
label: 'Edit Roles',
onClick: () => {
setEditRoles(true);
}
}}
/>
<Property name="Student" value={isStudent === undefined ? '---' : (isStudent ? 'Enabled' : 'Disabled')} />
</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>
</>;
}

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, value: string, action?: { label: string, onClick: () => void } }) {
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">
{p.value}
</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

0 comments on commit e4ec8bb

Please sign in to comment.