diff --git a/.repos/3/demo-repo.git/logs/refs/heads/main b/.repos/3/demo-repo.git/logs/refs/heads/main index 162b0c5..bffca4d 100644 --- a/.repos/3/demo-repo.git/logs/refs/heads/main +++ b/.repos/3/demo-repo.git/logs/refs/heads/main @@ -1 +1,2 @@ 822b85a78b17deb9373a3f5a802e9db2a9893846 eed5dac1a3ac99a4e127a1b3b74acedc8c6b2945 devtest 1778145720 +0200 commit: Update README via editor +eed5dac1a3ac99a4e127a1b3b74acedc8c6b2945 b8eb0c5e1ab3acc860bb55c75fcde31ab22b83dd testuser 1778158078 +0200 push diff --git a/.repos/3/demo-repo.git/objects/0c/ab0d84bbc6c67a04f7783fa5309e19e063085e b/.repos/3/demo-repo.git/objects/0c/ab0d84bbc6c67a04f7783fa5309e19e063085e new file mode 100644 index 0000000..cc0ece4 Binary files /dev/null and b/.repos/3/demo-repo.git/objects/0c/ab0d84bbc6c67a04f7783fa5309e19e063085e differ diff --git a/.repos/3/demo-repo.git/objects/9d/aeafb9864cf43055ae93beb0afd6c7d144bfa4 b/.repos/3/demo-repo.git/objects/9d/aeafb9864cf43055ae93beb0afd6c7d144bfa4 new file mode 100644 index 0000000..4667dcf Binary files /dev/null and b/.repos/3/demo-repo.git/objects/9d/aeafb9864cf43055ae93beb0afd6c7d144bfa4 differ diff --git a/.repos/3/demo-repo.git/objects/b8/eb0c5e1ab3acc860bb55c75fcde31ab22b83dd b/.repos/3/demo-repo.git/objects/b8/eb0c5e1ab3acc860bb55c75fcde31ab22b83dd new file mode 100644 index 0000000..9225adb Binary files /dev/null and b/.repos/3/demo-repo.git/objects/b8/eb0c5e1ab3acc860bb55c75fcde31ab22b83dd differ diff --git a/.repos/3/demo-repo.git/refs/heads/main b/.repos/3/demo-repo.git/refs/heads/main index 195395c..ae7c67e 100644 --- a/.repos/3/demo-repo.git/refs/heads/main +++ b/.repos/3/demo-repo.git/refs/heads/main @@ -1 +1 @@ -eed5dac1a3ac99a4e127a1b3b74acedc8c6b2945 +b8eb0c5e1ab3acc860bb55c75fcde31ab22b83dd diff --git a/frontend/src/api/queries/members.ts b/frontend/src/api/queries/members.ts new file mode 100644 index 0000000..cc6f661 --- /dev/null +++ b/frontend/src/api/queries/members.ts @@ -0,0 +1,65 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { z } from 'zod' +import { api } from '../client' +import type { RepoMember } from '../../types/api' + +const memberSchema = z.object({ + userId: z.number(), + username: z.string(), + avatarUrl: z.string().default(''), + permission: z.enum(['read', 'write', 'admin']), + isOwner: z.boolean(), + addedAt: z.string().default(''), +}) + +const membersSchema = z.array(memberSchema) + +export function useRepoMembers(owner: string, repo: string) { + return useQuery({ + queryKey: ['repos', owner, repo, 'members'], + queryFn: () => + api.get(`/api/v1/repos/${owner}/${repo}/members`, membersSchema), + enabled: Boolean(owner && repo), + }) +} + +export function useAddMember(owner: string, repo: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: { username: string; permission: string }) => + api.post( + `/api/v1/repos/${owner}/${repo}/members`, + memberSchema, + data, + ), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'members'] }), + }) +} + +export function useUpdateMember(owner: string, repo: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ username, permission }: { username: string; permission: string }) => + api.patch( + `/api/v1/repos/${owner}/${repo}/members/${username}`, + memberSchema, + { permission }, + ), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'members'] }), + }) +} + +export function useRemoveMember(owner: string, repo: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (username: string) => + api.delete( + `/api/v1/repos/${owner}/${repo}/members/${username}`, + z.any(), + ), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'members'] }), + }) +} diff --git a/frontend/src/pages/RepoSettingsPage.tsx b/frontend/src/pages/RepoSettingsPage.tsx index c6dd3c5..23af1a6 100644 --- a/frontend/src/pages/RepoSettingsPage.tsx +++ b/frontend/src/pages/RepoSettingsPage.tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom' import { useRepo, useUpdateRepo, useDeleteRepo, useUploadRepoAvatar } from '../api/queries/repos' +import { useRepoMembers, useAddMember, useUpdateMember, useRemoveMember } from '../api/queries/members' import { useRecentRepos } from '../hooks/useRecentRepos' +import { useAuth } from '../contexts/AuthContext' import { Skeleton } from '../ui/Skeleton' import { RepoAvatar } from '../ui/RepoAvatar' @@ -128,10 +130,9 @@ export default function RepoSettingsPage() { {SIDEBAR.map(g => g.items.map(i => ))} - {section === 'repository-details' - ? - : - } + {section === 'repository-details' && } + {section === 'repository-permissions' && } + {section !== 'repository-details' && section !== 'repository-permissions' && } ) @@ -500,6 +501,214 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string ) } +// ─── Repository permissions section ────────────────────────────────────────── + +const PERMISSION_LABELS: Record = { + read: { label: 'Read', description: 'Can clone and pull', color: 'bg-[var(--c-surface-muted)] text-[var(--c-muted)]' }, + write: { label: 'Write', description: 'Can push branches and commits', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' }, + admin: { label: 'Admin', description: 'Can manage settings and members', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300' }, +} + +function PermissionBadge({ permission }: { permission: string }) { + const p = PERMISSION_LABELS[permission] ?? PERMISSION_LABELS.read + return ( + + {p.label} + + ) +} + +function RepositoryPermissionsSection({ owner, repo }: { owner: string; repo: string }) { + const { user } = useAuth() + const { data: members, isLoading } = useRepoMembers(owner, repo) + const addMember = useAddMember(owner, repo) + const updateMember = useUpdateMember(owner, repo) + const removeMember = useRemoveMember(owner, repo) + + const [username, setUsername] = useState('') + const [permission, setPermission] = useState('write') + const [addError, setAddError] = useState('') + + const isOwner = members?.find(m => m.isOwner)?.username === user?.username + + async function handleAdd(e: React.FormEvent) { + e.preventDefault() + setAddError('') + if (!username.trim()) return + try { + await addMember.mutateAsync({ username: username.trim(), permission }) + setUsername('') + } catch (err) { + setAddError((err as Error).message) + } + } + + async function handlePermissionChange(memberUsername: string, newPermission: string) { + await updateMember.mutateAsync({ username: memberUsername, permission: newPermission }) + } + + async function handleRemove(memberUsername: string) { + await removeMember.mutateAsync(memberUsername) + } + + return ( +
+
+

Repository permissions

+

+ Manage who has access to this repository and what they can do. +

+
+ +
+ + {/* Permission level reference */} +
+ {Object.entries(PERMISSION_LABELS).map(([key, val]) => ( +
+ +

{val.description}

+
+ ))} +
+ + {/* Member list */} +
+
+

Members

+ {members?.length ?? 0} {members?.length === 1 ? 'person' : 'people'} +
+ + {isLoading ? ( +
+ {[1,2].map(i => ( +
+ + + +
+ ))} +
+ ) : ( +
    + {members?.map(member => ( +
  • + {/* Avatar */} +
    + {member.avatarUrl + ? {member.username} + : member.username[0]?.toUpperCase() + } +
    + + {/* Name */} +
    + {member.username} + {member.isOwner && ( + owner + )} +
    + + {/* Permission selector or badge */} + {member.isOwner ? ( + + ) : isOwner ? ( +
    + + +
    + ) : ( + + )} +
  • + ))} +
+ )} +
+ + {/* Add member form — only for owner/admin */} + {isOwner && ( +
+
+

Add a member

+
+
+
+
+ + { setUsername(e.target.value); setAddError('') }} + placeholder="e.g. alice" + className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)]" + /> +
+
+ + +
+
+ +
+ {PERMISSION_LABELS[permission]?.description} +
+ + {addError && ( +

{addError}

+ )} + + +
+
+ )} + + {/* Info for non-owners */} + {!isOwner && !isLoading && ( +
+ + + +

+ Only the repository owner and admins can manage member permissions. +

+
+ )} +
+ ) +} + // ─── Coming soon ────────────────────────────────────────────────────────────── function ComingSoon({ sectionId }: { sectionId: SectionId }) { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 02646d3..34e0188 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -92,6 +92,15 @@ export interface SSHKey { createdAt: string } +export interface RepoMember { + userId: number + username: string + avatarUrl: string + permission: 'read' | 'write' | 'admin' + isOwner: boolean + addedAt: string +} + export interface ApiError { error: string status: number diff --git a/internal/api/handlers/githttp.go b/internal/api/handlers/githttp.go index 8abc842..ead8e35 100644 --- a/internal/api/handlers/githttp.go +++ b/internal/api/handlers/githttp.go @@ -59,11 +59,21 @@ func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) { } } - // Require authentication for push; allow anonymous read for public repos + // Authenticate and enforce permission checks. var authedUser string user, authed := h.basicAuth(r) if authed { authedUser = user + // Push requires write or admin permission. + if service == "git-receive-pack" && !HasPermission(h.db, &repo, user, "write") { + http.Error(w, "forbidden: you do not have write access to this repository", http.StatusForbidden) + return + } + // Pull on a private repo requires at least read permission. + if repo.IsPrivate && !HasPermission(h.db, &repo, user, "read") { + http.Error(w, "forbidden: you do not have read access to this repository", http.StatusForbidden) + return + } } else if service == "git-receive-pack" || repo.IsPrivate { w.Header().Set("WWW-Authenticate", `Basic realm="ForgeBucket"`) http.Error(w, "authentication required", http.StatusUnauthorized) diff --git a/internal/api/handlers/members.go b/internal/api/handlers/members.go new file mode 100644 index 0000000..043da93 --- /dev/null +++ b/internal/api/handlers/members.go @@ -0,0 +1,250 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "xorm.io/xorm" + + "github.com/forgeo/forgebucket/internal/api/middleware" + "github.com/forgeo/forgebucket/internal/models" +) + +type MemberHandler struct { + db *xorm.Engine +} + +func NewMemberHandler(db *xorm.Engine) *MemberHandler { + return &MemberHandler{db: db} +} + +type memberResponse struct { + UserID int64 `json:"userId"` + Username string `json:"username"` + AvatarURL string `json:"avatarUrl"` + Permission string `json:"permission"` + IsOwner bool `json:"isOwner"` + AddedAt string `json:"addedAt"` +} + +// lookupRepoForMembers resolves the repo from URL params and returns the owner User. +func (h *MemberHandler) lookupRepoAndOwner(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) { + ownerName := chi.URLParam(r, "owner") + repoName := chi.URLParam(r, "repo") + + var owner models.User + if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found { + jsonError(w, "repository not found", http.StatusNotFound) + return nil, nil, false + } + var repo models.Repository + if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found { + jsonError(w, "repository not found", http.StatusNotFound) + return nil, nil, false + } + return &repo, &owner, true +} + +// callerCanManage returns true if callerID is the repo owner or has admin permission. +func (h *MemberHandler) callerCanManage(repo *models.Repository, callerID int64) bool { + if callerID == repo.OwnerID { + return true + } + var m models.RepoMember + found, _ := h.db.Where("repo_id = ? AND user_id = ? AND permission = 'admin'", repo.ID, callerID).Get(&m) + return found +} + +// List returns all members (explicit + owner) for a repo. +func (h *MemberHandler) List(w http.ResponseWriter, r *http.Request) { + repo, owner, ok := h.lookupRepoAndOwner(w, r) + if !ok { + return + } + + result := []memberResponse{ + { + UserID: owner.ID, + Username: owner.Username, + AvatarURL: owner.AvatarURL, + Permission: "admin", + IsOwner: true, + AddedAt: repo.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + }, + } + + var members []models.RepoMember + h.db.Where("repo_id = ?", repo.ID).OrderBy("created_at").Find(&members) + + for _, m := range members { + var u models.User + if found, _ := h.db.ID(m.UserID).Get(&u); !found { + continue + } + result = append(result, memberResponse{ + UserID: u.ID, + Username: u.Username, + AvatarURL: u.AvatarURL, + Permission: m.Permission, + IsOwner: false, + AddedAt: m.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) + } + + jsonOK(w, result) +} + +// Add grants a user access to the repo. +func (h *MemberHandler) Add(w http.ResponseWriter, r *http.Request) { + repo, _, ok := h.lookupRepoAndOwner(w, r) + if !ok { + return + } + callerID, _ := middleware.UserIDFromContext(r.Context()) + if !h.callerCanManage(repo, callerID) { + jsonError(w, "only the owner or an admin can manage members", http.StatusForbidden) + return + } + + var body struct { + Username string `json:"username"` + Permission string `json:"permission"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Username == "" { + jsonError(w, "username is required", http.StatusBadRequest) + return + } + if body.Permission != "read" && body.Permission != "write" && body.Permission != "admin" { + jsonError(w, "permission must be read, write, or admin", http.StatusBadRequest) + return + } + + var target models.User + if found, _ := h.db.Where("username = ?", body.Username).Get(&target); !found { + jsonError(w, "user not found", http.StatusNotFound) + return + } + if target.ID == repo.OwnerID { + jsonError(w, "the repository owner always has admin access", http.StatusConflict) + return + } + + // Upsert: if already a member, update permission. + var existing models.RepoMember + found, _ := h.db.Where("repo_id = ? AND user_id = ?", repo.ID, target.ID).Get(&existing) + if found { + existing.Permission = body.Permission + h.db.ID(existing.ID).Cols("permission").Update(&existing) + } else { + m := &models.RepoMember{RepoID: repo.ID, UserID: target.ID, Permission: body.Permission} + if _, err := h.db.Insert(m); err != nil { + jsonError(w, "could not add member", http.StatusInternalServerError) + return + } + } + + jsonOK(w, memberResponse{ + UserID: target.ID, + Username: target.Username, + AvatarURL: target.AvatarURL, + Permission: body.Permission, + IsOwner: false, + }) +} + +// UpdatePermission changes an existing member's permission level. +func (h *MemberHandler) UpdatePermission(w http.ResponseWriter, r *http.Request) { + repo, owner, ok := h.lookupRepoAndOwner(w, r) + if !ok { + return + } + callerID, _ := middleware.UserIDFromContext(r.Context()) + if !h.callerCanManage(repo, callerID) { + jsonError(w, "only the owner or an admin can manage members", http.StatusForbidden) + return + } + + targetUsername := chi.URLParam(r, "username") + var target models.User + if found, _ := h.db.Where("username = ?", targetUsername).Get(&target); !found { + jsonError(w, "user not found", http.StatusNotFound) + return + } + if target.ID == owner.ID { + jsonError(w, "cannot change the owner's permission", http.StatusConflict) + return + } + + var body struct { + Permission string `json:"permission"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + jsonError(w, "invalid body", http.StatusBadRequest) + return + } + if body.Permission != "read" && body.Permission != "write" && body.Permission != "admin" { + jsonError(w, "permission must be read, write, or admin", http.StatusBadRequest) + return + } + + var m models.RepoMember + if found, _ := h.db.Where("repo_id = ? AND user_id = ?", repo.ID, target.ID).Get(&m); !found { + jsonError(w, "user is not a member", http.StatusNotFound) + return + } + m.Permission = body.Permission + h.db.ID(m.ID).Cols("permission").Update(&m) + + jsonOK(w, memberResponse{ + UserID: target.ID, + Username: target.Username, + Permission: body.Permission, + IsOwner: false, + }) +} + +// Remove revokes a user's access to the repo. +func (h *MemberHandler) Remove(w http.ResponseWriter, r *http.Request) { + repo, owner, ok := h.lookupRepoAndOwner(w, r) + if !ok { + return + } + callerID, _ := middleware.UserIDFromContext(r.Context()) + if !h.callerCanManage(repo, callerID) { + jsonError(w, "only the owner or an admin can manage members", http.StatusForbidden) + return + } + + targetUsername := chi.URLParam(r, "username") + var target models.User + if found, _ := h.db.Where("username = ?", targetUsername).Get(&target); !found { + jsonError(w, "user not found", http.StatusNotFound) + return + } + if target.ID == owner.ID { + jsonError(w, "cannot remove the repository owner", http.StatusConflict) + return + } + + h.db.Where("repo_id = ? AND user_id = ?", repo.ID, target.ID).Delete(&models.RepoMember{}) + w.WriteHeader(http.StatusNoContent) +} + +// HasPermission checks if a user (by username) has at least the given permission level on a repo. +// Permission hierarchy: read < write < admin. Owner always passes. +func HasPermission(db *xorm.Engine, repo *models.Repository, username, required string) bool { + var u models.User + if found, _ := db.Where("username = ?", username).Get(&u); !found { + return false + } + if u.ID == repo.OwnerID { + return true + } + var m models.RepoMember + if found, _ := db.Where("repo_id = ? AND user_id = ?", repo.ID, u.ID).Get(&m); !found { + return false + } + rank := map[string]int{"read": 1, "write": 2, "admin": 3} + return rank[m.Permission] >= rank[required] +} diff --git a/internal/api/router.go b/internal/api/router.go index f7e83fd..f102254 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -43,6 +43,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi gitH := handlers.NewGitHTTPHandler(engine, cfg) issueH := handlers.NewIssueHandler(engine) sshKeyH := handlers.NewSSHKeyHandler(engine) + memberH := handlers.NewMemberHandler(engine) // ── Git smart-HTTP transport ─────────────────────────────────────────────── // These routes MUST be registered before the SPA catch-all and outside CSRF. @@ -134,6 +135,12 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi r.Get("/", pipeH.List) r.Get("/{runID}", pipeH.Get) }) + r.Route("/members", func(r chi.Router) { + r.Get("/", memberH.List) + r.With(csrf).Post("/", memberH.Add) + r.With(csrf).Patch("/{username}", memberH.UpdatePermission) + r.With(csrf).Delete("/{username}", memberH.Remove) + }) }) }) }) diff --git a/internal/models/member.go b/internal/models/member.go new file mode 100644 index 0000000..d0baeb0 --- /dev/null +++ b/internal/models/member.go @@ -0,0 +1,13 @@ +package models + +import "time" + +// RepoMember stores the explicit permission a user has been granted on a repository. +// The repository owner always has implicit admin access and is never stored here. +type RepoMember struct { + ID int64 `xorm:"'id' pk autoincr"` + RepoID int64 `xorm:"'repo_id' notnull index"` + UserID int64 `xorm:"'user_id' notnull index"` + Permission string `xorm:"'permission' notnull"` // read | write | admin + CreatedAt time.Time `xorm:"'created_at' created"` +} diff --git a/internal/models/migrations/001_init.go b/internal/models/migrations/001_init.go index a2fbaa9..3c8df80 100644 --- a/internal/models/migrations/001_init.go +++ b/internal/models/migrations/001_init.go @@ -16,5 +16,8 @@ func Run(engine *xorm.Engine) error { ); err != nil { return err } - return Run002(engine) + if err := Run002(engine); err != nil { + return err + } + return Run003(engine) } diff --git a/internal/models/migrations/003_members.go b/internal/models/migrations/003_members.go new file mode 100644 index 0000000..434b1b2 --- /dev/null +++ b/internal/models/migrations/003_members.go @@ -0,0 +1,10 @@ +package migrations + +import ( + "github.com/forgeo/forgebucket/internal/models" + "xorm.io/xorm" +) + +func Run003(engine *xorm.Engine) error { + return engine.Sync2(&models.RepoMember{}) +}