diff --git a/src/app/components/Clients/Search.vue b/src/app/components/Clients/Search.vue new file mode 100644 index 00000000..f55e8084 --- /dev/null +++ b/src/app/components/Clients/Search.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/app/components/Panel/head/Boat.vue b/src/app/components/Panel/head/Boat.vue index 9282261d..7bb2cffe 100644 --- a/src/app/components/Panel/head/Boat.vue +++ b/src/app/components/Panel/head/Boat.vue @@ -1,5 +1,5 @@ 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,