mirror of
https://github.com/HangarMC/Hangar.git
synced 2025-01-30 14:30:08 +08:00
feat(front+backend): add user list to admin section
This commit is contained in:
parent
60ad2741d5
commit
c7e519b811
@ -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));
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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")),
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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!");
|
||||
}
|
||||
}
|
||||
|
108
frontend/src/pages/admin/user/index.vue
Normal file
108
frontend/src/pages/admin/user/index.vue
Normal 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>
|
Loading…
Reference in New Issue
Block a user