From a68368df3c4c25885cbaa1803032e2d24193d4eb Mon Sep 17 00:00:00 2001 From: KartoffelChipss Date: Sat, 2 May 2026 19:08:02 +0200 Subject: [PATCH] Add git servers --- backend/app/routes/routes.go | 34 +- frontend/src/api/gitservers.api.ts | 65 +++ frontend/src/api/types/gitserver.ts | 28 + frontend/src/components/AppSidebar.tsx | 32 +- frontend/src/components/icons/GitHubIcon.tsx | 15 + frontend/src/components/icons/GitLabIcon.tsx | 15 + frontend/src/components/icons/GiteaIcon.tsx | 15 + frontend/src/components/icons/props.ts | 1 + frontend/src/components/navbar.tsx | 20 +- .../api/gitservers/useCreateGitServer.ts | 16 + .../api/gitservers/useDeleteGitServer.ts | 15 + .../src/hooks/api/gitservers/useGitServers.ts | 9 + .../api/gitservers/useUpdateGitServer.ts | 17 + frontend/src/main.tsx | 2 + frontend/src/pages/GitServers/GitServers.tsx | 502 ++++++++++++++++++ 15 files changed, 757 insertions(+), 29 deletions(-) create mode 100644 frontend/src/api/gitservers.api.ts create mode 100644 frontend/src/api/types/gitserver.ts create mode 100644 frontend/src/components/icons/GitHubIcon.tsx create mode 100644 frontend/src/components/icons/GitLabIcon.tsx create mode 100644 frontend/src/components/icons/GiteaIcon.tsx create mode 100644 frontend/src/components/icons/props.ts create mode 100644 frontend/src/hooks/api/gitservers/useCreateGitServer.ts create mode 100644 frontend/src/hooks/api/gitservers/useDeleteGitServer.ts create mode 100644 frontend/src/hooks/api/gitservers/useGitServers.ts create mode 100644 frontend/src/hooks/api/gitservers/useUpdateGitServer.ts create mode 100644 frontend/src/pages/GitServers/GitServers.tsx diff --git a/backend/app/routes/routes.go b/backend/app/routes/routes.go index 024d4dc..a04871a 100644 --- a/backend/app/routes/routes.go +++ b/backend/app/routes/routes.go @@ -11,6 +11,7 @@ import ( "quay/internal/config" "quay/internal/database" "quay/internal/envconfig" + "quay/internal/security" "github.com/gofiber/fiber/v3" ) @@ -21,6 +22,26 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d userRepository := database.NewSQLiteUserRepository(db) gitServerRepository := database.NewSQLiteGitServerRepository(db) + if uList, err := userRepository.GetAllUsers(); err != nil { + log.Printf("Warning checking users: %v", err) + } else if len(uList) == 0 { + pwd := "admin" + hashedPassword, err := security.HashPassword(pwd) + if err != nil { + log.Println("Error hashing default user password: ", err) + } + defaultUser := models.User{ + Name: "admin", + HashedPassword: hashedPassword, + Role: "admin", + } + if err := userRepository.CreateUser(&defaultUser); err != nil { + log.Printf("Warning creating default user: %v", err) + } else { + log.Printf("Created default user: admin/admin") + } + } + siteHandler := handlers.NewSiteHandler(siteRepository) deploySiteHandler := handlers.NewDeploySiteHandler(envCfg, siteRepository, deploymentRepository) userHandler := handlers.NewUserHandler(userRepository) @@ -85,18 +106,7 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d protected.Get("/users/:id", userHandler.GetUserById) protected.Get("/users/by-name/:name", userHandler.GetUserByName) protected.Get("/me", userHandler.GetMe) - - // Allow creating the very first admin user without auth (bootstrap). - // If an admin already exists, require auth to create users. - if exists, err := userRepository.AdminUserExists(); err != nil { - // if we can't determine, be conservative and require auth - protected.Post("/users", userHandler.CreateUser) - } else if !exists { - public.Post("/users", userHandler.CreateUser) - } else { - protected.Post("/users", userHandler.CreateUser) - } - + protected.Post("/users", userHandler.CreateUser) protected.Put("/users/:id", userHandler.UpdateUser) protected.Delete("/users/:id", userHandler.DeleteUser) diff --git a/frontend/src/api/gitservers.api.ts b/frontend/src/api/gitservers.api.ts new file mode 100644 index 0000000..5d655f6 --- /dev/null +++ b/frontend/src/api/gitservers.api.ts @@ -0,0 +1,65 @@ +import { fetchWithAuth } from '.'; +import type { CreateGitServerRequest, GitServer, UpdateGitServerRequest } from './types/gitserver'; + +export const getGitServers = async (): Promise => { + const response = await fetchWithAuth('/gitservers', { + method: 'GET', + }); + if (response.status === 404) { + return []; + } + if (!response.ok) { + throw new Error('Failed to fetch git servers'); + } + return response.json(); +}; + +export const getGitServerById = async (id: string): Promise => { + const response = await fetchWithAuth(`/gitservers/${id}`, { + method: 'GET', + }); + if (!response.ok) { + throw new Error('Failed to fetch git server'); + } + return response.json(); +}; + +export const createGitServer = async (data: CreateGitServerRequest): Promise => { + const response = await fetchWithAuth('/gitservers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + if (!response.ok) { + throw new Error('Failed to create git server'); + } + return response.json(); +}; + +export const updateGitServer = async ( + id: string, + data: UpdateGitServerRequest +): Promise => { + const response = await fetchWithAuth(`/gitservers/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + if (!response.ok) { + throw new Error('Failed to update git server'); + } + return response.json(); +}; + +export const deleteGitServer = async (id: string): Promise => { + const response = await fetchWithAuth(`/gitservers/${id}`, { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error('Failed to delete git server'); + } +}; diff --git a/frontend/src/api/types/gitserver.ts b/frontend/src/api/types/gitserver.ts new file mode 100644 index 0000000..6b8bbe0 --- /dev/null +++ b/frontend/src/api/types/gitserver.ts @@ -0,0 +1,28 @@ +export type GitServerProtocol = 'http' | 'https'; +export type GitServerType = 'github' | 'gitlab' | 'gitea'; + +export interface GitServer { + id: string; + name: string; + protocol: GitServerProtocol; + baseUrl: string; + type: GitServerType; + auth_token?: string; + created_at: string; +} + +export interface CreateGitServerRequest { + name: string; + protocol: GitServerProtocol; + baseUrl: string; + type: GitServerType; + auth_token?: string; +} + +export interface UpdateGitServerRequest { + name?: string; + protocol?: GitServerProtocol; + baseUrl?: string; + type?: GitServerType; + auth_token?: string; +} diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx index 39400ce..d59e6f3 100644 --- a/frontend/src/components/AppSidebar.tsx +++ b/frontend/src/components/AppSidebar.tsx @@ -10,7 +10,15 @@ import { SidebarMenuItem, SidebarMenuSub, } from '@/components/ui/sidebar'; -import { Home, Library, PanelsTopLeft, PanelTop, Search } from 'lucide-react'; +import { + Home, + Library, + PanelsTopLeft, + PanelTop, + Search, + Users as UsersIcon, + Code2, +} from 'lucide-react'; import { Link, useLocation } from 'react-router'; import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; import { useTheme } from './theme-provider'; @@ -79,6 +87,28 @@ const AppSidebar = () => { + + + + + Users + + + + + + + + Git Servers + + + {/* ( + + GitHub + + +); diff --git a/frontend/src/components/icons/GitLabIcon.tsx b/frontend/src/components/icons/GitLabIcon.tsx new file mode 100644 index 0000000..82fd9e3 --- /dev/null +++ b/frontend/src/components/icons/GitLabIcon.tsx @@ -0,0 +1,15 @@ +import type { IconProps } from './props'; + +export const GitLabIcon = ({ size = 24 }: IconProps) => ( + + GitLab + + +); diff --git a/frontend/src/components/icons/GiteaIcon.tsx b/frontend/src/components/icons/GiteaIcon.tsx new file mode 100644 index 0000000..e415fdf --- /dev/null +++ b/frontend/src/components/icons/GiteaIcon.tsx @@ -0,0 +1,15 @@ +import type { IconProps } from './props'; + +export const GiteaIcon = ({ size = 24 }: IconProps) => ( + + Gitea + + +); diff --git a/frontend/src/components/icons/props.ts b/frontend/src/components/icons/props.ts new file mode 100644 index 0000000..0062bfa --- /dev/null +++ b/frontend/src/components/icons/props.ts @@ -0,0 +1 @@ +export type IconProps = { size?: number | string }; diff --git a/frontend/src/components/navbar.tsx b/frontend/src/components/navbar.tsx index 3c1bd2c..1f23dc3 100644 --- a/frontend/src/components/navbar.tsx +++ b/frontend/src/components/navbar.tsx @@ -10,7 +10,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { ChartLine, CircleUserRound, LogOut, Settings, Users } from 'lucide-react'; +import { GitBranch, LogOut, Users } from 'lucide-react'; interface NavbarProps { userName?: string; @@ -41,18 +41,6 @@ const Navbar = ({ userName, profilePictureUrl }: NavbarProps) => { {userName} - - - - Account - - - - - - Analytics - - @@ -60,9 +48,9 @@ const Navbar = ({ userName, profilePictureUrl }: NavbarProps) => { - - - Settings + + + Git servers diff --git a/frontend/src/hooks/api/gitservers/useCreateGitServer.ts b/frontend/src/hooks/api/gitservers/useCreateGitServer.ts new file mode 100644 index 0000000..9fb6f05 --- /dev/null +++ b/frontend/src/hooks/api/gitservers/useCreateGitServer.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createGitServer } from '../../../api/gitservers.api'; +import type { CreateGitServerRequest } from '../../../api/types/gitserver'; + +export function useCreateGitServer() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateGitServerRequest) => createGitServer(data), + onSuccess: () => { + qc.invalidateQueries({ + queryKey: ['gitservers'], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/gitservers/useDeleteGitServer.ts b/frontend/src/hooks/api/gitservers/useDeleteGitServer.ts new file mode 100644 index 0000000..dd63310 --- /dev/null +++ b/frontend/src/hooks/api/gitservers/useDeleteGitServer.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deleteGitServer } from '../../../api/gitservers.api'; + +export function useDeleteGitServer() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => deleteGitServer(id), + onSuccess: () => { + qc.invalidateQueries({ + queryKey: ['gitservers'], + }); + }, + }); +} diff --git a/frontend/src/hooks/api/gitservers/useGitServers.ts b/frontend/src/hooks/api/gitservers/useGitServers.ts new file mode 100644 index 0000000..39e674c --- /dev/null +++ b/frontend/src/hooks/api/gitservers/useGitServers.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { getGitServers } from '../../../api/gitservers.api'; + +export function useGitServers() { + return useQuery({ + queryKey: ['gitservers'], + queryFn: getGitServers, + }); +} diff --git a/frontend/src/hooks/api/gitservers/useUpdateGitServer.ts b/frontend/src/hooks/api/gitservers/useUpdateGitServer.ts new file mode 100644 index 0000000..73cab90 --- /dev/null +++ b/frontend/src/hooks/api/gitservers/useUpdateGitServer.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { updateGitServer } from '../../../api/gitservers.api'; +import type { UpdateGitServerRequest } from '../../../api/types/gitserver'; + +export function useUpdateGitServer() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateGitServerRequest }) => + updateGitServer(id, data), + onSuccess: () => { + qc.invalidateQueries({ + queryKey: ['gitservers'], + }); + }, + }); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9cc6ccd..35adf2d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import './index.css'; import NewSite from './pages/NewSite/NewSite'; import SiteOverview from './pages/SiteOverview/SiteOverview'; import Users from './pages/Users/Users'; +import GitServers from './pages/GitServers/GitServers'; import Login from './pages/Login/Login'; import Logout from './pages/Logout/Logout'; @@ -23,6 +24,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/GitServers/GitServers.tsx b/frontend/src/pages/GitServers/GitServers.tsx new file mode 100644 index 0000000..32b182e --- /dev/null +++ b/frontend/src/pages/GitServers/GitServers.tsx @@ -0,0 +1,502 @@ +import { Plus, Trash2, Edit2, X, Code2 } from 'lucide-react'; +import { Button } from '../../components/ui/button'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '../../components/ui/empty'; +import { useGitServers } from '../../hooks/api/gitservers/useGitServers'; +import { useCreateGitServer } from '../../hooks/api/gitservers/useCreateGitServer'; +import { useDeleteGitServer } from '../../hooks/api/gitservers/useDeleteGitServer'; +import { useUpdateGitServer } from '../../hooks/api/gitservers/useUpdateGitServer'; +import Page from '../Page'; +import { Skeleton } from '../../components/ui/skeleton'; +import { memo, useState } from 'react'; +import type { GitServer, GitServerProtocol, GitServerType } from '../../api/types/gitserver'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '../../components/ui/dialog'; +import { Input } from '../../components/ui/input'; +import { Label } from '../../components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../../components/ui/select'; +import { GiteaIcon } from '../../components/icons/GiteaIcon'; +import { GitLabIcon } from '../../components/icons/GitLabIcon'; +import { GitHubIcon } from '../../components/icons/GitHubIcon'; + +const DeleteGitServerDialog = ({ gitServerId }: { gitServerId: string }) => { + const [open, setOpen] = useState(false); + const deleteGitServer = useDeleteGitServer(); + + const handleDelete = () => { + deleteGitServer.mutate(gitServerId, { + onSuccess: () => { + setOpen(false); + }, + }); + }; + + return ( + + + + + + + Delete git server + + Are you sure you want to delete this git server? This action cannot be + undone. + + + + + + + + + ); +}; + +const GitServerTypeIcon = ({ type }: { type: GitServerType }) => { + switch (type) { + case 'github': + return ; + case 'gitlab': + return ; + case 'gitea': + return ; + default: + return ; + } +}; + +const GitServerRow = ({ gitServer }: { gitServer: GitServer }) => { + return ( +
+
+ +
+
+

{gitServer.name}

+

+ {gitServer.protocol}://{gitServer.baseUrl} +

+
+
+ + +
+
+ ); +}; + +const GitServersLoadingSkeleton = memo(() => { + return ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ); +}); + +const CreateGitServerDialog = () => { + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [protocol, setProtocol] = useState<'https' | 'http'>('https'); + const [baseUrl, setBaseUrl] = useState(''); + const [type, setType] = useState<'github' | 'gitlab' | 'gitea'>('github'); + const [authToken, setAuthToken] = useState(''); + + const { mutate: createGitServer, isPending, error, reset } = useCreateGitServer(); + + const resetForm = () => { + setName(''); + setProtocol('https'); + setBaseUrl(''); + setType('github'); + setAuthToken(''); + reset(); + }; + + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) { + resetForm(); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !baseUrl.trim()) return; + + createGitServer( + { + name: name.trim(), + protocol, + baseUrl: baseUrl.trim(), + type, + auth_token: authToken.trim() || undefined, + }, + { + onSuccess: () => { + setOpen(false); + resetForm(); + }, + } + ); + }; + + const isValid = name.trim().length > 0 && baseUrl.trim().length > 0; + + return ( + + + + + +
+ + Create git server + + Add a new git server configuration. You can optionally provide an auth + token. + + + +
+
+ + setName(e.target.value)} + placeholder="My GitHub" + autoFocus + required + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + setBaseUrl(e.target.value)} + placeholder="github.com" + required + /> +
+ +
+ + setAuthToken(e.target.value)} + placeholder="••••••••" + /> +
+ + {error && ( +

+ Failed to create git server. Please try again. +

+ )} +
+ + + + + +
+
+
+ ); +}; + +const UpdateGitServerDialog = ({ gitServer }: { gitServer: GitServer }) => { + const [open, setOpen] = useState(false); + const [name, setName] = useState(gitServer.name); + const [protocol, setProtocol] = useState<'https' | 'http'>(gitServer.protocol); + const [baseUrl, setBaseUrl] = useState(gitServer.baseUrl); + const [type, setType] = useState<'github' | 'gitlab' | 'gitea'>(gitServer.type); + const [authToken, setAuthToken] = useState(gitServer.auth_token || ''); + + const { mutate: updateGitServer, isPending, error, reset } = useUpdateGitServer(); + + const resetForm = () => { + setName(gitServer.name); + setProtocol(gitServer.protocol); + setBaseUrl(gitServer.baseUrl); + setType(gitServer.type); + setAuthToken(gitServer.auth_token || ''); + reset(); + }; + + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) { + resetForm(); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !baseUrl.trim()) return; + + updateGitServer( + { + id: gitServer.id, + data: { + name: name.trim(), + protocol, + baseUrl: baseUrl.trim(), + type, + auth_token: authToken.trim() || undefined, + }, + }, + { + onSuccess: () => { + setOpen(false); + resetForm(); + }, + } + ); + }; + + const isValid = name.trim().length > 0 && baseUrl.trim().length > 0; + const hasChanges = + name !== gitServer.name || + protocol !== gitServer.protocol || + baseUrl !== gitServer.baseUrl || + type !== gitServer.type || + authToken !== (gitServer.auth_token || ''); + + return ( + + + + + +
+ + Edit git server + Update the git server configuration. + + +
+
+ + setName(e.target.value)} + placeholder="My GitHub" + required + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + setBaseUrl(e.target.value)} + placeholder="github.com" + required + /> +
+ +
+ + setAuthToken(e.target.value)} + placeholder="••••••••" + /> +
+ + {error && ( +

+ Failed to update git server. Please try again. +

+ )} +
+ + + + + +
+
+
+ ); +}; + +export const GitServers = () => { + const { data: gitServers, isLoading, error } = useGitServers(); + + if (isLoading || error) { + return null; + } + + return ( + +
+

Git Servers

+ +
+ +
+ {isLoading && } + {error && ( + + + + + + Failed to load git servers + + An error occurred while fetching your git servers. Please try again + later. + + + + )} + {gitServers && + gitServers.length > 0 && + !error && + !isLoading && + gitServers.map((gs) => )} + {gitServers && !error && !isLoading && gitServers.length === 0 && ( + + + + + + + No git servers found + + + You haven't added any git servers yet. + + + + )} +
+
+ ); +}; + +export default GitServers;