Skip to content

Commit e86eef8

Browse files
Add list users API endpoint and UI page
1 parent e7e6326 commit e86eef8

9 files changed

Lines changed: 286 additions & 1 deletion

File tree

apiserver/controllers/users.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright 2022 Cloudbase Solutions SRL
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
// not use this file except in compliance with the License. You may obtain
5+
// a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
// License for the specific language governing permissions and limitations
13+
// under the License.
14+
15+
package controllers
16+
17+
import (
18+
"encoding/json"
19+
"log/slog"
20+
"net/http"
21+
)
22+
23+
// swagger:route GET /users users ListUsers
24+
//
25+
// List all users.
26+
//
27+
// Responses:
28+
// 200: Users
29+
// default: APIErrorResponse
30+
func (a *APIController) ListUsersHandler(w http.ResponseWriter, r *http.Request) {
31+
ctx := r.Context()
32+
33+
users, err := a.r.ListUsers(ctx)
34+
if err != nil {
35+
slog.With(slog.Any("error", err)).ErrorContext(ctx, "listing users")
36+
handleError(ctx, w, err)
37+
return
38+
}
39+
40+
w.Header().Set("Content-Type", "application/json")
41+
if err := json.NewEncoder(w).Encode(users); err != nil {
42+
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
43+
}
44+
}
45+

apiserver/routers/routers.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,13 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware
251251
apiRouter.Handle("/metrics-token/", http.HandlerFunc(han.MetricsTokenHandler)).Methods("GET", "OPTIONS")
252252
apiRouter.Handle("/metrics-token", http.HandlerFunc(han.MetricsTokenHandler)).Methods("GET", "OPTIONS")
253253

254+
///////////
255+
// Users //
256+
///////////
257+
// List users
258+
apiRouter.Handle("/users/", http.HandlerFunc(han.ListUsersHandler)).Methods("GET", "OPTIONS")
259+
apiRouter.Handle("/users", http.HandlerFunc(han.ListUsersHandler)).Methods("GET", "OPTIONS")
260+
254261
/////////////
255262
// Objects //
256263
/////////////

database/common/store.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ type UserStore interface {
8585
GetUser(ctx context.Context, user string) (params.User, error)
8686
GetUserByID(ctx context.Context, userID string) (params.User, error)
8787
GetAdminUser(ctx context.Context) (params.User, error)
88+
ListUsers(ctx context.Context) ([]params.User, error)
8889

8990
CreateUser(ctx context.Context, user params.NewUserParams) (params.User, error)
9091
UpdateUser(ctx context.Context, user string, param params.UpdateUserParams) (params.User, error)

database/sql/users.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,17 @@ func (s *sqlDatabase) GetAdminUser(_ context.Context) (params.User, error) {
163163
}
164164
return s.sqlToParamsUser(user), nil
165165
}
166+
167+
func (s *sqlDatabase) ListUsers(_ context.Context) ([]params.User, error) {
168+
var users []User
169+
q := s.conn.Model(&User{}).Find(&users)
170+
if q.Error != nil {
171+
return nil, fmt.Errorf("error fetching users: %w", q.Error)
172+
}
173+
174+
ret := make([]params.User, len(users))
175+
for idx, user := range users {
176+
ret[idx] = s.sqlToParamsUser(user)
177+
}
178+
return ret, nil
179+
}

runner/runner.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,3 +977,11 @@ func (r *Runner) getGHCliFromInstance(ctx context.Context, instance params.Insta
977977
}
978978
return ghCli, scaleSetCli, nil
979979
}
980+
981+
func (r *Runner) ListUsers(ctx context.Context) ([]params.User, error) {
982+
users, err := r.store.ListUsers(ctx)
983+
if err != nil {
984+
return nil, fmt.Errorf("error fetching users: %w", err)
985+
}
986+
return users, nil
987+
}

webapp/src/lib/api/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
type FileObject,
2828
type FileObjectPaginatedResponse,
2929
type UpdateFileObjectParams,
30+
type User,
3031
} from './generated-client.js';
3132

3233
// Import endpoint and credentials types directly
@@ -68,6 +69,7 @@ export type {
6869
FileObject,
6970
FileObjectPaginatedResponse,
7071
UpdateFileObjectParams,
72+
User,
7173
};
7274

7375
// Legacy APIError type for backward compatibility

webapp/src/lib/api/generated-client.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,27 @@ export class GeneratedGarmApiClient {
677677
async deleteFileObject(objectID: string): Promise<void> {
678678
await this.objectsApi.deleteFileObject(objectID);
679679
}
680+
681+
// User methods (not in generated API yet)
682+
async listUsers(): Promise<User[]> {
683+
const isDevMode = this.isDevelopmentMode();
684+
const headers: Record<string, string> = {
685+
'Content-Type': 'application/json',
686+
};
687+
if (this.token) {
688+
headers['Authorization'] = `Bearer ${this.token}`;
689+
}
690+
const response = await fetch(`${this.baseUrl}/api/v1/users`, {
691+
method: 'GET',
692+
headers,
693+
credentials: isDevMode ? 'omit' : 'include',
694+
});
695+
if (!response.ok) {
696+
const errorData = await response.json().catch(() => ({ error: response.statusText }));
697+
throw new Error(errorData.error || errorData.details || 'Failed to fetch users');
698+
}
699+
return response.json();
700+
}
680701
}
681702

682703
// Create a singleton instance

webapp/src/lib/components/Navigation.svelte

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@
5555
label: 'Organizations',
5656
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
5757
},
58+
{
59+
href: resolve('/users'),
60+
label: 'Users',
61+
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'
62+
},
5863
{
5964
href: resolve('/enterprises'),
6065
label: 'Enterprises',
@@ -384,4 +389,4 @@
384389
<!-- Close user menu when clicking outside -->
385390
{#if userMenuOpen}
386391
<div class="fixed inset-0 z-10" on:click={() => userMenuOpen = false} on:keydown={(e) => { if (e.key === 'Escape') userMenuOpen = false; }} role="button" tabindex="0" aria-label="Close user menu"></div>
387-
{/if}
392+
{/if}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte';
3+
import { garmApi } from '$lib/api/client.js';
4+
import type { User } from '$lib/api/client.js';
5+
import PageHeader from '$lib/components/PageHeader.svelte';
6+
import DataTable from '$lib/components/DataTable.svelte';
7+
import Badge from '$lib/components/Badge.svelte';
8+
import { GenericCell } from '$lib/components/cells';
9+
import { extractAPIError } from '$lib/utils/apiError';
10+
11+
let users: User[] = [];
12+
let loading = true;
13+
let error = '';
14+
let searchTerm = '';
15+
16+
// Pagination
17+
let currentPage = 1;
18+
let perPage = 25;
19+
20+
// Filter users by search term
21+
function filterBySearchTerm(users: User[], term: string): User[] {
22+
if (!term) return users;
23+
const lowerTerm = term.toLowerCase();
24+
return users.filter(user =>
25+
user.username?.toLowerCase().includes(lowerTerm) ||
26+
user.email?.toLowerCase().includes(lowerTerm) ||
27+
user.full_name?.toLowerCase().includes(lowerTerm)
28+
);
29+
}
30+
31+
$: filteredUsers = filterBySearchTerm(users, searchTerm);
32+
$: totalPages = Math.ceil(filteredUsers.length / perPage);
33+
$: {
34+
if (currentPage > totalPages && totalPages > 0) {
35+
currentPage = totalPages;
36+
}
37+
}
38+
$: paginatedUsers = filteredUsers.slice(
39+
(currentPage - 1) * perPage,
40+
currentPage * perPage
41+
);
42+
43+
async function loadUsers() {
44+
try {
45+
loading = true;
46+
error = '';
47+
users = await garmApi.listUsers();
48+
} catch (err) {
49+
error = extractAPIError(err);
50+
console.error('Failed to load users:', err);
51+
} finally {
52+
loading = false;
53+
}
54+
}
55+
56+
onMount(() => {
57+
loadUsers();
58+
});
59+
60+
// DataTable configuration
61+
const columns = [
62+
{
63+
key: 'username',
64+
title: 'Username',
65+
cellComponent: GenericCell,
66+
cellProps: { field: 'username' }
67+
},
68+
{
69+
key: 'email',
70+
title: 'Email',
71+
cellComponent: GenericCell,
72+
cellProps: { field: 'email' }
73+
},
74+
{
75+
key: 'full_name',
76+
title: 'Full Name',
77+
cellComponent: GenericCell,
78+
cellProps: { field: 'full_name' }
79+
},
80+
{
81+
key: 'is_admin',
82+
title: 'Role',
83+
align: 'center' as const
84+
},
85+
{
86+
key: 'enabled',
87+
title: 'Status',
88+
align: 'center' as const
89+
}
90+
];
91+
92+
function handleTableSearch(event: CustomEvent<{ term: string }>) {
93+
searchTerm = event.detail.term;
94+
currentPage = 1;
95+
}
96+
97+
function handleTablePageChange(event: CustomEvent<{ page: number }>) {
98+
currentPage = event.detail.page;
99+
}
100+
101+
function handleTablePerPageChange(event: CustomEvent<{ perPage: number }>) {
102+
perPage = event.detail.perPage;
103+
currentPage = 1;
104+
}
105+
</script>
106+
107+
<svelte:head>
108+
<title>Users - GARM</title>
109+
</svelte:head>
110+
111+
<div class="space-y-6">
112+
<!-- Header -->
113+
<PageHeader
114+
title="Users"
115+
description="View all users in the system"
116+
/>
117+
118+
<DataTable
119+
{columns}
120+
data={paginatedUsers}
121+
{loading}
122+
{error}
123+
{searchTerm}
124+
searchPlaceholder="Search users..."
125+
{currentPage}
126+
{perPage}
127+
{totalPages}
128+
totalItems={filteredUsers.length}
129+
itemName="users"
130+
emptyIconType="users"
131+
showRetry={!!error}
132+
on:search={handleTableSearch}
133+
on:pageChange={handleTablePageChange}
134+
on:perPageChange={handleTablePerPageChange}
135+
on:retry={loadUsers}
136+
>
137+
<!-- Custom cell rendering for Role and Status -->
138+
<svelte:fragment slot="cell" let:column let:item>
139+
{#if column.key === 'is_admin'}
140+
<Badge
141+
variant={item.is_admin ? 'purple' : 'gray'}
142+
text={item.is_admin ? 'Admin' : 'User'}
143+
/>
144+
{:else if column.key === 'enabled'}
145+
<Badge
146+
variant={item.enabled ? 'green' : 'red'}
147+
text={item.enabled ? 'Enabled' : 'Disabled'}
148+
/>
149+
{/if}
150+
</svelte:fragment>
151+
152+
<!-- Mobile card content -->
153+
<svelte:fragment slot="mobile-card" let:item={user}>
154+
<div class="flex items-center justify-between">
155+
<div class="flex-1 min-w-0">
156+
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
157+
{user.username}
158+
</p>
159+
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
160+
{user.email}
161+
</p>
162+
{#if user.full_name}
163+
<p class="text-xs text-gray-400 dark:text-gray-500 truncate">
164+
{user.full_name}
165+
</p>
166+
{/if}
167+
</div>
168+
<div class="flex items-center space-x-2 ml-4">
169+
<Badge
170+
variant={user.is_admin ? 'purple' : 'gray'}
171+
text={user.is_admin ? 'Admin' : 'User'}
172+
/>
173+
<Badge
174+
variant={user.enabled ? 'green' : 'red'}
175+
text={user.enabled ? 'Enabled' : 'Disabled'}
176+
/>
177+
</div>
178+
</div>
179+
</svelte:fragment>
180+
</DataTable>
181+
</div>
182+

0 commit comments

Comments
 (0)