From edf3c9824e33c99315d3adf045cb1e0474e12e7b Mon Sep 17 00:00:00 2001 From: erangel1 Date: Mon, 11 May 2026 23:34:46 +0200 Subject: [PATCH] =?UTF-8?q?Phase=203C=20=E2=80=94=20Commit=20Summary=20fea?= =?UTF-8?q?t:=20workspaces=20=E2=80=94=20collaborative=20repo=20namespaces?= =?UTF-8?q?=20Backend=20-=20internal/models/workspace.go=20=E2=80=94=20Wor?= =?UTF-8?q?kspace=20(handle,=20displayName,=20=20=20description,=20created?= =?UTF-8?q?By)=20+=20WorkspaceMember=20(workspaceId,=20userId,=20=20=20use?= =?UTF-8?q?rname,=20role:=20owner/admin/member)=20-=20internal/models/repo?= =?UTF-8?q?.go=20=E2=80=94=20added=20nullable=20workspace=5Fid=20column;?= =?UTF-8?q?=20existing=20=20=20user=20repos=20unaffected=20-=20internal/mo?= =?UTF-8?q?dels/migrations/011=5Fworkspaces.go=20=E2=80=94=20syncs=20both?= =?UTF-8?q?=20tables=20+=20=20=20adds=20column=20to=20repository=20-=20int?= =?UTF-8?q?ernal/api/handlers/workspace.go=20=E2=80=94=20ListWorkspaces,?= =?UTF-8?q?=20CreateWorkspace,=20=20=20GetWorkspace,=20UpdateWorkspace,=20?= =?UTF-8?q?DeleteWorkspace=20(blocks=20if=20repos=20=20=20remain),=20ListR?= =?UTF-8?q?epos,=20ListMembers,=20AddMember,=20UpdateMember,=20RemoveMembe?= =?UTF-8?q?r=20-=20internal/api/handlers/repos.go=20=E2=80=94=20lookupRepo?= =?UTF-8?q?=20resolves=20workspace=20=20=20handles;=20Create=20accepts=20w?= =?UTF-8?q?orkspace=20field;=20List=20includes=20workspace=20=20=20member?= =?UTF-8?q?=20repos;=20withOwnerName=20uses=20workspace=20handle=20for=20w?= =?UTF-8?q?orkspace-owned=20=20=20repos=20-=20internal/api/handlers/dashbo?= =?UTF-8?q?ard.go=20=E2=80=94=20recentRuns=20+=20repo=20list=20include=20?= =?UTF-8?q?=20=20workspace=20repos=20the=20user=20is=20a=20member=20of=20-?= =?UTF-8?q?=20internal/api/router.go=20=E2=80=94=20/workspaces,=20/workspa?= =?UTF-8?q?ces/:handle/*=20routes=20=20=20Workspace=20rules=20enforced:=20?= =?UTF-8?q?-=20Handle=20globally=20unique=20across=20usernames=20+=20works?= =?UTF-8?q?pace=20handles=20(409=20on=20=20=20collision)=20-=20Creator=20a?= =?UTF-8?q?uto-assigned=20owner=20role=20-=20Delete=20blocked=20if=20repos?= =?UTF-8?q?=20exist=20-=20Last=20owner=20cannot=20be=20demoted/removed=20?= =?UTF-8?q?=20=20---=20=20=20feat:=20secret=20management=20hierarchy=20(Gl?= =?UTF-8?q?obal=20=E2=86=92=20Workspace=20=E2=86=92=20Repo=20=E2=86=92=20E?= =?UTF-8?q?nv)=20=20=20Backend=20-=20internal/models/secret.go=20=E2=80=94?= =?UTF-8?q?=20Secret=20struct=20+=20=20=20EncryptSecret/DecryptSecret=20wi?= =?UTF-8?q?th=20AES-256-GCM=20(key=20=3D=20SHA-256=20of=20=20=20SESSION=5F?= =?UTF-8?q?SECRET);=20values=20never=20serialised=20to=20JSON=20-=20intern?= =?UTF-8?q?al/models/migrations/012=5Fsecrets.go=20=E2=80=94=20syncs=20sec?= =?UTF-8?q?ret=20table=20-=20internal/api/handlers/secret.go=20=E2=80=94?= =?UTF-8?q?=20List/Upsert/Delete=20for=20all=20four=20=20=20scopes;=20Reso?= =?UTF-8?q?lveSecretsForRun=20builds=20merged=20env=20map=20for=20CI=20-?= =?UTF-8?q?=20internal/domain/ci/executor.go=20=E2=80=94=20JobContext.Secr?= =?UTF-8?q?ets=20field;=20secrets=20=20=20injected=20as=20--env=20KEY=3DVA?= =?UTF-8?q?LUE=20into=20docker=20run;=20buildJobContext=20calls=20=20=20re?= =?UTF-8?q?solveSecrets(Global=20<=20Workspace=20<=20Repo=20<=20Env)=20-?= =?UTF-8?q?=20internal/domain/ci/runner=5Fmanager.go=20=E2=80=94=20passes?= =?UTF-8?q?=20cfg.SessionSecret=20to=20=20=20buildJobContext=20-=20interna?= =?UTF-8?q?l/api/router.go=20=E2=80=94=20/repos/:owner/:repo/secrets,=20?= =?UTF-8?q?=20=20/environments/:envName/secrets,=20/workspaces/:handle/sec?= =?UTF-8?q?rets,=20=20=20/admin/secrets=20=20=20---=20=20=20feat:=20worksp?= =?UTF-8?q?ace=20+=20secret=20management=20UI=20=20=20Frontend=20-=20types?= =?UTF-8?q?/api.ts=20=E2=80=94=20Workspace,=20WorkspaceWithMeta,=20Workspa?= =?UTF-8?q?ceMember,=20=20=20SecretListItem=20types=20-=20api/queries/work?= =?UTF-8?q?spaces.ts=20=E2=80=94=20full=20CRUD=20hooks=20+=20WorkspaceRepo?= =?UTF-8?q?=20type=20-=20api/queries/secrets.ts=20=E2=80=94=20repo/env/wor?= =?UTF-8?q?kspace=20secret=20hooks=20-=20pages/WorkspacesPage.tsx=20?= =?UTF-8?q?=E2=80=94=20list=20+=20create=20modal=20-=20pages/WorkspacePage?= =?UTF-8?q?.tsx=20=E2=80=94=20workspace=20dashboard=20with=20repo=20list?= =?UTF-8?q?=20-=20pages/WorkspaceSettingsPage.tsx=20=E2=80=94=20general=20?= =?UTF-8?q?settings,=20members=20CRUD,=20=20=20workspace=20secrets,=20dang?= =?UTF-8?q?er=20zone=20-=20pages/RepoSecretsPage.tsx=20=E2=80=94=20repo=20?= =?UTF-8?q?secrets=20+=20per-environment=20secret=20=20=20sections=20with?= =?UTF-8?q?=20priority=20hierarchy=20callout=20-=20pages/CreateRepoPage.ts?= =?UTF-8?q?x=20=E2=80=94=20=3Fworkspace=3D=20query=20param=20pre-fills=20o?= =?UTF-8?q?wner=20=20=20selector;=20only=20admin/owner=20workspaces=20show?= =?UTF-8?q?n=20-=20components/layout/Sidebar.tsx=20=E2=80=94=20"Workspaces?= =?UTF-8?q?"=20global=20nav=20item=20+=20=20=20workspace=20quick-links;=20?= =?UTF-8?q?"Secrets"=20in=20RepoSubNav;=20new=20SecretsIcon,=20=20=20Works?= =?UTF-8?q?paceIcon=20-=20App.tsx=20=E2=80=94=20routes=20for=20/workspaces?= =?UTF-8?q?,=20/workspaces/:handle,=20=20=20/workspaces/:handle/settings,?= =?UTF-8?q?=20/repos/:owner/:repo/secrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- CHANGELOG.md | 15 +- README.md | 5 +- frontend/src/App.tsx | 12 +- frontend/src/api/queries/repos.ts | 1 + frontend/src/api/queries/secrets.ts | 118 ++++++ frontend/src/api/queries/workspaces.ts | 157 +++++++ frontend/src/components/layout/Sidebar.tsx | 24 ++ frontend/src/pages/CreateRepoPage.tsx | 25 +- frontend/src/pages/RepoSecretsPage.tsx | 194 +++++++++ frontend/src/pages/WorkspacePage.tsx | 126 ++++++ frontend/src/pages/WorkspaceSettingsPage.tsx | 312 ++++++++++++++ frontend/src/pages/WorkspacesPage.tsx | 182 +++++++++ frontend/src/types/api.ts | 37 ++ internal/api/handlers/dashboard.go | 34 +- internal/api/handlers/repos.go | 121 ++++-- internal/api/handlers/secret.go | 309 ++++++++++++++ internal/api/handlers/workspace.go | 409 +++++++++++++++++++ internal/api/router.go | 32 ++ internal/domain/ci/executor.go | 73 +++- internal/domain/ci/runner_manager.go | 2 +- internal/models/migrations/001_init.go | 8 +- internal/models/migrations/011_workspaces.go | 20 + internal/models/migrations/012_secrets.go | 10 + internal/models/repo.go | 17 +- internal/models/secret.go | 90 ++++ internal/models/workspace.go | 36 ++ 27 files changed, 2306 insertions(+), 67 deletions(-) create mode 100644 frontend/src/api/queries/secrets.ts create mode 100644 frontend/src/api/queries/workspaces.ts create mode 100644 frontend/src/pages/RepoSecretsPage.tsx create mode 100644 frontend/src/pages/WorkspacePage.tsx create mode 100644 frontend/src/pages/WorkspaceSettingsPage.tsx create mode 100644 frontend/src/pages/WorkspacesPage.tsx create mode 100644 internal/api/handlers/secret.go create mode 100644 internal/api/handlers/workspace.go create mode 100644 internal/models/migrations/011_workspaces.go create mode 100644 internal/models/migrations/012_secrets.go create mode 100644 internal/models/secret.go create mode 100644 internal/models/workspace.go diff --git a/AGENTS.md b/AGENTS.md index 694d31c..1b87f3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,8 +60,8 @@ Understand the phases before adding code — don't build Phase 3 infrastructure | 2B | CI orchestrator, runner manager, Docker executor, artifact registry | **Complete** | | 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette wiring | **Complete** | | 3A | Environment model + deployment tracking | **Complete** | -| 3B | Unified operational timeline | **Active** | -| 3C | Secret management hierarchy | Planned | +| 3B | Unified operational timeline | **Complete** | +| 3C | Workspaces + secret management hierarchy | **Active** | | 3D | GitOps controller + drift detection | Planned | | 3E | Observability (Prometheus, health sparklines) | Planned | | 3F | Federation handlers (ActivityPub inbox/outbox) | Planned | diff --git a/CHANGELOG.md b/CHANGELOG.md index 92a1ea4..e543e0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,20 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### In Progress — Phase 3B (Unified Operational Timeline) +### In Progress — Phase 3C (Workspaces + Secret management hierarchy) +- `Workspace` model — named collaborative namespace (handle, displayName, description, avatarUrl) +- `WorkspaceMember` model — user membership with owner/admin/member roles +- Repos can be owned by a workspace; URL format stays `/{owner}/{repo}` where owner is a workspace handle or username +- `Secret` model — AES-256-GCM encrypted, scoped to global / workspace / repo / env +- Secret hierarchy resolution in CI executor: Env → Repo → Workspace → Global +- Full CRUD APIs for workspaces, workspace members, secrets at all scope levels +- WorkspacesPage, WorkspacePage, WorkspaceSettingsPage (settings + members) +- Workspace switcher in sidebar header +- Create repo: workspace owner selector +- RepoSecretsPage — write-only secret management per repo and per environment +- Sidebar "Secrets" nav item in repo context + +### Completed — Phase 3B (Unified Operational Timeline) - `GET /api/v1/repos/:owner/:repo/timeline` — merges commits, pipeline runs, and deployments into a single chronological feed - `RepoTimelinePage` at `/repos/:owner/:repo/timeline` — vertical event feed with type filter tabs - Sidebar "Timeline" nav item between Environments and Settings diff --git a/README.md b/README.md index af5c1e8..0da3947 100644 --- a/README.md +++ b/README.md @@ -223,8 +223,9 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a | Phase 2B | CI orchestrator, runner manager, Docker backend, artifact registry | Done | | Phase 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette | Done | | Phase 3A | Environment model + deployment tracking | Done | -| Phase 3B | Unified operational timeline | **In progress** | -| Phase 3C–F | Secrets, drift detection, federation, observability | Planned | +| Phase 3B | Unified operational timeline | Done | +| Phase 3C | Workspaces + secret management hierarchy | **In progress** | +| Phase 3D–F | GitOps/drift, federation, observability | Planned | | Phase 4 | AI diagnostics, signed artifacts, OCI registry, dep scanning | Planned | --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cb5dcc8..209618c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,8 +37,12 @@ const PRsPage = lazy(() => import('./pages/PRsPage')) const PipelinesPage = lazy(() => import('./pages/PipelinesPage')) const PipelineRunPage = lazy(() => import('./pages/PipelineRunPage')) const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage')) -const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage')) -const RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage')) +const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage')) +const RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage')) +const RepoSecretsPage = lazy(() => import('./pages/RepoSecretsPage')) +const WorkspacesPage = lazy(() => import('./pages/WorkspacesPage')) +const WorkspacePage = lazy(() => import('./pages/WorkspacePage')) +const WorkspaceSettingsPage = lazy(() => import('./pages/WorkspaceSettingsPage')) const ProfilePage = lazy(() => import('./pages/ProfilePage')) const ExplorePage = lazy(() => import('./pages/ExplorePage')) const SettingsPage = lazy(() => import('./pages/SettingsPage')) @@ -89,11 +93,15 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/queries/repos.ts b/frontend/src/api/queries/repos.ts index d68a966..fde644a 100644 --- a/frontend/src/api/queries/repos.ts +++ b/frontend/src/api/queries/repos.ts @@ -186,6 +186,7 @@ export function useCreateRepo() { defaultBranch?: string initReadme?: 'none' | 'blank' | 'tutorial' initGitignore?: boolean + workspace?: string }) => api.post('/api/v1/repos', repositorySchema, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['repos'] }) diff --git a/frontend/src/api/queries/secrets.ts b/frontend/src/api/queries/secrets.ts new file mode 100644 index 0000000..f029c0e --- /dev/null +++ b/frontend/src/api/queries/secrets.ts @@ -0,0 +1,118 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { z } from 'zod' +import { api } from '../client' +import type { SecretListItem } from '../../types/api' + +const secretSchema = z.object({ + id: z.number(), + name: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +const secretsSchema = z.array(secretSchema) + +// ── Repo secrets ────────────────────────────────────────────────────────────── + +export function useRepoSecrets(owner: string, repo: string) { + return useQuery({ + queryKey: ['repos', owner, repo, 'secrets'], + queryFn: () => + api.get(`/api/v1/repos/${owner}/${repo}/secrets`, secretsSchema), + enabled: Boolean(owner && repo), + }) +} + +export function useUpsertRepoSecret(owner: string, repo: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { name: string; value: string }) => + api.post(`/api/v1/repos/${owner}/${repo}/secrets`, secretSchema, body), + onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'secrets'] }), + }) +} + +export function useDeleteRepoSecret(owner: string, repo: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (name: string) => + api.delete( + `/api/v1/repos/${owner}/${repo}/secrets/${encodeURIComponent(name)}`, + z.unknown() as z.ZodType, + ), + onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'secrets'] }), + }) +} + +// ── Environment secrets ─────────────────────────────────────────────────────── + +export function useEnvSecrets(owner: string, repo: string, envName: string) { + return useQuery({ + queryKey: ['repos', owner, repo, 'environments', envName, 'secrets'], + queryFn: () => + api.get( + `/api/v1/repos/${owner}/${repo}/environments/${envName}/secrets`, + secretsSchema, + ), + enabled: Boolean(owner && repo && envName), + }) +} + +export function useUpsertEnvSecret(owner: string, repo: string, envName: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { name: string; value: string }) => + api.post( + `/api/v1/repos/${owner}/${repo}/environments/${envName}/secrets`, + secretSchema, + body, + ), + onSuccess: () => + qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments', envName, 'secrets'] }), + }) +} + +export function useDeleteEnvSecret(owner: string, repo: string, envName: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (name: string) => + api.delete( + `/api/v1/repos/${owner}/${repo}/environments/${envName}/secrets/${encodeURIComponent(name)}`, + z.unknown() as z.ZodType, + ), + onSuccess: () => + qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments', envName, 'secrets'] }), + }) +} + +// ── Workspace secrets ───────────────────────────────────────────────────────── + +export function useWorkspaceSecrets(handle: string) { + return useQuery({ + queryKey: ['workspaces', handle, 'secrets'], + queryFn: () => + api.get(`/api/v1/workspaces/${handle}/secrets`, secretsSchema), + enabled: Boolean(handle), + }) +} + +export function useUpsertWorkspaceSecret(handle: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { name: string; value: string }) => + api.post(`/api/v1/workspaces/${handle}/secrets`, secretSchema, body), + onSuccess: () => qc.invalidateQueries({ queryKey: ['workspaces', handle, 'secrets'] }), + }) +} + +export function useDeleteWorkspaceSecret(handle: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (name: string) => + api.delete( + `/api/v1/workspaces/${handle}/secrets/${encodeURIComponent(name)}`, + z.unknown() as z.ZodType, + ), + onSuccess: () => qc.invalidateQueries({ queryKey: ['workspaces', handle, 'secrets'] }), + }) +} diff --git a/frontend/src/api/queries/workspaces.ts b/frontend/src/api/queries/workspaces.ts new file mode 100644 index 0000000..2371750 --- /dev/null +++ b/frontend/src/api/queries/workspaces.ts @@ -0,0 +1,157 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { z } from 'zod' +import { api } from '../client' +import type { WorkspaceWithMeta, WorkspaceMember } from '../../types/api' + +// ── Schemas ─────────────────────────────────────────────────────────────────── + +const wsSchema = z.object({ + id: z.number(), + handle: z.string(), + displayName: z.string(), + description: z.string(), + avatarUrl: z.string(), + createdBy: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + memberCount: z.number(), + repoCount: z.number(), + myRole: z.string(), +}) + +const memberSchema = z.object({ + id: z.number(), + workspaceId: z.number(), + userId: z.number(), + username: z.string(), + role: z.enum(['owner', 'admin', 'member']), + addedAt: z.string(), +}) + +// WorkspaceRepo — raw repo as returned by the workspace list endpoint +export interface WorkspaceRepo { + id: number + ownerId: number + workspaceId?: number | null + ownerName: string + name: string + description: string + isPrivate: boolean + defaultBranch: string + createdAt: string + updatedAt: string +} + +const repoSchema = z.object({ + id: z.number(), + ownerId: z.number(), + workspaceId: z.number().nullable().optional(), + ownerName: z.string(), + name: z.string(), + description: z.string(), + isPrivate: z.boolean(), + defaultBranch: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +// ── Queries ─────────────────────────────────────────────────────────────────── + +export function useWorkspaces() { + return useQuery({ + queryKey: ['workspaces'], + queryFn: () => api.get('/api/v1/workspaces', z.array(wsSchema)), + }) +} + +export function useWorkspace(handle: string) { + return useQuery({ + queryKey: ['workspaces', handle], + queryFn: () => api.get(`/api/v1/workspaces/${handle}`, wsSchema), + enabled: Boolean(handle), + }) +} + +export function useWorkspaceMembers(handle: string) { + return useQuery({ + queryKey: ['workspaces', handle, 'members'], + queryFn: () => + api.get(`/api/v1/workspaces/${handle}/members`, z.array(memberSchema)), + enabled: Boolean(handle), + }) +} + +export function useWorkspaceRepos(handle: string) { + return useQuery({ + queryKey: ['workspaces', handle, 'repos'], + queryFn: () => + api.get(`/api/v1/workspaces/${handle}/repos`, z.array(repoSchema)), + enabled: Boolean(handle), + }) +} + +// ── Mutations ───────────────────────────────────────────────────────────────── + +export function useCreateWorkspace() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { handle: string; displayName?: string; description?: string }) => + api.post('/api/v1/workspaces', wsSchema, body), + onSuccess: () => qc.invalidateQueries({ queryKey: ['workspaces'] }), + }) +} + +export function useUpdateWorkspace(handle: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { displayName?: string; description?: string }) => + api.patch(`/api/v1/workspaces/${handle}`, wsSchema, body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workspaces'] }) + qc.invalidateQueries({ queryKey: ['workspaces', handle] }) + }, + }) +} + +export function useDeleteWorkspace(handle: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => + api.delete(`/api/v1/workspaces/${handle}`, z.unknown() as z.ZodType), + onSuccess: () => qc.invalidateQueries({ queryKey: ['workspaces'] }), + }) +} + +export function useAddWorkspaceMember(handle: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { username: string; role: string }) => + api.post(`/api/v1/workspaces/${handle}/members`, memberSchema, body), + onSuccess: () => qc.invalidateQueries({ queryKey: ['workspaces', handle, 'members'] }), + }) +} + +export function useUpdateWorkspaceMember(handle: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ username, role }: { username: string; role: string }) => + api.patch( + `/api/v1/workspaces/${handle}/members/${username}`, + memberSchema, + { role }, + ), + onSuccess: () => qc.invalidateQueries({ queryKey: ['workspaces', handle, 'members'] }), + }) +} + +export function useRemoveWorkspaceMember(handle: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (username: string) => + api.delete( + `/api/v1/workspaces/${handle}/members/${username}`, + z.unknown() as z.ZodType, + ), + onSuccess: () => qc.invalidateQueries({ queryKey: ['workspaces', handle, 'members'] }), + }) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 35397fc..ba5ed31 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -5,6 +5,7 @@ import { useAuth } from '../../contexts/AuthContext' import { useRecentRepos } from '../../hooks/useRecentRepos' import { useStarredRepos } from '../../hooks/useStarredRepos' import { useRepos } from '../../api/queries/repos' +import { useWorkspaces } from '../../api/queries/workspaces' import { RepoAvatar } from '../../ui/RepoAvatar' interface SidebarProps { @@ -16,6 +17,7 @@ export function Sidebar({ className }: SidebarProps) { const { repos: recentRepos, remove } = useRecentRepos() const { toggle, isStarred } = useStarredRepos() const { data: apiRepos } = useRepos() + const { data: workspaces } = useWorkspaces() const [openRecent, setOpenRecent] = useState(true) // Drop localStorage entries for repos that no longer exist in the API. @@ -53,6 +55,25 @@ export function Sidebar({ className }: SidebarProps) { } label="Repositories" /> } label="Explore" /> } label="Starred" /> + } label="Workspaces" /> + {/* Workspace quick-links */} + {workspaces && workspaces.length > 0 && workspaces.map(ws => ( + cn( + 'flex items-center gap-2.5 px-3 py-1.5 mx-1 rounded text-xs transition-colors min-h-[32px]', + isActive + ? 'bg-[var(--c-surface)]/12 text-white font-medium' + : 'text-white/55 hover:bg-white/8 hover:text-white', + )} + > +
+ {(ws.displayName || ws.handle)[0].toUpperCase()} +
+ {ws.displayName || ws.handle} +
+ ))} {/* ── Recent repos ───────────────────────────────────────────── */} {recentRepos.length > 0 && ( @@ -164,6 +185,7 @@ function RepoSubNav({ owner, repo }: { owner: string; repo: string }) { { label: 'Pipelines', to: `${base}/pipelines`, icon: }, { label: 'Environments', to: `${base}/environments`, icon: }, { label: 'Timeline', to: `${base}/timeline`, icon: }, + { label: 'Secrets', to: `${base}/secrets`, icon: }, { label: 'Settings', to: `${base}/settings`, icon: }, ] return ( @@ -207,4 +229,6 @@ const IssueIcon = () => const EnvIcon = () => const TimelineIcon = () => +const SecretsIcon = () => +const WorkspaceIcon = () => const SettingsSmIcon = () => diff --git a/frontend/src/pages/CreateRepoPage.tsx b/frontend/src/pages/CreateRepoPage.tsx index 725a9ab..3d281e3 100644 --- a/frontend/src/pages/CreateRepoPage.tsx +++ b/frontend/src/pages/CreateRepoPage.tsx @@ -1,12 +1,16 @@ import { useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { useCreateRepo } from '../api/queries/repos' +import { useWorkspaces } from '../api/queries/workspaces' export default function CreateRepoPage() { const navigate = useNavigate() + const [searchParams] = useSearchParams() const createRepo = useCreateRepo() + const { data: workspaces } = useWorkspaces() const [name, setName] = useState('') + const [workspace, setWorkspace] = useState(searchParams.get('workspace') ?? '') const [isPrivate, setIsPrivate] = useState(true) const [initReadme, setInitReadme] = useState<'none' | 'blank' | 'tutorial'>('none') const [defaultBranch, setDefaultBranch] = useState('') @@ -24,6 +28,7 @@ export default function CreateRepoPage() { defaultBranch: defaultBranch.trim() || 'main', initReadme, initGitignore, + workspace: workspace || undefined, }) navigate(`/repos/${repo.ownerName}/${repo.name}`) } @@ -41,6 +46,24 @@ export default function CreateRepoPage() {
+ {/* Owner */} + {workspaces && workspaces.length > 0 && ( + + + + )} + {/* Repository name */} void + isLoading: boolean +}) { + if (isLoading) { + return ( +
+ {[1, 2].map(i => )} +
+ ) + } + if (!secrets?.length) { + return

No secrets defined.

+ } + return ( +
+ {secrets.map(s => ( +
+ {s.name} + •••••••• + +
+ ))} +
+ ) +} + +function AddSecretForm({ + onSubmit, + isPending, + error, +}: { + onSubmit: (name: string, value: string) => void + isPending: boolean + error?: string +}) { + const [name, setName] = useState('') + const [value, setValue] = useState('') + + function submit(e: React.FormEvent) { + e.preventDefault() + if (!name.trim() || !value.trim()) return + onSubmit(name.trim(), value.trim()) + setName('') + setValue('') + } + + return ( + + setName(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '_'))} + placeholder="SECRET_NAME" + className="w-44 px-3 py-2 text-sm border border-[var(--c-border)] rounded-lg bg-[var(--c-surface-muted)] text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)] font-mono" + /> + setValue(e.target.value)} + placeholder="Secret value" + className="flex-1 px-3 py-2 text-sm border border-[var(--c-border)] rounded-lg bg-[var(--c-surface-muted)] text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)]" + /> + + {error &&

{error}

} + + ) +} + +// ── Env secrets sub-section ─────────────────────────────────────────────────── + +function EnvSecretsSection({ owner, repo, envName }: { owner: string; repo: string; envName: string }) { + const { data: secrets, isLoading } = useEnvSecrets(owner, repo, envName) + const upsert = useUpsertEnvSecret(owner, repo, envName) + const del = useDeleteEnvSecret(owner, repo, envName) + + return ( +
+
+ + + + {envName} + env-level override +
+
+ del.mutate(name)} isLoading={isLoading} /> + upsert.mutate({ name, value })} + isPending={upsert.isPending} + error={(upsert.error as Error)?.message} + /> +
+
+ ) +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function RepoSecretsPage() { + const { owner = '', repo = '' } = useParams() + const { data: repoSecrets, isLoading: loadingRepo } = useRepoSecrets(owner, repo) + const upsertRepo = useUpsertRepoSecret(owner, repo) + const deleteRepo = useDeleteRepoSecret(owner, repo) + const { data: environments } = useEnvironments(owner, repo) + + return ( +
+
+

Secrets

+

+ Secrets are injected as environment variables into CI pipeline jobs. Values are write-only. +

+
+ Priority order:{' '} + Environment secrets → Repository secrets → Workspace secrets → Global secrets. + Higher-priority secrets with the same name override lower ones. +
+
+ + {/* Repository-level secrets */} +
+

+ Repository secrets +

+
+
+ deleteRepo.mutate(name)} + isLoading={loadingRepo} + /> + upsertRepo.mutate({ name, value })} + isPending={upsertRepo.isPending} + error={(upsertRepo.error as Error)?.message} + /> +
+
+
+ + {/* Environment-level secrets */} + {environments && environments.length > 0 && ( +
+

+ Environment secrets +

+
+ {environments.map(env => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/WorkspacePage.tsx b/frontend/src/pages/WorkspacePage.tsx new file mode 100644 index 0000000..0cc1a10 --- /dev/null +++ b/frontend/src/pages/WorkspacePage.tsx @@ -0,0 +1,126 @@ +import { useParams, Link } from 'react-router-dom' +import { useWorkspace, useWorkspaceRepos, type WorkspaceRepo } from '../api/queries/workspaces' +import { Skeleton } from '../ui/Skeleton' + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const min = Math.floor(diff / 60_000) + if (min < 60) return `${Math.max(1, min)}m ago` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}h ago` + return `${Math.floor(hr / 24)}d ago` +} + +export default function WorkspacePage() { + const { handle = '' } = useParams() + const { data: ws, isLoading } = useWorkspace(handle) + const { data: repos } = useWorkspaceRepos(handle) + + if (isLoading) { + return ( +
+ + +
+ {[1, 2, 3].map(i => )} +
+
+ ) + } + + if (!ws) { + return ( +
+

Workspace not found.

+ ← All workspaces +
+ ) + } + + const canAdmin = ws.myRole === 'owner' || ws.myRole === 'admin' + + return ( +
+ {/* Header */} +
+
+
+ {ws.displayName?.[0]?.toUpperCase() || ws.handle[0].toUpperCase()} +
+
+

{ws.displayName || ws.handle}

+
+ @{ws.handle} + · + {ws.memberCount} member{ws.memberCount !== 1 ? 's' : ''} + · + {ws.repoCount} repo{ws.repoCount !== 1 ? 's' : ''} +
+ {ws.description &&

{ws.description}

} +
+
+ {canAdmin && ( + + + + + Settings + + )} +
+ + {/* Repositories */} +
+
+

Repositories

+ {canAdmin && ( + + + New repo + + )} +
+
+ {!repos?.length ? ( +
+ No repositories yet.{' '} + {canAdmin && ( + + Create the first one + + )} +
+ ) : ( + (repos as WorkspaceRepo[]).map(repo => ( + +
+
+ + {repo.name} + + {repo.isPrivate && ( + private + )} +
+ {repo.description && ( +

{repo.description}

+ )} +
+ {timeAgo(repo.updatedAt)} + + )) + )} +
+
+
+ ) +} diff --git a/frontend/src/pages/WorkspaceSettingsPage.tsx b/frontend/src/pages/WorkspaceSettingsPage.tsx new file mode 100644 index 0000000..7818382 --- /dev/null +++ b/frontend/src/pages/WorkspaceSettingsPage.tsx @@ -0,0 +1,312 @@ +import { useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { + useWorkspace, useWorkspaceMembers, useUpdateWorkspace, + useDeleteWorkspace, useAddWorkspaceMember, useUpdateWorkspaceMember, + useRemoveWorkspaceMember, +} from '../api/queries/workspaces' +import { + useWorkspaceSecrets, useUpsertWorkspaceSecret, useDeleteWorkspaceSecret, +} from '../api/queries/secrets' +import { Skeleton } from '../ui/Skeleton' +import type { WorkspaceMember } from '../types/api' + +// ── Shared section wrapper ─────────────────────────────────────────────────── + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+
+
{children}
+
+ ) +} + +// ── General settings ───────────────────────────────────────────────────────── + +function GeneralSection({ handle }: { handle: string }) { + const { data: ws } = useWorkspace(handle) + const update = useUpdateWorkspace(handle) + const [displayName, setDisplayName] = useState(ws?.displayName ?? '') + const [description, setDescription] = useState(ws?.description ?? '') + + function submit(e: React.FormEvent) { + e.preventDefault() + update.mutate({ displayName, description }) + } + + return ( +
+
+
+ + setDisplayName(e.target.value)} + placeholder={handle} + className="w-full px-3 py-2 text-sm border border-[var(--c-border)] rounded-lg bg-[var(--c-surface-muted)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" + /> +
+
+ +