diff --git a/app/Http/Controllers/Api/Client/Servers/SubuserController.php b/app/Http/Controllers/Api/Client/Servers/SubuserController.php new file mode 100644 index 000000000..7ef619138 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/SubuserController.php @@ -0,0 +1,45 @@ +repository = $repository; + } + + /** + * Return the users associated with this server instance. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\GetSubuserRequest $request + * @param \Pterodactyl\Models\Server $server + * @return array + */ + public function index(GetSubuserRequest $request, Server $server) + { + $users = $this->repository->getSubusersForServer($server->id); + + return $this->fractal->collection($users) + ->transformWith($this->getTransformer(SubuserTransformer::class)) + ->toArray(); + } +} diff --git a/app/Http/Requests/Api/Client/Servers/Subusers/GetSubuserRequest.php b/app/Http/Requests/Api/Client/Servers/Subusers/GetSubuserRequest.php new file mode 100644 index 000000000..810afdfb1 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Subusers/GetSubuserRequest.php @@ -0,0 +1,18 @@ +user()->can('view-subusers', $this->route()->parameter('server')); + } +} diff --git a/app/Models/Subuser.php b/app/Models/Subuser.php index 0f343f20b..0b841fd24 100644 --- a/app/Models/Subuser.php +++ b/app/Models/Subuser.php @@ -4,6 +4,17 @@ namespace Pterodactyl\Models; use Illuminate\Notifications\Notifiable; +/** + * @property int $id + * @property int $user_id + * @property int $server_id + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * + * @property \Pterodactyl\Models\User $user + * @property \Pterodactyl\Models\Server $server + * @property \Pterodactyl\Models\Permission[]|\Illuminate\Support\Collection $permissions + */ class Subuser extends Validable { use Notifiable; diff --git a/app/Models/User.php b/app/Models/User.php index 0ed3a67d0..96cbf4287 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -216,7 +216,7 @@ class User extends Validable implements */ public function getNameAttribute() { - return $this->name_first . ' ' . $this->name_last; + return trim($this->name_first . ' ' . $this->name_last); } /** diff --git a/app/Repositories/Eloquent/SubuserRepository.php b/app/Repositories/Eloquent/SubuserRepository.php index c0fb930a6..4636f7b37 100644 --- a/app/Repositories/Eloquent/SubuserRepository.php +++ b/app/Repositories/Eloquent/SubuserRepository.php @@ -3,6 +3,7 @@ namespace Pterodactyl\Repositories\Eloquent; use Pterodactyl\Models\Subuser; +use Illuminate\Support\Collection; use Pterodactyl\Exceptions\Repository\RecordNotFoundException; use Pterodactyl\Contracts\Repository\SubuserRepositoryInterface; @@ -18,6 +19,22 @@ class SubuserRepository extends EloquentRepository implements SubuserRepositoryI return Subuser::class; } + /** + * Returns the subusers for the given server instance with the associated user + * and permission relationships pre-loaded. + * + * @param int $server + * @return \Illuminate\Support\Collection + */ + public function getSubusersForServer(int $server): Collection + { + return $this->getBuilder() + ->with('user', 'permissions') + ->where('server_id', $server) + ->get() + ->toBase(); + } + /** * Return a subuser with the associated server relationship. * diff --git a/app/Transformers/Api/Client/SubuserTransformer.php b/app/Transformers/Api/Client/SubuserTransformer.php new file mode 100644 index 000000000..bdb3fb759 --- /dev/null +++ b/app/Transformers/Api/Client/SubuserTransformer.php @@ -0,0 +1,55 @@ +user; + + return [ + 'uuid' => $user->uuid, + 'username' => $user->username, + 'email' => $user->email, + 'image' => 'https://gravatar.com/avatar/' . md5(Str::lower($user->email)), + '2fa_enabled' => $user->use_totp, + 'created_at' => $model->created_at->toIso8601String(), + ]; + } + + /** + * Include the permissions associated with this subuser. + * + * @param \Pterodactyl\Models\Subuser $model + * @return \League\Fractal\Resource\Item + */ + public function includePermissions(Subuser $model) + { + return $this->item($model, function (Subuser $model) { + return ['permissions' => $model->permissions->pluck('permission')]; + }); + } +} diff --git a/resources/scripts/api/http.ts b/resources/scripts/api/http.ts index ba0a9e230..d4cf11332 100644 --- a/resources/scripts/api/http.ts +++ b/resources/scripts/api/http.ts @@ -38,6 +38,16 @@ export function httpErrorToHuman (error: any): string { return error.message; } +export interface FractalResponseData { + object: string; + attributes: { + [k: string]: any; + relationships?: { + [k: string]: FractalResponseData; + }; + }; +} + export interface PaginatedResult { items: T[]; pagination: PaginationDataSet; diff --git a/resources/scripts/api/server/users/getServerSubusers.ts b/resources/scripts/api/server/users/getServerSubusers.ts new file mode 100644 index 000000000..0d290549d --- /dev/null +++ b/resources/scripts/api/server/users/getServerSubusers.ts @@ -0,0 +1,21 @@ +import http, { FractalResponseData } from '@/api/http'; +import { Subuser } from '@/state/server/subusers'; + +export const rawDataToServerSubuser = (data: FractalResponseData): Subuser => ({ + uuid: data.attributes.uuid, + username: data.attributes.username, + email: data.attributes.email, + image: data.attributes.image, + twoFactorEnabled: data.attributes['2fa_enabled'], + createdAt: new Date(data.attributes.created_at), + permissions: data.attributes.relationships!.permissions.attributes.permissions, + can: permission => data.attributes.relationships!.permissions.attributes.permissions.indexOf(permission) >= 0, +}); + +export default (uuid: string): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/client/servers/${uuid}/users`, { params: { include: [ 'permissions' ] } }) + .then(({ data }) => resolve((data.data || []).map(rawDataToServerSubuser))) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/server/users/UsersContainer.tsx b/resources/scripts/components/server/users/UsersContainer.tsx new file mode 100644 index 000000000..527ee46b2 --- /dev/null +++ b/resources/scripts/components/server/users/UsersContainer.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUserPlus } from '@fortawesome/free-solid-svg-icons/faUserPlus'; +import { ServerContext } from '@/state/server'; +import Spinner from '@/components/elements/Spinner'; + +export default () => { + const [ loading, setLoading ] = useState(true); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const subusers = ServerContext.useStoreState(state => state.subusers.data); + const getSubusers = ServerContext.useStoreActions(actions => actions.subusers.getSubusers); + + useEffect(() => { + getSubusers(uuid) + .then(() => setLoading(false)) + .catch(error => { + console.error(error); + }); + }, [ uuid, getSubusers ]); + + useEffect(() => { + if (subusers.length > 0) { + setLoading(false); + } + }, [subusers]); + + return ( +
+
+

Subusers

+
+ {loading ? +
+ +
+ : + !subusers.length ? +

It looks like you don't have any subusers.

+ : + subusers.map(subuser => ( +
+ +
+

{subuser.email}

+
+
+ + +
+
+ )) + } +
+
+ +
+
+
+ ); +}; diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 4e5919dd6..38635f9d8 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -12,6 +12,7 @@ import FileManagerContainer from '@/components/server/files/FileManagerContainer import { CSSTransition } from 'react-transition-group'; import SuspenseSpinner from '@/components/elements/SuspenseSpinner'; import FileEditContainer from '@/components/server/files/FileEditContainer'; +import UsersContainer from '@/components/server/users/UsersContainer'; const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => { const server = ServerContext.useStoreState(state => state.server.data); @@ -61,7 +62,8 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) )} exact /> - + + } diff --git a/resources/scripts/state/server/index.ts b/resources/scripts/state/server/index.ts index 1dd54dc06..334b7b493 100644 --- a/resources/scripts/state/server/index.ts +++ b/resources/scripts/state/server/index.ts @@ -3,6 +3,7 @@ import { action, Action, createContextStore, thunk, Thunk } from 'easy-peasy'; import socket, { SocketStore } from './socket'; import { ServerDatabase } from '@/api/server/getServerDatabases'; import files, { ServerFileStore } from '@/state/server/files'; +import subusers, { ServerSubuserStore } from '@/state/server/subusers'; export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running'; @@ -56,6 +57,7 @@ const databases: ServerDatabaseStore = { export interface ServerStore { server: ServerDataStore; + subusers: ServerSubuserStore; databases: ServerDatabaseStore; files: ServerFileStore; socket: SocketStore; @@ -69,9 +71,14 @@ export const ServerContext = createContextStore({ status, databases, files, + subusers, clearServerState: action(state => { state.server.data = undefined; state.databases.items = []; + state.subusers.data = []; + + state.files.directory = '/'; + state.files.contents = []; if (state.socket.instance) { state.socket.instance.removeAllListeners(); diff --git a/resources/scripts/state/server/subusers.ts b/resources/scripts/state/server/subusers.ts new file mode 100644 index 000000000..758e704cf --- /dev/null +++ b/resources/scripts/state/server/subusers.ts @@ -0,0 +1,43 @@ +import { action, Action, thunk, Thunk } from 'easy-peasy'; +import getServerSubusers from '@/api/server/users/getServerSubusers'; + +export type SubuserPermission = string; + +export interface Subuser { + uuid: string; + username: string; + email: string; + image: string; + twoFactorEnabled: boolean; + createdAt: Date; + permissions: SubuserPermission[]; + + can (permission: SubuserPermission): boolean; +} + +export interface ServerSubuserStore { + data: Subuser[]; + setSubusers: Action; + appendSubuser: Action; + getSubusers: Thunk>; +} + +const subusers: ServerSubuserStore = { + data: [], + + setSubusers: action((state, payload) => { + state.data = payload; + }), + + appendSubuser: action((state, payload) => { + state.data = [...state.data, payload]; + }), + + getSubusers: thunk(async (actions, payload) => { + const subusers = await getServerSubusers(payload); + + actions.setSubusers(subusers); + }), +}; + +export default subusers; diff --git a/resources/styles/components/miscellaneous.css b/resources/styles/components/miscellaneous.css index 9b2c4d39c..48c51e8f3 100644 --- a/resources/styles/components/miscellaneous.css +++ b/resources/styles/components/miscellaneous.css @@ -21,5 +21,9 @@ code.clean { } .grey-box { - @apply .mt-4 .shadow-md .bg-neutral-700 .rounded .p-3 .flex .text-xs; -} \ No newline at end of file + @apply .shadow-md .bg-neutral-700 .rounded .p-3 .flex .text-xs; + + &:not(.mt-0) { + @apply .mt-4; + } +} diff --git a/routes/api-client.php b/routes/api-client.php index b246bebcc..6862227e6 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -57,4 +57,8 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ Route::group(['prefix' => '/network'], function () { Route::get('/', 'Servers\NetworkController@index')->name('api.client.servers.network'); }); + + Route::group(['prefix' => '/users'], function () { + Route::get('/', 'Servers\SubuserController@index'); + }); });