Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions apiserver/controllers/users.go
Original file line number Diff line number Diff line change
@@ -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")
}
}

7 changes: 7 additions & 0 deletions apiserver/routers/routers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 //
/////////////
Expand Down
1 change: 1 addition & 0 deletions database/common/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions database/sql/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
8 changes: 8 additions & 0 deletions runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions webapp/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type FileObject,
type FileObjectPaginatedResponse,
type UpdateFileObjectParams,
type User,
} from './generated-client.js';

// Import endpoint and credentials types directly
Expand Down Expand Up @@ -72,6 +73,7 @@ export type {
FileObject,
FileObjectPaginatedResponse,
UpdateFileObjectParams,
User,
};

// Legacy APIError type for backward compatibility
Expand Down
21 changes: 21 additions & 0 deletions webapp/src/lib/api/generated-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,27 @@ export class GeneratedGarmApiClient {
async deleteFileObject(objectID: string): Promise<void> {
await this.objectsApi.deleteFileObject(objectID);
}

// User methods (not in generated API yet)
async listUsers(): Promise<User[]> {
const isDevMode = this.isDevelopmentMode();
const headers: Record<string, string> = {
'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
Expand Down
7 changes: 6 additions & 1 deletion webapp/src/lib/components/Navigation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -394,4 +399,4 @@
<!-- Close user menu when clicking outside -->
{#if userMenuOpen}
<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>
{/if}
{/if}
182 changes: 182 additions & 0 deletions webapp/src/routes/users/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte';
import { garmApi } from '$lib/api/client.js';
import type { User } from '$lib/api/client.js';
import PageHeader from '$lib/components/PageHeader.svelte';
import DataTable from '$lib/components/DataTable.svelte';
import Badge from '$lib/components/Badge.svelte';
import { GenericCell } from '$lib/components/cells';
import { extractAPIError } from '$lib/utils/apiError';

let users: User[] = [];
let loading = true;
let error = '';
let searchTerm = '';

// Pagination
let currentPage = 1;
let perPage = 25;

// Filter users by search term
function filterBySearchTerm(users: User[], term: string): User[] {
if (!term) return users;
const lowerTerm = term.toLowerCase();
return users.filter(user =>
user.username?.toLowerCase().includes(lowerTerm) ||
user.email?.toLowerCase().includes(lowerTerm) ||
user.full_name?.toLowerCase().includes(lowerTerm)
);
}

$: filteredUsers = filterBySearchTerm(users, searchTerm);
$: totalPages = Math.ceil(filteredUsers.length / perPage);
$: {
if (currentPage > totalPages && totalPages > 0) {
currentPage = totalPages;
}
}
$: paginatedUsers = filteredUsers.slice(
(currentPage - 1) * perPage,
currentPage * perPage
);

async function loadUsers() {
try {
loading = true;
error = '';
users = await garmApi.listUsers();
} catch (err) {
error = extractAPIError(err);
console.error('Failed to load users:', err);
} finally {
loading = false;
}
}

onMount(() => {
loadUsers();
});

// DataTable configuration
const columns = [
{
key: 'username',
title: 'Username',
cellComponent: GenericCell,
cellProps: { field: 'username' }
},
{
key: 'email',
title: 'Email',
cellComponent: GenericCell,
cellProps: { field: 'email' }
},
{
key: 'full_name',
title: 'Full Name',
cellComponent: GenericCell,
cellProps: { field: 'full_name' }
},
{
key: 'is_admin',
title: 'Role',
align: 'center' as const
},
{
key: 'enabled',
title: 'Status',
align: 'center' as const
}
];

function handleTableSearch(event: CustomEvent<{ term: string }>) {
searchTerm = event.detail.term;
currentPage = 1;
}

function handleTablePageChange(event: CustomEvent<{ page: number }>) {
currentPage = event.detail.page;
}

function handleTablePerPageChange(event: CustomEvent<{ perPage: number }>) {
perPage = event.detail.perPage;
currentPage = 1;
}
</script>

<svelte:head>
<title>Users - GARM</title>
</svelte:head>

<div class="space-y-6">
<!-- Header -->
<PageHeader
title="Users"
description="View all users in the system"
/>

<DataTable
{columns}
data={paginatedUsers}
{loading}
{error}
{searchTerm}
searchPlaceholder="Search users..."
{currentPage}
{perPage}
{totalPages}
totalItems={filteredUsers.length}
itemName="users"
emptyIconType="users"
showRetry={!!error}
on:search={handleTableSearch}
on:pageChange={handleTablePageChange}
on:perPageChange={handleTablePerPageChange}
on:retry={loadUsers}
>
<!-- Custom cell rendering for Role and Status -->
<svelte:fragment slot="cell" let:column let:item>
{#if column.key === 'is_admin'}
<Badge
variant={item.is_admin ? 'purple' : 'gray'}
text={item.is_admin ? 'Admin' : 'User'}
/>
{:else if column.key === 'enabled'}
<Badge
variant={item.enabled ? 'green' : 'red'}
text={item.enabled ? 'Enabled' : 'Disabled'}
/>
{/if}
</svelte:fragment>

<!-- Mobile card content -->
<svelte:fragment slot="mobile-card" let:item={user}>
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{user.username}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
{user.email}
</p>
{#if user.full_name}
<p class="text-xs text-gray-400 dark:text-gray-500 truncate">
{user.full_name}
</p>
{/if}
</div>
<div class="flex items-center space-x-2 ml-4">
<Badge
variant={user.is_admin ? 'purple' : 'gray'}
text={user.is_admin ? 'Admin' : 'User'}
/>
<Badge
variant={user.enabled ? 'green' : 'red'}
text={user.enabled ? 'Enabled' : 'Disabled'}
/>
</div>
</div>
</svelte:fragment>
</DataTable>
</div>