From 93c35ef6408f1f971170204cb1cb81929237f432 Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Tue, 30 Dec 2025 23:38:47 +0000 Subject: [PATCH] Add list users API endpoint and UI page --- apiserver/controllers/users.go | 45 +++++ apiserver/routers/routers.go | 7 + database/common/store.go | 1 + database/sql/users.go | 14 ++ runner/runner.go | 8 + webapp/src/lib/api/client.ts | 2 + webapp/src/lib/api/generated-client.ts | 21 +++ webapp/src/lib/components/Navigation.svelte | 7 +- webapp/src/routes/users/+page.svelte | 182 ++++++++++++++++++++ 9 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 apiserver/controllers/users.go create mode 100644 webapp/src/routes/users/+page.svelte diff --git a/apiserver/controllers/users.go b/apiserver/controllers/users.go new file mode 100644 index 000000000..51b44183f --- /dev/null +++ b/apiserver/controllers/users.go @@ -0,0 +1,45 @@ +// Copyright 2022 Cloudbase Solutions SRL +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package controllers + +import ( + "encoding/json" + "log/slog" + "net/http" +) + +// swagger:route GET /users users ListUsers +// +// List all users. +// +// Responses: +// 200: Users +// default: APIErrorResponse +func (a *APIController) ListUsersHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + users, err := a.r.ListUsers(ctx) + if err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "listing users") + handleError(ctx, w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(users); err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response") + } +} + diff --git a/apiserver/routers/routers.go b/apiserver/routers/routers.go index e11ae25d2..7101fd830 100644 --- a/apiserver/routers/routers.go +++ b/apiserver/routers/routers.go @@ -242,6 +242,13 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware apiRouter.Handle("/metrics-token/", http.HandlerFunc(han.MetricsTokenHandler)).Methods("GET", "OPTIONS") apiRouter.Handle("/metrics-token", http.HandlerFunc(han.MetricsTokenHandler)).Methods("GET", "OPTIONS") + /////////// + // Users // + /////////// + // List users + apiRouter.Handle("/users/", http.HandlerFunc(han.ListUsersHandler)).Methods("GET", "OPTIONS") + apiRouter.Handle("/users", http.HandlerFunc(han.ListUsersHandler)).Methods("GET", "OPTIONS") + ///////////// // Objects // ///////////// diff --git a/database/common/store.go b/database/common/store.go index 97497f4ee..77daf6bec 100644 --- a/database/common/store.go +++ b/database/common/store.go @@ -94,6 +94,7 @@ type UserStore interface { GetUser(ctx context.Context, user string) (params.User, error) GetUserByID(ctx context.Context, userID string) (params.User, error) GetAdminUser(ctx context.Context) (params.User, error) + ListUsers(ctx context.Context) ([]params.User, error) CreateUser(ctx context.Context, user params.NewUserParams) (params.User, error) UpdateUser(ctx context.Context, user string, param params.UpdateUserParams) (params.User, error) diff --git a/database/sql/users.go b/database/sql/users.go index 490234212..b3d6c98f7 100644 --- a/database/sql/users.go +++ b/database/sql/users.go @@ -171,3 +171,17 @@ func (s *sqlDatabase) GetAdminUser(_ context.Context) (params.User, error) { } return s.sqlToParamsUser(user), nil } + +func (s *sqlDatabase) ListUsers(_ context.Context) ([]params.User, error) { + var users []User + q := s.conn.Model(&User{}).Find(&users) + if q.Error != nil { + return nil, fmt.Errorf("error fetching users: %w", q.Error) + } + + ret := make([]params.User, len(users)) + for idx, user := range users { + ret[idx] = s.sqlToParamsUser(user) + } + return ret, nil +} diff --git a/runner/runner.go b/runner/runner.go index 8b2efbee1..fe901e2dc 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -1314,3 +1314,11 @@ func (r *Runner) getGHCliFromInstance(ctx context.Context, instance params.Insta } return ghCli, scaleSetCli, nil } + +func (r *Runner) ListUsers(ctx context.Context) ([]params.User, error) { + users, err := r.store.ListUsers(ctx) + if err != nil { + return nil, fmt.Errorf("error fetching users: %w", err) + } + return users, nil +} diff --git a/webapp/src/lib/api/client.ts b/webapp/src/lib/api/client.ts index 0dea23702..812c7da97 100644 --- a/webapp/src/lib/api/client.ts +++ b/webapp/src/lib/api/client.ts @@ -29,6 +29,7 @@ import { type FileObject, type FileObjectPaginatedResponse, type UpdateFileObjectParams, + type User, } from './generated-client.js'; // Import endpoint and credentials types directly @@ -72,6 +73,7 @@ export type { FileObject, FileObjectPaginatedResponse, UpdateFileObjectParams, + User, }; // Legacy APIError type for backward compatibility diff --git a/webapp/src/lib/api/generated-client.ts b/webapp/src/lib/api/generated-client.ts index ebaa5724a..f1e797e48 100644 --- a/webapp/src/lib/api/generated-client.ts +++ b/webapp/src/lib/api/generated-client.ts @@ -735,6 +735,27 @@ export class GeneratedGarmApiClient { async deleteFileObject(objectID: string): Promise { await this.objectsApi.deleteFileObject(objectID); } + + // User methods (not in generated API yet) + async listUsers(): Promise { + const isDevMode = this.isDevelopmentMode(); + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + const response = await fetch(`${this.baseUrl}/api/v1/users`, { + method: 'GET', + headers, + credentials: isDevMode ? 'omit' : 'include', + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(errorData.error || errorData.details || 'Failed to fetch users'); + } + return response.json(); + } } // Create a singleton instance diff --git a/webapp/src/lib/components/Navigation.svelte b/webapp/src/lib/components/Navigation.svelte index b0e539683..c42284d34 100644 --- a/webapp/src/lib/components/Navigation.svelte +++ b/webapp/src/lib/components/Navigation.svelte @@ -55,6 +55,11 @@ label: 'Organizations', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' // Users/group icon }, + { + href: resolve('/users'), + label: 'Users', + icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' + }, { href: resolve('/enterprises'), label: 'Enterprises', @@ -394,4 +399,4 @@ {#if userMenuOpen}
userMenuOpen = false} on:keydown={(e) => { if (e.key === 'Escape') userMenuOpen = false; }} role="button" tabindex="0" aria-label="Close user menu">
-{/if} \ No newline at end of file +{/if} diff --git a/webapp/src/routes/users/+page.svelte b/webapp/src/routes/users/+page.svelte new file mode 100644 index 000000000..3c686eed0 --- /dev/null +++ b/webapp/src/routes/users/+page.svelte @@ -0,0 +1,182 @@ + + + + Users - GARM + + +
+ + + + + + + {#if column.key === 'is_admin'} + + {:else if column.key === 'enabled'} + + {/if} + + + + +
+
+

+ {user.username} +

+

+ {user.email} +

+ {#if user.full_name} +

+ {user.full_name} +

+ {/if} +
+
+ + +
+
+
+
+
+