Add search / filter box (#2170)
* feat: Add search client based on #1978 * moved the filtering to the DB level using zod and tidied up some imports. * minor fix * minor fix * fix typo --------- Co-authored-by: Bernd Storath <999999bst@gmail.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="relative w-60 md:mr-2">
|
||||
<div class="relative flex h-full items-center">
|
||||
<MagnifyingGlassIcon
|
||||
class="absolute left-2.5 h-4 w-4 text-gray-400 dark:text-neutral-500"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('client.search')"
|
||||
class="w-full rounded bg-white py-2 pr-8 text-sm text-gray-900 shadow-sm ring-1 ring-gray-300 transition-all placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-red-600 dark:bg-neutral-800 dark:text-white dark:ring-neutral-700 dark:placeholder:text-neutral-500 dark:focus:ring-red-700"
|
||||
@input="updateSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="absolute right-2 flex h-5 w-5 items-center justify-center rounded-full bg-gray-200 text-gray-600 hover:bg-gray-300 hover:text-gray-800 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600 dark:hover:text-neutral-100"
|
||||
aria-label="Clear search"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<IconsClose class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const clientsStore = useClientsStore();
|
||||
const searchQuery = ref('');
|
||||
|
||||
const updateSearch = useDebounceFn(() => {
|
||||
clientsStore.setSearchQuery(searchQuery.value);
|
||||
}, 300);
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = '';
|
||||
clientsStore.setSearchQuery('');
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-shrink-0 space-x-1 md:block">
|
||||
<div class="flex flex-shrink-0 items-center space-x-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<PanelHead>
|
||||
<PanelHeadTitle :text="$t('pages.clients')" />
|
||||
<PanelHeadBoat>
|
||||
<ClientsSearch />
|
||||
<ClientsSort />
|
||||
<ClientsNew />
|
||||
</PanelHeadBoat>
|
||||
|
||||
@@ -31,8 +31,13 @@ export const useClientsStore = defineStore('Clients', () => {
|
||||
const clients = ref<null | LocalClient[]>(null);
|
||||
const clientsPersist = ref<Record<string, ClientPersist>>({});
|
||||
|
||||
const searchParams = ref({
|
||||
filter: undefined as string | undefined,
|
||||
});
|
||||
|
||||
const { data: _clients, refresh: _refresh } = useFetch('/api/client', {
|
||||
method: 'get',
|
||||
params: searchParams,
|
||||
});
|
||||
|
||||
// TODO: rewrite
|
||||
@@ -120,6 +125,7 @@ export const useClientsStore = defineStore('Clients', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// TODO: move sort to backend
|
||||
if (transformedClients !== undefined) {
|
||||
transformedClients = sortByProperty(
|
||||
transformedClients,
|
||||
@@ -130,5 +136,11 @@ export const useClientsStore = defineStore('Clients', () => {
|
||||
|
||||
clients.value = transformedClients ?? null;
|
||||
}
|
||||
return { clients, clientsPersist, refresh, _clients };
|
||||
|
||||
function setSearchQuery(filter: string) {
|
||||
clients.value = null;
|
||||
searchParams.value.filter = filter || undefined;
|
||||
}
|
||||
|
||||
return { clients, clientsPersist, refresh, _clients, setSearchQuery };
|
||||
});
|
||||
|
||||
@@ -116,7 +116,8 @@
|
||||
"dnsDesc": "DNS server clients will use (overrides global config)",
|
||||
"notConnected": "Client not connected",
|
||||
"endpoint": "Endpoint",
|
||||
"endpointDesc": "IP of the client from which the WireGuard connection is established"
|
||||
"endpointDesc": "IP of the client from which the WireGuard connection is established",
|
||||
"search": "Search clients..."
|
||||
},
|
||||
"dialog": {
|
||||
"change": "Change",
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
"persistentKeepaliveDesc": "设置保活数据包的发送间隔(秒)。0表示禁用",
|
||||
"hooks": "钩子脚本",
|
||||
"hooksDescription": "钩子脚本仅在使用wg-quick时有效",
|
||||
"hooksLeaveEmpty": "如果不使用wg-quick,请留空此字段"
|
||||
"hooksLeaveEmpty": "如果不使用wg-quick,请留空此字段",
|
||||
"search": "搜索客户端..."
|
||||
},
|
||||
"dialog": {
|
||||
"change": "确认修改",
|
||||
|
||||
@@ -113,7 +113,8 @@
|
||||
"hooks": "掛鉤",
|
||||
"hooksDescription": "掛鉤僅適用於wg-quick",
|
||||
"hooksLeaveEmpty": "僅適用於wg-quick,否則請留空",
|
||||
"dnsDesc": "客戶端使用的域名系統伺服器(取代全局配置)"
|
||||
"dnsDesc": "客戶端使用的域名系統伺服器(取代全局配置)",
|
||||
"search": "搜尋客戶端..."
|
||||
},
|
||||
"dialog": {
|
||||
"change": "更改",
|
||||
|
||||
@@ -13,6 +13,7 @@ export default defineNuxtConfig({
|
||||
'@pinia/nuxt',
|
||||
'@eschricht/nuxt-color-mode',
|
||||
'radix-vue/nuxt',
|
||||
'@vueuse/nuxt',
|
||||
'@nuxt/eslint',
|
||||
],
|
||||
colorMode: {
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"@phc/format": "^1.0.0",
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"@vueuse/nuxt": "^13.9.0",
|
||||
"apexcharts": "^5.3.5",
|
||||
"argon2": "^0.44.0",
|
||||
"cidr-tools": "^11.0.3",
|
||||
|
||||
Generated
+54
@@ -35,6 +35,12 @@ importers:
|
||||
'@tailwindcss/forms':
|
||||
specifier: ^0.5.10
|
||||
version: 0.5.10(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))
|
||||
'@vueuse/core':
|
||||
specifier: ^13.9.0
|
||||
version: 13.9.0(vue@3.5.22(typescript@5.9.3))
|
||||
'@vueuse/nuxt':
|
||||
specifier: ^13.9.0
|
||||
version: 13.9.0(magicast@0.3.5)(nuxt@3.19.3(@libsql/client@0.15.15)(@parcel/watcher@2.5.1)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.22)(db0@0.3.4(@libsql/client@0.15.15)(drizzle-orm@0.44.6(@libsql/client@0.15.15)))(drizzle-orm@0.44.6(@libsql/client@0.15.15))(eslint@9.37.0(jiti@1.21.7))(ioredis@5.8.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.50.0)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(vite@7.1.9(@types/node@24.7.2)(jiti@1.21.7)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue-tsc@3.1.1(typescript@5.9.3))(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
|
||||
apexcharts:
|
||||
specifier: ^5.3.5
|
||||
version: 5.3.5
|
||||
@@ -1874,6 +1880,9 @@ packages:
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
@@ -2155,12 +2164,31 @@ packages:
|
||||
'@vueuse/core@10.11.1':
|
||||
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
|
||||
|
||||
'@vueuse/core@13.9.0':
|
||||
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/metadata@10.11.1':
|
||||
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
|
||||
|
||||
'@vueuse/metadata@13.9.0':
|
||||
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
|
||||
|
||||
'@vueuse/nuxt@13.9.0':
|
||||
resolution: {integrity: sha512-n/9BRU3nLl2mVI6rYbB3jOctCmQD0xT799hXPCwCn1PyvK7r6O9Nt1dxfVCMfKCDAiCi8Fz2IqPC6Zs2Dv1pVA==}
|
||||
peerDependencies:
|
||||
nuxt: ^3.0.0 || ^4.0.0-0
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/shared@10.11.1':
|
||||
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
||||
|
||||
'@vueuse/shared@13.9.0':
|
||||
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@yr/monotone-cubic-spline@1.0.3':
|
||||
resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==}
|
||||
|
||||
@@ -7190,6 +7218,8 @@ snapshots:
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.7.2
|
||||
@@ -7558,8 +7588,28 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/core@13.9.0(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 13.9.0
|
||||
'@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.9.3))
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
|
||||
'@vueuse/metadata@10.11.1': {}
|
||||
|
||||
'@vueuse/metadata@13.9.0': {}
|
||||
|
||||
'@vueuse/nuxt@13.9.0(magicast@0.3.5)(nuxt@3.19.3(@libsql/client@0.15.15)(@parcel/watcher@2.5.1)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.22)(db0@0.3.4(@libsql/client@0.15.15)(drizzle-orm@0.44.6(@libsql/client@0.15.15)))(drizzle-orm@0.44.6(@libsql/client@0.15.15))(eslint@9.37.0(jiti@1.21.7))(ioredis@5.8.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.50.0)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(vite@7.1.9(@types/node@24.7.2)(jiti@1.21.7)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue-tsc@3.1.1(typescript@5.9.3))(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@nuxt/kit': 3.19.3(magicast@0.3.5)
|
||||
'@vueuse/core': 13.9.0(vue@3.5.22(typescript@5.9.3))
|
||||
'@vueuse/metadata': 13.9.0
|
||||
local-pkg: 1.1.2
|
||||
nuxt: 3.19.3(@libsql/client@0.15.15)(@parcel/watcher@2.5.1)(@types/node@24.7.2)(@vue/compiler-sfc@3.5.22)(db0@0.3.4(@libsql/client@0.15.15)(drizzle-orm@0.44.6(@libsql/client@0.15.15)))(drizzle-orm@0.44.6(@libsql/client@0.15.15))(eslint@9.37.0(jiti@1.21.7))(ioredis@5.8.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.50.0)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(vite@7.1.9(@types/node@24.7.2)(jiti@1.21.7)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue-tsc@3.1.1(typescript@5.9.3))(yaml@2.8.1)
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- magicast
|
||||
|
||||
'@vueuse/shared@10.11.1(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3))
|
||||
@@ -7567,6 +7617,10 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/shared@13.9.0(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
|
||||
'@yr/monotone-cubic-spline@1.0.3': {}
|
||||
|
||||
abbrev@3.0.1: {}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
export default definePermissionEventHandler('clients', 'custom', ({ user }) => {
|
||||
if (user.role === roles.ADMIN) {
|
||||
return WireGuard.getAllClients();
|
||||
import { ClientQuerySchema } from '#db/repositories/client/types';
|
||||
|
||||
export default definePermissionEventHandler(
|
||||
'clients',
|
||||
'custom',
|
||||
async ({ event, user }) => {
|
||||
const { filter } = await getValidatedQuery(
|
||||
event,
|
||||
validateZod(ClientQuerySchema, event)
|
||||
);
|
||||
|
||||
if (user.role === roles.ADMIN) {
|
||||
return WireGuard.getAllClients(filter);
|
||||
}
|
||||
return WireGuard.getClientsForUser(user.id, filter);
|
||||
}
|
||||
return WireGuard.getClientsForUser(user.id);
|
||||
});
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { eq, sql, or, like, and } from 'drizzle-orm';
|
||||
import { containsCidr, parseCidr } from 'cidr-tools';
|
||||
import { client } from './schema';
|
||||
import type {
|
||||
@@ -42,6 +42,39 @@ function createPreparedStatement(db: DBType) {
|
||||
},
|
||||
})
|
||||
.prepare(),
|
||||
findAllPublicFiltered: db.query.client
|
||||
.findMany({
|
||||
where: or(
|
||||
like(client.name, sql.placeholder('filter')),
|
||||
like(client.ipv4Address, sql.placeholder('filter')),
|
||||
like(client.ipv6Address, sql.placeholder('filter'))
|
||||
),
|
||||
with: {
|
||||
oneTimeLink: true,
|
||||
},
|
||||
columns: {
|
||||
privateKey: false,
|
||||
preSharedKey: false,
|
||||
},
|
||||
})
|
||||
.prepare(),
|
||||
findByUserIdFiltered: db.query.client
|
||||
.findMany({
|
||||
where: and(
|
||||
eq(client.userId, sql.placeholder('userId')),
|
||||
or(
|
||||
like(client.name, sql.placeholder('filter')),
|
||||
like(client.ipv4Address, sql.placeholder('filter')),
|
||||
like(client.ipv6Address, sql.placeholder('filter'))
|
||||
)
|
||||
),
|
||||
with: { oneTimeLink: true },
|
||||
columns: {
|
||||
privateKey: false,
|
||||
preSharedKey: false,
|
||||
},
|
||||
})
|
||||
.prepare(),
|
||||
toggle: db
|
||||
.update(client)
|
||||
.set({ enabled: sql.placeholder('enabled') as never as boolean })
|
||||
@@ -96,6 +129,41 @@ export class ClientService {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clients based on user ID and filter conditions
|
||||
*/
|
||||
async getForUserFiltered(userId: ID, filter: string) {
|
||||
const filterPattern = `%${filter.toLowerCase()}%`;
|
||||
|
||||
const result = await this.#statements.findByUserIdFiltered.execute({
|
||||
userId,
|
||||
filter: filterPattern,
|
||||
});
|
||||
|
||||
return result.map((row) => ({
|
||||
...row,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all clients based on filter conditions without sensitive data
|
||||
*/
|
||||
async getAllPublicFiltered(filter: string) {
|
||||
const filterPattern = `%${filter.toLowerCase()}%`;
|
||||
|
||||
const result = await this.#statements.findAllPublicFiltered.execute({
|
||||
filter: filterPattern,
|
||||
});
|
||||
|
||||
return result.map((row) => ({
|
||||
...row,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
}));
|
||||
}
|
||||
|
||||
get(id: ID) {
|
||||
return this.#statements.findById.execute({ id });
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ const address6 = z
|
||||
.min(1, { message: t('zod.client.address6') })
|
||||
.pipe(safeStringRefine);
|
||||
|
||||
const filter = z.string().optional();
|
||||
|
||||
const serverAllowedIps = z.array(AddressSchema, {
|
||||
message: t('zod.client.serverAllowedIps'),
|
||||
});
|
||||
@@ -50,6 +52,12 @@ export const ClientCreateSchema = z.object({
|
||||
|
||||
export type ClientCreateType = z.infer<typeof ClientCreateSchema>;
|
||||
|
||||
export const ClientQuerySchema = z.object({
|
||||
filter: filter,
|
||||
});
|
||||
|
||||
export type ClientQueryType = z.infer<typeof ClientQuerySchema>;
|
||||
|
||||
export const ClientUpdateSchema = schemaForType<UpdateClientType>()(
|
||||
z.object({
|
||||
name: name,
|
||||
|
||||
@@ -61,10 +61,15 @@ class WireGuard {
|
||||
WG_DEBUG('Config synced successfully.');
|
||||
}
|
||||
|
||||
async getClientsForUser(userId: ID) {
|
||||
async getClientsForUser(userId: ID, filter?: string) {
|
||||
const wgInterface = await Database.interfaces.get();
|
||||
|
||||
const dbClients = await Database.clients.getForUser(userId);
|
||||
let dbClients;
|
||||
if (filter?.trim()) {
|
||||
dbClients = await Database.clients.getForUserFiltered(userId, filter);
|
||||
} else {
|
||||
dbClients = await Database.clients.getForUser(userId);
|
||||
}
|
||||
|
||||
const clients = dbClients.map((client) => ({
|
||||
...client,
|
||||
@@ -104,9 +109,16 @@ class WireGuard {
|
||||
return clientDump;
|
||||
}
|
||||
|
||||
async getAllClients() {
|
||||
async getAllClients(filter?: string) {
|
||||
const wgInterface = await Database.interfaces.get();
|
||||
const dbClients = await Database.clients.getAllPublic();
|
||||
|
||||
let dbClients;
|
||||
if (filter?.trim()) {
|
||||
dbClients = await Database.clients.getAllPublicFiltered(filter);
|
||||
} else {
|
||||
dbClients = await Database.clients.getAllPublic();
|
||||
}
|
||||
|
||||
const clients = dbClients.map((client) => ({
|
||||
...client,
|
||||
latestHandshakeAt: null as Date | null,
|
||||
|
||||
Reference in New Issue
Block a user