feat(front+backend): add user list to admin section

This commit is contained in:
MiniDigger | Martin 2022-12-22 20:41:36 +01:00
parent 60ad2741d5
commit c7e519b811
10 changed files with 153 additions and 28 deletions

View File

@ -37,6 +37,7 @@ public class UsersController extends HangarComponent implements IUsersController
}
@Override
@ApplicableSorters({SorterRegistry.USER_NAME, SorterRegistry.USER_JOIN_DATE, SorterRegistry.USER_PROJECT_COUNT, SorterRegistry.USER_LOCKED, SorterRegistry.USER_ORG, SorterRegistry.USER_ROLES})
public ResponseEntity<PaginatedResult<User>> getUsers(String query, @NotNull RequestPagination pagination) {
return ResponseEntity.ok(usersApiService.getUsers(query, pagination, User.class));
}

View File

@ -53,7 +53,7 @@ public interface IUsersController {
@ApiResponse(code = 403, message = "Not enough permissions to use this endpoint")
})
@GetMapping("/users")
ResponseEntity<PaginatedResult<User>> getUsers(@ApiParam(value = "The search query", required = true) @RequestParam("query") String query,
ResponseEntity<PaginatedResult<User>> getUsers(@ApiParam(value = "The search query", required = true) @RequestParam(value = "query", required = false) String query,
@ApiParam("Pagination information") @NotNull RequestPagination pagination);
@ApiOperation(

View File

@ -11,6 +11,8 @@ public enum SorterRegistry implements Sorter {
USER_NAME("name", simpleSorter("lower(u.name)")),
USER_PROJECT_COUNT("projectCount", simpleSorter("project_count")),
USER_ROLES("roles", simpleSorter("roles")),
USER_ORG("org", simpleSorter("is_organization")),
USER_LOCKED("locked", simpleSorter("locked")),
// For Projects
VIEWS("views", simpleSorter("hp.views")),

View File

@ -1,12 +1,18 @@
package io.papermc.hangar.db.dao;
import io.papermc.hangar.db.extras.BindPagination;
import io.papermc.hangar.db.mappers.factories.RoleColumnMapperFactory;
import io.papermc.hangar.model.api.User;
import io.papermc.hangar.model.api.requests.RequestPagination;
import io.papermc.hangar.model.internal.user.HangarUser;
import org.jdbi.v3.sqlobject.config.RegisterColumnMapperFactory;
import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper;
import org.jdbi.v3.sqlobject.customizer.AllowUnusedBindings;
import org.jdbi.v3.sqlobject.customizer.Bind;
import org.jdbi.v3.sqlobject.customizer.Define;
import org.jdbi.v3.sqlobject.statement.MapTo;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.stringtemplate4.UseStringTemplateEngine;
import org.springframework.stereotype.Repository;
import java.util.List;
@ -46,6 +52,8 @@ public interface UsersDAO {
return _getUser(null, id, type);
}
@AllowUnusedBindings // query can be unused
@UseStringTemplateEngine
@SqlQuery("SELECT u.id," +
" u.created_at," +
" u.name," +
@ -59,15 +67,16 @@ public interface UsersDAO {
" u.theme," +
" exists(SELECT 1 FROM organizations o WHERE u.id = o.user_id) AS is_organization" +
" FROM users u" +
" WHERE u.name ILIKE '%' || :query || '%' " +
" <if(hasQuery)>WHERE u.name ILIKE '%' || :query || '%'<endif>" +
" GROUP BY u.id " +
" LIMIT :limit OFFSET :offset")
<T extends User> List<T> getUsers(String query, long limit, long offset, @MapTo Class<T> type);
" <sorters>" +
" <offsetLimit>")
<T extends User> List<T> getUsers(@Define boolean hasQuery, String query, @BindPagination RequestPagination pagination, @MapTo Class<T> type);
@AllowUnusedBindings // query can be unused
@UseStringTemplateEngine
@SqlQuery("SELECT COUNT(*)" +
" FROM users u" +
" JOIN user_global_roles ugr ON u.id = ugr.user_id" +
" JOIN roles r ON ugr.role_id = r.id" +
" WHERE u.name ILIKE '%' || :query || '%'")
long getUsersCount(String query);
" <if(hasQuery)>WHERE u.name ILIKE '%' || :query || '%'<endif>")
long getUsersCount(@Define boolean hasQuery, String query);
}

View File

@ -23,6 +23,7 @@ import io.papermc.hangar.service.internal.projects.ProjectAdminService;
import io.papermc.hangar.service.internal.versions.ReviewService;
import java.util.List;
import java.util.function.BiFunction;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
@ -66,8 +67,9 @@ public class UsersApiService extends HangarComponent {
@Transactional
public <T extends User> PaginatedResult<T> getUsers(String query, RequestPagination pagination, Class<T> type) {
List<T> users = usersDAO.getUsers(query, pagination.getLimit(), pagination.getOffset(), type);
return new PaginatedResult<>(new Pagination(usersDAO.getUsersCount(query), pagination), users);
boolean hasQuery = !StringUtils.isBlank(query);
List<T> users = usersDAO.getUsers(hasQuery, query, pagination, type);
return new PaginatedResult<>(new Pagination(usersDAO.getUsersCount(hasQuery, query), pagination), users);
}
@Transactional

View File

@ -321,6 +321,9 @@ function isRecent(date: string): boolean {
<DropdownItem v-if="hasPerms(NamedPermission.MANUAL_VALUE_CHANGES)" to="/admin/versions">
{{ t("nav.user.platformVersions") }}
</DropdownItem>
<DropdownItem v-if="hasPerms(NamedPermission.EDIT_ALL_USER_SETTINGS)" to="/admin/user/">
{{ t("nav.user.userList") }}
</DropdownItem>
<hr />
<DropdownItem @click="auth.logout()">{{ t("nav.user.logout") }}</DropdownItem>
</div>

View File

@ -50,6 +50,10 @@ export async function useAuthors() {
return (await useAsyncData("useAuthors", () => useApi<PaginatedResult<User>>("authors"))).data;
}
export async function useUsers() {
return (await useAsyncData("useUsers", () => useApi<PaginatedResult<User>>("users"))).data;
}
export async function useInvites() {
return (await useAsyncData("useInvites", () => useInternalApi<Invites>("invites"))).data;
}

View File

@ -69,7 +69,9 @@
"username": "Username",
"roles": "Roles",
"joined": "Joined",
"projects": "Projects"
"projects": "Projects",
"locked": "Locked?",
"organization": "Org?"
}
},
"nav": {
@ -87,6 +89,7 @@
"health": "Hangar Health",
"log": "User Action Log",
"platformVersions": "Platform Versions",
"userList": "User List",
"logout": "Sign out",
"error": {
"loginFailed": "Authentication Failed",
@ -920,6 +923,9 @@
"forum": "Forum Profile",
"roles": "Roles"
},
"userList": {
"title": "User List"
},
"userActionLog": {
"title": "User Action Log",
"user": "User",

View File

@ -1,12 +1,10 @@
import { RouteLocationNamedRaw, RouteLocationNormalized } from "vue-router";
import { Composer, useI18n } from "vue-i18n";
import { PermissionCheck, UserPermissions } from "hangar-api";
import { defineNuxtRouteMiddleware, handleRequestError, hasPerms, navigateTo, toNamedPermission, useApi, useAuth } from "#imports";
import { defineNuxtRouteMiddleware, handleRequestError, hasPerms, toNamedPermission, useApi, useAuth } from "#imports";
import { useAuthStore } from "~/store/auth";
import { routePermLog } from "~/lib/composables/useLog";
import { NamedPermission, PermissionType } from "~/types/enums";
import { useErrorRedirect } from "~/lib/composables/useErrorRedirect";
import { I18n } from "~/lib/i18n";
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized, from: RouteLocationNormalized) => {
if (to.fullPath.startsWith("/@vite")) {
@ -17,10 +15,7 @@ export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized, fro
await useAuth.updateUser();
await loadRoutePerms(to);
const result = await handleRoutePerms(to);
if (result) {
return navigateTo(result, { redirectCode: result.params?.status });
}
await handleRoutePerms(to);
});
async function loadRoutePerms(to: RouteLocationNormalized) {
@ -60,9 +55,7 @@ async function handleRoutePerms(to: RouteLocationNormalized) {
for (const key in handlers) {
if (!to.meta[key]) continue;
const handler = handlers[key];
const result = await handler(authStore, to);
routePermLog("result", result);
if (result) return result;
await handler(authStore, to);
}
}
@ -83,8 +76,7 @@ function currentUserRequired(authStore: ReturnType<typeof useAuthStore>, to: Rou
if (!to.params.user) {
throw new TypeError("Must have 'user' as a route param to use CurrentUser");
}
const result = checkLogin(authStore, to, 404);
if (result) return result;
checkLogin(authStore, to, 404);
if (!hasPerms(NamedPermission.EDIT_ALL_USER_SETTINGS)) {
if (to.params.user !== authStore.user?.name) {
return useErrorRedirect(to, 403);
@ -94,8 +86,7 @@ function currentUserRequired(authStore: ReturnType<typeof useAuthStore>, to: Rou
async function globalPermsRequired(authStore: ReturnType<typeof useAuthStore>, to: RouteLocationNormalized) {
routePermLog("route globalPermsRequired", to.meta.globalPermsRequired);
const result = checkLogin(authStore, to, 403);
if (result) return result;
checkLogin(authStore, to, 403);
const check = await useApi<PermissionCheck>("permissions/hasAll", "get", {
permissions: toNamedPermission(to.meta.globalPermsRequired as string[]),
}).catch((e) => {
@ -119,8 +110,7 @@ function projectPermsRequired(authStore: ReturnType<typeof useAuthStore>, to: Ro
if (!to.params.user || !to.params.project) {
throw new Error("Can't use this decorator on a route without `author` and `slug` path params");
}
const result = checkLogin(authStore, to, 404);
if (result) return result;
checkLogin(authStore, to, 404);
if (!authStore.routePermissions) {
return useErrorRedirect(to, 404);
}
@ -133,6 +123,6 @@ function projectPermsRequired(authStore: ReturnType<typeof useAuthStore>, to: Ro
function checkLogin(authStore: ReturnType<typeof useAuthStore>, to: RouteLocationNormalized, status: number, msg?: string) {
if (!authStore.authenticated) {
routePermLog("not logged in!");
return useErrorRedirect(to, status, msg);
return useErrorRedirect(to, status, msg || "Not logged in!");
}
}

View File

@ -0,0 +1,108 @@
<script lang="ts" setup>
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { useHead } from "@vueuse/head";
import { computed, ref } from "vue";
import { PaginatedResult, User } from "hangar-api";
import PageTitle from "~/lib/components/design/PageTitle.vue";
import Link from "~/lib/components/design/Link.vue";
import Tag from "~/components/Tag.vue";
import { useApi } from "~/composables/useApi";
import { Header } from "~/components/SortableTable.vue";
import { useSeo } from "~/composables/useSeo";
import { useBackendDataStore } from "~/store/backendData";
import { definePageMeta, handleRequestError, watch } from "#imports";
import { useUsers } from "~/composables/useApiHelper";
import InputCheckbox from "~/lib/components/ui/InputCheckbox.vue";
import InputText from "~/lib/components/ui/InputText.vue";
definePageMeta({
globalPermsRequired: ["EDIT_ALL_USER_SETTINGS"],
});
const i18n = useI18n();
const route = useRoute();
const router = useRouter();
const backendData = useBackendDataStore();
const headers = [
{ name: "pic", title: "", sortable: false },
{ name: "name", title: i18n.t("pages.headers.username"), sortable: true },
{ name: "roles", title: i18n.t("pages.headers.roles"), sortable: true },
{ name: "joinDate", title: i18n.t("pages.headers.joined"), sortable: true },
{ name: "projectCount", title: i18n.t("pages.headers.projects"), sortable: true },
{ name: "locked", title: i18n.t("pages.headers.locked"), sortable: true },
{ name: "org", title: i18n.t("pages.headers.organization"), sortable: true },
] as Header[];
const users = await useUsers().catch((e) => handleRequestError(e));
const page = ref(0);
const sort = ref<string[]>([]);
const query = ref();
const requestParams = computed(() => {
const limit = 25;
return {
query: query.value,
limit,
offset: page.value * limit,
sort: sort.value,
};
});
watch(query, update);
async function updateSort(col: string, sorter: Record<string, number>) {
sort.value = [...Object.keys(sorter)]
.map((k) => {
const val = sorter[k];
if (val === -1) return "-" + k;
if (val === 1) return k;
return null;
})
.filter((v) => v !== null) as string[];
await update();
}
async function updatePage(newPage: number) {
page.value = newPage;
await update();
}
async function update() {
users.value = await useApi<PaginatedResult<User>>("users", "GET", requestParams.value);
}
useHead(useSeo(i18n.t("userList.title"), null, route, null));
</script>
<template>
<div>
<PageTitle>{{ i18n.t("userList.title") }}</PageTitle>
<div class="mb-4">
<InputText v-model="query" label="Username" />
</div>
<SortableTable :headers="headers" :items="users?.result" :server-pagination="users?.pagination" @update:sort="updateSort" @update:page="updatePage">
<template #item_pic="{ item }">
<UserAvatar :username="item.name" size="xs"></UserAvatar>
</template>
<template #item_joinDate="{ item }">{{ i18n.d(item?.joinDate, "date") }}</template>
<template #item_name="{ item }">
<Link :to="'/' + item.name">{{ item.name }}</Link>
</template>
<template #item_locked="{ item }">
<InputCheckbox disabled :model-value="item.locked" />
</template>
<template #item_org="{ item }">
<InputCheckbox disabled :model-value="item.isOrganization" />
</template>
<template #item_roles="{ item }">
<div class="space-x-1">
<Tag v-for="role in item.roles" :key="role.roleId" :color="{ background: role.color }" :name="role.title" />
</div>
</template>
</SortableTable>
</div>
</template>