diff --git a/proto/api/v1/user_service.proto b/proto/api/v1/user_service.proto index 2e00601b1..8ed5376b3 100644 --- a/proto/api/v1/user_service.proto +++ b/proto/api/v1/user_service.proto @@ -20,7 +20,10 @@ service UserService { option (google.api.http) = {get: "/api/v1/users"}; } - // GetUser gets a user by name. + // GetUser gets a user by ID or username. + // Supports both numeric IDs and username strings: + // - users/{id} (e.g., users/101) + // - users/{username} (e.g., users/steven) rpc GetUser(GetUserRequest) returns (User) { option (google.api.http) = {get: "/api/v1/{name=users/*}"}; option (google.api.method_signature) = "name"; @@ -220,9 +223,9 @@ message ListUsersRequest { string page_token = 2 [(google.api.field_behavior) = OPTIONAL]; // Optional. Filter to apply to the list results. - // Example: "state=ACTIVE" or "role=USER" or "email:@example.com" - // Supported operators: =, !=, <, <=, >, >=, : - // Supported fields: username, email, role, state, create_time, update_time + // Example: "username == 'steven'" + // Supported operators: == + // Supported fields: username string filter = 3 [(google.api.field_behavior) = OPTIONAL]; // Optional. If true, show deleted users in the response. @@ -243,7 +246,10 @@ message ListUsersResponse { message GetUserRequest { // Required. The resource name of the user. - // Format: users/{user} + // Supports both numeric IDs and username strings: + // - users/{id} (e.g., users/101) + // - users/{username} (e.g., users/steven) + // Format: users/{id_or_username} string name = 1 [ (google.api.field_behavior) = REQUIRED, (google.api.resource_reference) = {type: "memos.api.v1/User"} diff --git a/proto/gen/api/v1/user_service.pb.go b/proto/gen/api/v1/user_service.pb.go index 6f573b83d..2067416b6 100644 --- a/proto/gen/api/v1/user_service.pb.go +++ b/proto/gen/api/v1/user_service.pb.go @@ -290,9 +290,9 @@ type ListUsersRequest struct { // Provide this to retrieve the subsequent page. PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` // Optional. Filter to apply to the list results. - // Example: "state=ACTIVE" or "role=USER" or "email:@example.com" - // Supported operators: =, !=, <, <=, >, >=, : - // Supported fields: username, email, role, state, create_time, update_time + // Example: "username == 'steven'" + // Supported operators: == + // Supported fields: username Filter string `protobuf:"bytes,3,opt,name=filter,proto3" json:"filter,omitempty"` // Optional. If true, show deleted users in the response. ShowDeleted bool `protobuf:"varint,4,opt,name=show_deleted,json=showDeleted,proto3" json:"show_deleted,omitempty"` @@ -425,7 +425,11 @@ func (x *ListUsersResponse) GetTotalSize() int32 { type GetUserRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Required. The resource name of the user. - // Format: users/{user} + // Supports both numeric IDs and username strings: + // - users/{id} (e.g., users/101) + // - users/{username} (e.g., users/steven) + // + // Format: users/{id_or_username} Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Optional. The fields to return in the response. // If not specified, all fields are returned. diff --git a/proto/gen/api/v1/user_service_grpc.pb.go b/proto/gen/api/v1/user_service_grpc.pb.go index 228f97af7..575241ead 100644 --- a/proto/gen/api/v1/user_service_grpc.pb.go +++ b/proto/gen/api/v1/user_service_grpc.pb.go @@ -49,7 +49,10 @@ const ( type UserServiceClient interface { // ListUsers returns a list of users. ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error) - // GetUser gets a user by name. + // GetUser gets a user by ID or username. + // Supports both numeric IDs and username strings: + // - users/{id} (e.g., users/101) + // - users/{username} (e.g., users/steven) GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error) // CreateUser creates a new user. CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*User, error) @@ -303,7 +306,10 @@ func (c *userServiceClient) DeleteUserWebhook(ctx context.Context, in *DeleteUse type UserServiceServer interface { // ListUsers returns a list of users. ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) - // GetUser gets a user by name. + // GetUser gets a user by ID or username. + // Supports both numeric IDs and username strings: + // - users/{id} (e.g., users/101) + // - users/{username} (e.g., users/steven) GetUser(context.Context, *GetUserRequest) (*User, error) // CreateUser creates a new user. CreateUser(context.Context, *CreateUserRequest) (*User, error) diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index 810b45410..25ecf23f3 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -1217,9 +1217,9 @@ paths: in: query description: |- Optional. Filter to apply to the list results. - Example: "state=ACTIVE" or "role=USER" or "email:@example.com" - Supported operators: =, !=, <, <=, >, >=, : - Supported fields: username, email, role, state, create_time, update_time + Example: "username == 'steven'" + Supported operators: == + Supported fields: username schema: type: string - name: showDeleted @@ -1289,7 +1289,11 @@ paths: get: tags: - UserService - description: GetUser gets a user by name. + description: |- + GetUser gets a user by ID or username. + Supports both numeric IDs and username strings: + - users/{id} (e.g., users/101) + - users/{username} (e.g., users/steven) operationId: UserService_GetUser parameters: - name: user diff --git a/server/router/api/v1/resource_name.go b/server/router/api/v1/resource_name.go index 1a2ee714a..9f1294d28 100644 --- a/server/router/api/v1/resource_name.go +++ b/server/router/api/v1/resource_name.go @@ -73,6 +73,17 @@ func ExtractUserIDFromName(name string) (int32, error) { return id, nil } +// extractUserIdentifierFromName extracts the identifier (ID or username) from a user resource name. +// Supports: "users/101" or "users/steven" +// Returns the identifier string (e.g., "101" or "steven") +func extractUserIdentifierFromName(name string) string { + tokens, err := GetNameParentTokens(name, UserNamePrefix) + if err != nil || len(tokens) == 0 { + return "" + } + return tokens[0] +} + // ExtractMemoUIDFromName returns the memo UID from a resource name. // e.g., "memos/uuid" -> "uuid". func ExtractMemoUIDFromName(name string) (string, error) { diff --git a/server/router/api/v1/user_service.go b/server/router/api/v1/user_service.go index be3adf2dc..3bead551e 100644 --- a/server/router/api/v1/user_service.go +++ b/server/router/api/v1/user_service.go @@ -14,6 +14,8 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/ast" "github.com/labstack/echo/v4" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" @@ -45,9 +47,13 @@ func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersReq userFind := &store.FindUser{} if request.Filter != "" { - if err := validateUserFilter(ctx, request.Filter); err != nil { + username, err := extractUsernameFromFilter(request.Filter) + if err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid filter: %v", err) } + if username != "" { + userFind.Username = &username + } } users, err := s.Store.ListUsers(ctx, userFind) @@ -68,13 +74,29 @@ func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersReq } func (s *APIV1Service) GetUser(ctx context.Context, request *v1pb.GetUserRequest) (*v1pb.User, error) { - userID, err := ExtractUserIDFromName(request.Name) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err) + // Extract identifier from "users/{id_or_username}" + identifier := extractUserIdentifierFromName(request.Name) + if identifier == "" { + return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %s", request.Name) } - user, err := s.Store.GetUser(ctx, &store.FindUser{ - ID: &userID, - }) + + var user *store.User + var err error + + // Try to parse as numeric ID first + if userID, parseErr := strconv.ParseInt(identifier, 10, 32); parseErr == nil { + // It's a numeric ID + userID32 := int32(userID) + user, err = s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID32, + }) + } else { + // It's a username + user, err = s.Store.GetUser(ctx, &store.FindUser{ + Username: &identifier, + }) + } + if err != nil { return nil, status.Errorf(codes.Internal, "failed to get user: %v", err) } @@ -1358,10 +1380,95 @@ func extractWebhookIDFromName(name string) string { return "" } -// validateUserFilter validates the user filter string. -func validateUserFilter(_ context.Context, filterStr string) error { - if strings.TrimSpace(filterStr) != "" { - return errors.New("user filters are not supported") +// extractUsernameFromFilter extracts username from the filter string using CEL. +// Supported filter format: "username == 'steven'" +// Returns the username value and an error if the filter format is invalid. +func extractUsernameFromFilter(filterStr string) (string, error) { + filterStr = strings.TrimSpace(filterStr) + if filterStr == "" { + return "", nil } - return nil + + // Create CEL environment with username variable + env, err := cel.NewEnv( + cel.Variable("username", cel.StringType), + ) + if err != nil { + return "", errors.Wrap(err, "failed to create CEL environment") + } + + // Parse and check the filter expression + celAST, issues := env.Compile(filterStr) + if issues != nil && issues.Err() != nil { + return "", errors.Wrapf(issues.Err(), "invalid filter expression: %s", filterStr) + } + + // Extract username from the AST + username, err := extractUsernameFromAST(celAST.NativeRep().Expr()) + if err != nil { + return "", err + } + + return username, nil +} + +// extractUsernameFromAST extracts the username value from a CEL AST expression. +func extractUsernameFromAST(expr ast.Expr) (string, error) { + if expr == nil { + return "", errors.New("empty expression") + } + + // Check if this is a call expression (for ==, !=, etc.) + if expr.Kind() != ast.CallKind { + return "", errors.New("filter must be a comparison expression (e.g., username == 'value')") + } + + call := expr.AsCall() + + // We only support == operator + if call.FunctionName() != "_==_" { + return "", errors.Errorf("unsupported operator: %s (only '==' is supported)", call.FunctionName()) + } + + // The call should have exactly 2 arguments + args := call.Args() + if len(args) != 2 { + return "", errors.New("invalid comparison expression") + } + + // Try to extract username from either left or right side + if username, ok := extractUsernameFromComparison(args[0], args[1]); ok { + return username, nil + } + if username, ok := extractUsernameFromComparison(args[1], args[0]); ok { + return username, nil + } + + return "", errors.New("filter must compare 'username' field with a string constant") +} + +// extractUsernameFromComparison tries to extract username value if left is 'username' ident and right is a string constant. +func extractUsernameFromComparison(left, right ast.Expr) (string, bool) { + // Check if left side is 'username' identifier + if left.Kind() != ast.IdentKind { + return "", false + } + ident := left.AsIdent() + if ident != "username" { + return "", false + } + + // Right side should be a constant string + if right.Kind() != ast.LiteralKind { + return "", false + } + literal := right.AsLiteral() + + // literal is a ref.Val, we need to get the Go value + str, ok := literal.Value().(string) + if !ok || str == "" { + return "", false + } + + return str, true } diff --git a/web/src/store/user.ts b/web/src/store/user.ts index ff62bd455..6f0e49c93 100644 --- a/web/src/store/user.ts +++ b/web/src/store/user.ts @@ -83,12 +83,10 @@ const userStore = (() => { return userMap[name]; } } - // Use search instead of the deprecated getUserByUsername - const { users } = await userServiceClient.listUsers({ - filter: `username == "${username}"`, - pageSize: 10, + // Use GetUser with username - supports both "users/{id}" and "users/{username}" + const user = await userServiceClient.getUser({ + name: `users/${username}`, }); - const user = users.find((u) => u.username === username); if (!user) { throw new Error(`User with username ${username} not found`); } diff --git a/web/src/types/proto/api/v1/user_service.ts b/web/src/types/proto/api/v1/user_service.ts index f2e8546e0..fdb68a825 100644 --- a/web/src/types/proto/api/v1/user_service.ts +++ b/web/src/types/proto/api/v1/user_service.ts @@ -109,9 +109,9 @@ export interface ListUsersRequest { pageToken: string; /** * Optional. Filter to apply to the list results. - * Example: "state=ACTIVE" or "role=USER" or "email:@example.com" - * Supported operators: =, !=, <, <=, >, >=, : - * Supported fields: username, email, role, state, create_time, update_time + * Example: "username == 'steven'" + * Supported operators: == + * Supported fields: username */ filter: string; /** Optional. If true, show deleted users in the response. */ @@ -133,7 +133,10 @@ export interface ListUsersResponse { export interface GetUserRequest { /** * Required. The resource name of the user. - * Format: users/{user} + * Supports both numeric IDs and username strings: + * - users/{id} (e.g., users/101) + * - users/{username} (e.g., users/steven) + * Format: users/{id_or_username} */ name: string; /** @@ -3221,7 +3224,12 @@ export const UserServiceDefinition = { }, }, }, - /** GetUser gets a user by name. */ + /** + * GetUser gets a user by ID or username. + * Supports both numeric IDs and username strings: + * - users/{id} (e.g., users/101) + * - users/{username} (e.g., users/steven) + */ getUser: { name: "GetUser", requestType: GetUserRequest,