+
diff --git a/src/app/pages/index.vue b/src/app/pages/index.vue
index 4168eb28..a4f27efe 100644
--- a/src/app/pages/index.vue
+++ b/src/app/pages/index.vue
@@ -4,6 +4,7 @@
+
diff --git a/src/app/stores/clients.ts b/src/app/stores/clients.ts
index d1c621ec..e9c58fa5 100644
--- a/src/app/stores/clients.ts
+++ b/src/app/stores/clients.ts
@@ -31,8 +31,13 @@ export const useClientsStore = defineStore('Clients', () => {
const clients = ref(null);
const clientsPersist = ref>({});
+ 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 };
});
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 73dba722..f76022cc 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -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",
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index 7c95a43b..938f5a16 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -112,7 +112,8 @@
"persistentKeepaliveDesc": "设置保活数据包的发送间隔(秒)。0表示禁用",
"hooks": "钩子脚本",
"hooksDescription": "钩子脚本仅在使用wg-quick时有效",
- "hooksLeaveEmpty": "如果不使用wg-quick,请留空此字段"
+ "hooksLeaveEmpty": "如果不使用wg-quick,请留空此字段",
+ "search": "搜索客户端..."
},
"dialog": {
"change": "确认修改",
diff --git a/src/i18n/locales/zh-HK.json b/src/i18n/locales/zh-HK.json
index b2d80cac..41e7001b 100644
--- a/src/i18n/locales/zh-HK.json
+++ b/src/i18n/locales/zh-HK.json
@@ -113,7 +113,8 @@
"hooks": "掛鉤",
"hooksDescription": "掛鉤僅適用於wg-quick",
"hooksLeaveEmpty": "僅適用於wg-quick,否則請留空",
- "dnsDesc": "客戶端使用的域名系統伺服器(取代全局配置)"
+ "dnsDesc": "客戶端使用的域名系統伺服器(取代全局配置)",
+ "search": "搜尋客戶端..."
},
"dialog": {
"change": "更改",
diff --git a/src/nuxt.config.ts b/src/nuxt.config.ts
index 87adfe60..c56673f2 100644
--- a/src/nuxt.config.ts
+++ b/src/nuxt.config.ts
@@ -13,6 +13,7 @@ export default defineNuxtConfig({
'@pinia/nuxt',
'@eschricht/nuxt-color-mode',
'radix-vue/nuxt',
+ '@vueuse/nuxt',
'@nuxt/eslint',
],
colorMode: {
diff --git a/src/package.json b/src/package.json
index 39e5a720..4ee76576 100644
--- a/src/package.json
+++ b/src/package.json
@@ -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",
diff --git a/src/pnpm-lock.yaml b/src/pnpm-lock.yaml
index 54211319..608d1279 100644
--- a/src/pnpm-lock.yaml
+++ b/src/pnpm-lock.yaml
@@ -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: {}
diff --git a/src/server/api/client/index.get.ts b/src/server/api/client/index.get.ts
index 296e0003..f962e610 100644
--- a/src/server/api/client/index.get.ts
+++ b/src/server/api/client/index.get.ts
@@ -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);
-});
+);
diff --git a/src/server/database/repositories/client/service.ts b/src/server/database/repositories/client/service.ts
index 2fcd40c3..80ddf74d 100644
--- a/src/server/database/repositories/client/service.ts
+++ b/src/server/database/repositories/client/service.ts
@@ -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 });
}
diff --git a/src/server/database/repositories/client/types.ts b/src/server/database/repositories/client/types.ts
index 6553dbc0..479385e3 100644
--- a/src/server/database/repositories/client/types.ts
+++ b/src/server/database/repositories/client/types.ts
@@ -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;
+export const ClientQuerySchema = z.object({
+ filter: filter,
+});
+
+export type ClientQueryType = z.infer;
+
export const ClientUpdateSchema = schemaForType()(
z.object({
name: name,
diff --git a/src/server/utils/WireGuard.ts b/src/server/utils/WireGuard.ts
index 034561c4..8f9a96f2 100644
--- a/src/server/utils/WireGuard.ts
+++ b/src/server/utils/WireGuard.ts
@@ -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,