Skip to content

Commit 3259de5

Browse files
committed
feat: add username-based user update support
- Adds optional username field to update request schema - Implements validation to ensure either UUID or username is provided - Updates endpoint description to reflect new lookup options - UUID takes precedence when both identifiers are provided
1 parent c064e61 commit 3259de5

File tree

7 files changed

+118
-103
lines changed

7 files changed

+118
-103
lines changed

libs/contract/commands/users/update-user.command.ts

Lines changed: 75 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -11,73 +11,85 @@ export namespace UpdateUserCommand {
1111
export const endpointDetails = getEndpointDetails(
1212
USERS_ROUTES.UPDATE,
1313
'patch',
14-
'Update a user',
14+
'Update a user by UUID or username',
1515
);
1616

17-
export const RequestSchema = z.object({
18-
uuid: z.string().uuid(),
19-
status: z
20-
.enum([USERS_STATUS.ACTIVE, USERS_STATUS.DISABLED], {
21-
errorMap: () => ({
22-
message:
23-
"You can't change status to LIMITED or EXPIRED. These statuses handled by Remnawave.",
24-
}),
25-
})
17+
export const RequestSchema = z
18+
.object({
19+
username: z.optional(z.string().describe('Username of the user')),
20+
uuid: z.optional(
21+
z
22+
.string()
23+
.uuid()
24+
.describe(
25+
'UUID of the user. UUID has higher priority than username, so if both are provided, username will be ignored.',
26+
),
27+
),
28+
status: z
29+
.enum([USERS_STATUS.ACTIVE, USERS_STATUS.DISABLED], {
30+
errorMap: () => ({
31+
message:
32+
"You can't change status to LIMITED or EXPIRED. These statuses handled by Remnawave.",
33+
}),
34+
})
2635

27-
.optional(),
28-
trafficLimitBytes: z
29-
.number({
30-
invalid_type_error: 'Traffic limit must be a number',
31-
})
32-
.int('Traffic limit must be an integer')
33-
.min(0, 'Traffic limit must be greater than 0')
34-
.describe('Traffic limit in bytes. 0 - unlimited')
35-
.optional(),
36-
trafficLimitStrategy: UsersSchema.shape.trafficLimitStrategy
37-
.describe('Traffic limit reset strategy')
38-
.superRefine((val, ctx) => {
39-
if (val && !Object.values(RESET_PERIODS).includes(val)) {
40-
ctx.addIssue({
41-
code: z.ZodIssueCode.invalid_enum_value,
42-
message: 'Invalid traffic limit strategy',
43-
path: ['trafficLimitStrategy'],
44-
received: val,
45-
options: Object.values(RESET_PERIODS),
46-
});
47-
}
48-
})
49-
.optional(),
50-
expireAt: z
51-
.string()
52-
.datetime({ local: true, offset: true, message: 'Invalid date format' })
53-
.transform((str) => new Date(str))
54-
.refine((date) => date > new Date(), {
55-
message: 'Expiration date cannot be in the past',
56-
})
57-
.describe('Expiration date: 2025-01-17T15:38:45.065Z')
58-
.optional(),
59-
description: z.optional(z.string().nullable()),
60-
tag: z.optional(
61-
z
36+
.optional(),
37+
trafficLimitBytes: z
38+
.number({
39+
invalid_type_error: 'Traffic limit must be a number',
40+
})
41+
.int('Traffic limit must be an integer')
42+
.min(0, 'Traffic limit must be greater than 0')
43+
.describe('Traffic limit in bytes. 0 - unlimited')
44+
.optional(),
45+
trafficLimitStrategy: UsersSchema.shape.trafficLimitStrategy
46+
.describe('Traffic limit reset strategy')
47+
.superRefine((val, ctx) => {
48+
if (val && !Object.values(RESET_PERIODS).includes(val)) {
49+
ctx.addIssue({
50+
code: z.ZodIssueCode.invalid_enum_value,
51+
message: 'Invalid traffic limit strategy',
52+
path: ['trafficLimitStrategy'],
53+
received: val,
54+
options: Object.values(RESET_PERIODS),
55+
});
56+
}
57+
})
58+
.optional(),
59+
expireAt: z
6260
.string()
63-
.regex(
64-
/^[A-Z0-9_]+$/,
65-
'Tag can only contain uppercase letters, numbers, underscores',
66-
)
67-
.max(16, 'Tag must be less than 16 characters')
68-
.nullable(),
69-
),
70-
telegramId: z.optional(z.number().int().nullable()),
71-
email: z.optional(z.string().email('Invalid email format').nullable()),
72-
hwidDeviceLimit: z.optional(
73-
z.number().int().min(0, 'Device limit must be non-negative').nullable(),
74-
),
75-
activeInternalSquads: z
76-
.array(z.string().uuid(), {
77-
invalid_type_error: 'Enabled internal squads must be an array of UUIDs',
78-
})
79-
.optional(),
80-
});
61+
.datetime({ local: true, offset: true, message: 'Invalid date format' })
62+
.transform((str) => new Date(str))
63+
.refine((date) => date > new Date(), {
64+
message: 'Expiration date cannot be in the past',
65+
})
66+
.describe('Expiration date: 2025-01-17T15:38:45.065Z')
67+
.optional(),
68+
description: z.optional(z.string().nullable()),
69+
tag: z.optional(
70+
z
71+
.string()
72+
.regex(
73+
/^[A-Z0-9_]+$/,
74+
'Tag can only contain uppercase letters, numbers, underscores',
75+
)
76+
.max(16, 'Tag must be less than 16 characters')
77+
.nullable(),
78+
),
79+
telegramId: z.optional(z.number().int().nullable()),
80+
email: z.optional(z.string().email('Invalid email format').nullable()),
81+
hwidDeviceLimit: z.optional(
82+
z.number().int().min(0, 'Device limit must be non-negative').nullable(),
83+
),
84+
activeInternalSquads: z
85+
.array(z.string().uuid(), {
86+
invalid_type_error: 'Enabled internal squads must be an array of UUIDs',
87+
})
88+
.optional(),
89+
})
90+
.refine((data) => data.uuid || data.username, {
91+
message: 'Either uuid or username must be provided',
92+
});
8193

8294
export type Request = z.infer<typeof RequestSchema>;
8395

libs/contract/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@remnawave/backend-contract",
3-
"version": "2.1.53",
3+
"version": "2.1.54",
44
"public": true,
55
"license": "AGPL-3.0-only",
66
"description": "A contract library for Remnawave Backend. It can be used in backend and frontend.",

package-lock.json

Lines changed: 28 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"@nestjs/jwt": "11.0.0",
6767
"@nestjs/passport": "11.0.5",
6868
"@nestjs/platform-express": "11.1.6",
69-
"@nestjs/schedule": "6.0.0",
69+
"@nestjs/schedule": "6.0.1",
7070
"@nestjs/serve-static": "5.0.3",
7171
"@nestjs/swagger": "11.2.0",
7272
"@nestjs/terminus": "^11.0.0",
@@ -78,7 +78,7 @@
7878
"@remnawave/hashed-set": "^0.0.4",
7979
"@remnawave/node-contract": "0.5.9",
8080
"@remnawave/xtls-sdk": "^0.6.3",
81-
"@scalar/nestjs-api-reference": "^1.0.1",
81+
"@scalar/nestjs-api-reference": "^1.0.2",
8282
"@stablelib/base64": "^2.0.1",
8383
"@stablelib/x25519": "^2.0.1",
8484
"@willsoto/nestjs-prometheus": "^6.0.2",
@@ -163,4 +163,4 @@
163163
"typescript": "~5.9.2",
164164
"typescript-eslint": "^8.39.1"
165165
}
166-
}
166+
}

src/common/utils/startup-app/docs.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export async function getDocs(app: INestApplication<unknown>, config: ConfigServ
7979
config.getOrThrow<string>('SCALAR_PATH'),
8080

8181
apiReference({
82+
orderSchemaPropertiesBy: 'preserve',
83+
orderRequiredPropertiesFirst: true,
8284
showSidebar: true,
8385
layout: 'modern',
8486
hideModels: false,
@@ -92,6 +94,7 @@ export async function getDocs(app: INestApplication<unknown>, config: ConfigServ
9294
theme: 'purple',
9395
hideClientButton: false,
9496
darkMode: true,
97+
persistAuth: true,
9598
hiddenClients: [
9699
'asynchttp',
97100
'nethttp',
@@ -112,6 +115,7 @@ export async function getDocs(app: INestApplication<unknown>, config: ConfigServ
112115
targetKey: 'js',
113116
clientKey: 'axios',
114117
},
118+
telemetry: false,
115119

116120
content: documentFactory,
117121
}),

src/modules/users/repositories/users.repository.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,6 @@ export class UsersRepository implements ICrud<BaseUserEntity> {
480480
lastConnectedNode: true,
481481
},
482482
): Promise<UserEntity | null> {
483-
// TODO: check this
484483
const result = await this.qb.kysely
485484
.selectFrom('users')
486485
.selectAll()

src/modules/users/users.service.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export class UsersService {
174174
try {
175175
const {
176176
uuid,
177+
username,
177178
expireAt,
178179
trafficLimitBytes,
179180
trafficLimitStrategy,
@@ -186,13 +187,12 @@ export class UsersService {
186187
activeInternalSquads,
187188
} = dto;
188189

189-
const user = await this.userRepository.findUniqueByCriteria(
190-
{ uuid: uuid },
191-
{
192-
activeInternalSquads: true,
193-
lastConnectedNode: false,
194-
},
195-
);
190+
const userCriteria = uuid ? { uuid } : { username };
191+
192+
const user = await this.userRepository.findUniqueByCriteria(userCriteria, {
193+
activeInternalSquads: true,
194+
lastConnectedNode: false,
195+
});
196196

197197
if (!user) {
198198
throw new Error(ERRORS.USER_NOT_FOUND.message);

0 commit comments

Comments
 (0)