diff --git a/.env.example b/.env.example index 7a1262b..31e58cc 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,8 @@ MULTICODEX_AUTH_MODE=local MULTICODEX_AUTH_SESSION_TTL=12h MULTICODEX_AUTH_COOKIE_SECURE=false MULTICODEX_AUTH_LOGIN_STATE_TTL=10m +MULTICODEX_LOCAL_ADMIN_EMAIL=local-dev@multi-codex.invalid +MULTICODEX_LOCAL_ADMIN_PASSWORD=admin123 MULTICODEX_OIDC_ISSUER= MULTICODEX_OIDC_AUDIENCE= MULTICODEX_OIDC_JWKS_URL= diff --git a/apps/web/src/components/StatusBadge.tsx b/apps/web/src/components/StatusBadge.tsx index 5c4f761..23f2fd6 100644 --- a/apps/web/src/components/StatusBadge.tsx +++ b/apps/web/src/components/StatusBadge.tsx @@ -7,5 +7,14 @@ type StatusBadgeProps = { export function StatusBadge({ status }: StatusBadgeProps) { const { t } = useI18n(); const normalized = status.toLowerCase().replaceAll("_", "-"); - return {t(`status.${normalized}`)}; + const key = `status.${normalized}`; + const label = t(key); + return {label === key ? labelize(status) : label}; +} + +function labelize(value: string) { + return value + .replaceAll("_", " ") + .replaceAll("-", " ") + .replace(/\b\w/g, (match) => match.toUpperCase()); } diff --git a/apps/web/src/features/tasks/TaskBoard.tsx b/apps/web/src/features/tasks/TaskBoard.tsx index fa40681..9b5194d 100644 --- a/apps/web/src/features/tasks/TaskBoard.tsx +++ b/apps/web/src/features/tasks/TaskBoard.tsx @@ -12,6 +12,7 @@ import { createProject, createRepository, createSkill, + createUser, createTask, decideApproval, dispatchQueue, @@ -28,6 +29,7 @@ import { listAuditLogs, listExecutorNodes, listOrganizations, + listProjectMembers, listProjects, listRepositories, listRunEvents, @@ -36,6 +38,8 @@ import { listSkills, listTasks, listToolCalls, + listUsers, + loginWithPassword, logout, parseRunEventPayload, registerExecutorNode, @@ -45,6 +49,7 @@ import { scopeCheck, startTask, setAuthToken, + upsertProjectMember, verifyExecutorNodeHostKey } from "../../lib/api"; import type { @@ -55,19 +60,36 @@ import type { AuthContext, Organization, Project, + ProjectMembership, QueueSnapshot, Repository, Run, RunEvent, SkillVersion, Task, - ToolCall + ToolCall, + UserDirectory } from "../../lib/api"; import { LanguageToggle, useI18n } from "../../lib/i18n"; -import { AccessProvider, hasPermission, useAccess, visiblePermissions } from "../../lib/permissions"; +import { AccessProvider, hasPermission, projectRole, useAccess, visiblePermissions } from "../../lib/permissions"; import type { Permission } from "../../lib/permissions"; -type View = "dashboard" | "tasks" | "runs" | "queue" | "approvals" | "nodes" | "organizations" | "skills" | "audit"; +type View = "dashboard" | "tasks" | "runs" | "queue" | "approvals" | "nodes" | "organizations" | "skills" | "admin" | "audit"; +type ListLimit = 10 | 20 | 50; +type ListPager = { + hasNext: boolean; + hasPrevious: boolean; + limit: ListLimit; + page: number; + pageCount: number; + setLimit: (value: ListLimit) => void; + goNext: () => void; + goPrevious: () => void; + total: number; +}; +type ListPagination = ListPager & { items: T[] }; + +const listLimitOptions = [10, 20, 50] as const; const navItems: Array<{ id: View; labelKey: string; permission: Permission }> = [ { id: "dashboard", labelKey: "nav.dashboard", permission: "projects:read" }, @@ -78,30 +100,135 @@ const navItems: Array<{ id: View; labelKey: string; permission: Permission }> = { id: "nodes", labelKey: "nav.nodes", permission: "nodes:read" }, { id: "organizations", labelKey: "nav.organizations", permission: "organizations:read" }, { id: "skills", labelKey: "nav.skills", permission: "projects:read" }, + { id: "admin", labelKey: "nav.admin", permission: "users:read" }, { id: "audit", labelKey: "nav.audit", permission: "audit:read" } ]; +function ListLimitControl({ + label, + onChange, + value +}: { + label?: string; + onChange: (value: ListLimit) => void; + value: ListLimit; +}) { + const { t } = useI18n(); + return ( +
+ {t("common.show")} + {listLimitOptions.map((option) => ( + + ))} +
+ ); +} + +function ListPaginationControl({ label, pagination }: { label?: string; pagination: ListPager }) { + const { t } = useI18n(); + return ( +
+ +
+ + {t("common.pageStatus", { page: pagination.page, pages: pagination.pageCount })} + +
+
+ ); +} + +function useListPagination(items: T[], options: { fromEnd?: boolean } = {}): ListPagination { + const [limit, setLimitState] = useState(10); + const [page, setPage] = useState(1); + const total = items.length; + const pageCount = Math.max(1, Math.ceil(total / limit)); + const normalizedPage = Math.min(page, pageCount); + + useEffect(() => { + setPage((current) => Math.min(Math.max(current, 1), pageCount)); + }, [pageCount]); + + const visibleItems = useMemo(() => { + if (!total) { + return []; + } + if (options.fromEnd) { + const end = Math.max(total - (normalizedPage - 1) * limit, 0); + const start = Math.max(end - limit, 0); + return items.slice(start, end); + } + const start = (normalizedPage - 1) * limit; + return items.slice(start, start + limit); + }, [items, limit, normalizedPage, options.fromEnd, total]); + + return { + hasNext: normalizedPage < pageCount, + hasPrevious: normalizedPage > 1, + items: visibleItems, + limit, + page: normalizedPage, + pageCount, + setLimit: (nextLimit: ListLimit) => { + setLimitState(nextLimit); + setPage(1); + }, + goNext: () => setPage((current) => Math.min(current + 1, pageCount)), + goPrevious: () => setPage((current) => Math.max(current - 1, 1)), + total + }; +} + export function TaskBoard() { const { t } = useI18n(); const [view, setView] = useHashView(); + const [path, setPath] = useState(() => window.location.pathname); const [selectedProjectId, setSelectedProjectId] = useState(); const [selectedRepositoryId, setSelectedRepositoryId] = useState(); const [selectedTaskId, setSelectedTaskId] = useState(); const [tokenDraft, setTokenDraft] = useState(getAuthToken); + const [loginEmail, setLoginEmail] = useState("local-dev@multi-codex.invalid"); + const [loginPassword, setLoginPassword] = useState("admin123"); const capabilities = useQuery({ queryKey: ["auth-capabilities"], queryFn: getAuthCapabilities, staleTime: 60_000 }); - const auth = useQuery({ queryKey: ["auth"], queryFn: getAuthContext }); + const auth = useQuery({ queryKey: ["auth"], queryFn: getAuthContext, retry: false }); const isAuthenticated = Boolean(auth.data); + const isLoginRoute = path === "/login"; const canProjectsRead = isAuthenticated && hasPermission(auth.data, "projects:read"); const canOrganizationsRead = isAuthenticated && hasPermission(auth.data, "organizations:read"); const canRunsRead = isAuthenticated && hasPermission(auth.data, "runs:read"); - const canNodesRead = isAuthenticated && hasPermission(auth.data, "nodes:read"); + const canUsersRead = isAuthenticated && hasPermission(auth.data, "users:read"); const canAuditRead = isAuthenticated && hasPermission(auth.data, "audit:read"); + const navigate = (nextPath: string) => { + if (window.location.pathname !== nextPath) { + window.history.pushState(null, "", nextPath); + } + setPath(window.location.pathname); + }; const logoutMutation = useMutation({ mutationFn: logout, onSettled: () => { clearAuthToken(); setTokenDraft(""); queryClient.invalidateQueries(); + navigate("/login"); } }); const connectMutation = useMutation({ @@ -111,10 +238,22 @@ export function TaskBoard() { setTokenDraft(""); queryClient.setQueryData(["auth"], nextAuth); queryClient.invalidateQueries(); + navigate("/"); + } + }); + const passwordLoginMutation = useMutation({ + mutationFn: loginWithPassword, + onSuccess: (nextAuth) => { + clearAuthToken(); + setTokenDraft(""); + queryClient.setQueryData(["auth"], nextAuth); + queryClient.invalidateQueries(); + navigate("/"); } }); const organizations = useQuery({ queryKey: ["organizations"], queryFn: listOrganizations, enabled: canOrganizationsRead }); const projects = useQuery({ queryKey: ["projects"], queryFn: listProjects, enabled: canProjectsRead }); + const users = useQuery({ queryKey: ["users"], queryFn: listUsers, enabled: canUsersRead }); const activeProject = useMemo(() => { if (!projects.data?.length) { return undefined; @@ -163,21 +302,75 @@ export function TaskBoard() { } }, [repositories.data, selectedRepositoryId]); + useEffect(() => { + const onPopState = () => setPath(window.location.pathname); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + useEffect(() => { + if (!auth.data && capabilities.data?.auth_mode !== "oidc" && capabilities.data?.local_admin_email) { + setLoginEmail((current) => (current === "local-dev@multi-codex.invalid" ? capabilities.data!.local_admin_email! : current)); + } + }, [auth.data, capabilities.data]); + + useEffect(() => { + if (auth.data && isLoginRoute) { + navigate("/"); + } + if (auth.isError && !isLoginRoute) { + navigate("/login"); + } + }, [auth.data, auth.isError, isLoginRoute]); + const activeNavItem = navItems.find((item) => item.id === view) ?? navItems[0]; const canViewActive = isAuthenticated && hasPermission(auth.data, activeNavItem.permission); + const activeProjectRole = projectRole(auth.data, activeProject?.id); + const saveToken = () => { + const trimmed = tokenDraft.trim(); + if (trimmed) { + connectMutation.mutate(trimmed); + } else { + setAuthToken(""); + queryClient.invalidateQueries(); + } + }; return ( + {isLoginRoute ? ( + passwordLoginMutation.mutate({ email: loginEmail, password: loginPassword })} + onOpenConsole={() => navigate("/")} + onSaveToken={saveToken} + tokenDraft={tokenDraft} + onTokenDraftChange={setTokenDraft} + /> + ) : (
+ )}
); } @@ -361,8 +539,13 @@ function AuthControls({ }) { const { t } = useI18n(); const permissions = visiblePermissions(auth); - const sessionLabel = auth ? `${auth.user.email} · ${auth.membership.role}` : authError ? t("auth.required") : t("auth.checking"); + const sessionLabel = auth + ? t("auth.sessionUserRole", { user: auth.user.email, role: roleLabel(auth.membership.role, t) }) + : authError + ? t("auth.required") + : t("auth.checking"); const authMode = capabilities ? (capabilities.auth_mode === "oidc" ? t("auth.oidc") : t("auth.local")) : t("auth.checking"); + const isOIDC = capabilities?.auth_mode === "oidc"; return (
@@ -382,20 +565,26 @@ function AuthControls({ {permissions.length > 5 ? +{permissions.length - 5} : null}
) : null} - onTokenDraftChange(event.target.value)} - /> + {isOIDC ? ( + onTokenDraftChange(event.target.value)} + /> + ) : null}
- - + {isOIDC ? ( + <> + + + + ) : null} @@ -404,19 +593,111 @@ function AuthControls({ ); } +function LoginPage({ + auth, + capabilities, + authError, + isConnecting, + isPasswordLoginPending, + loginEmail, + loginPassword, + onLoginEmailChange, + onLoginPasswordChange, + onPasswordLogin, + onOIDCLogin, + onOpenConsole, + onSaveToken, + tokenDraft, + onTokenDraftChange +}: { + auth?: AuthContext; + capabilities?: AuthCapabilities; + authError: Error | null; + isConnecting: boolean; + isPasswordLoginPending: boolean; + loginEmail: string; + loginPassword: string; + onLoginEmailChange: (value: string) => void; + onLoginPasswordChange: (value: string) => void; + onPasswordLogin: () => void; + onOIDCLogin: () => void; + onOpenConsole: () => void; + onSaveToken: () => void; + tokenDraft: string; + onTokenDraftChange: (value: string) => void; +}) { + const { t } = useI18n(); + return ( +
+
+
+ mcx +
+

multi-codex

+

{t("auth.enterpriseSubtitle")}

+
+
+
+

{t("auth.secureWorkspace")}

+

{t("auth.loginHeroTitle")}

+

{t("auth.loginHeroBody")}

+
+
+ {t("auth.assuranceEnvelope")} + {t("auth.assuranceRbac")} + {t("auth.assuranceAudit")} +
+
+ +
+ ); +} + function LoginPanel({ + auth, capabilities, authError, isConnecting, + isPasswordLoginPending, + loginEmail, + loginPassword, + onLoginEmailChange, + onLoginPasswordChange, + onPasswordLogin, onOIDCLogin, + onOpenConsole, onSaveToken, tokenDraft, onTokenDraftChange }: { + auth?: AuthContext; capabilities?: AuthCapabilities; authError: Error | null; isConnecting: boolean; + isPasswordLoginPending: boolean; + loginEmail: string; + loginPassword: string; + onLoginEmailChange: (value: string) => void; + onLoginPasswordChange: (value: string) => void; + onPasswordLogin: () => void; onOIDCLogin: () => void; + onOpenConsole?: () => void; onSaveToken: () => void; tokenDraft: string; onTokenDraftChange: (value: string) => void; @@ -442,28 +723,66 @@ function LoginPanel({
{helperText} {ttlHours ? {t("auth.sessionTtl", { hours: ttlHours })} : null} - {capabilities?.default_role ? {t("auth.defaultRole", { role: capabilities.default_role })} : null} + {capabilities?.default_role ? {t("auth.defaultRole", { role: roleLabel(capabilities.default_role, t) })} : null} {authError ? {authError.message} : null}
- -
- - -
+ ) : !isOIDC ? ( +
submit(event, onPasswordLogin)}> + + + +
+ ) : ( + + )} + {isOIDC ? ( +
+ {t("auth.advancedLogin")} +
+ + +
+
+ ) : null}
); @@ -621,6 +940,7 @@ function ActiveTasksPanel({ return ["done", "completed", "succeeded", "approved"].includes(task.status); }); }, [filter, tasks]); + const pagination = useListPagination(visibleTasks); return (
@@ -629,9 +949,12 @@ function ActiveTasksPanel({

{t("dashboard.workQueue")}

{t("dashboard.activeTasks")}

- {t("common.total", { count: tasks.length })} +
+ {t("common.total", { count: tasks.length })} + +
-
+
{(["all", "queued", "running", "blocked", "done"] as TaskFilter[]).map((item) => (
-
+
{visibleTasks.length ? ( - visibleTasks.slice(0, 8).map((task) => ( + pagination.items.map((task) => ( onSelectTask(task.id)} /> )) ) : ( @@ -675,6 +998,7 @@ function QueueHealthCard({ onSelectView, queue, runs }: { onSelectView: (view: V const blocked = runs.filter((run) => ["blocked", "failed"].includes(run.status)).length; const completed = runs.filter((run) => ["completed", "succeeded"].includes(run.status)).length; const snapshots = Object.values(queue?.backpressure ?? {}); + const pagination = useListPagination(snapshots); return (
@@ -683,7 +1007,10 @@ function QueueHealthCard({ onSelectView, queue, runs }: { onSelectView: (view: V

{t("dashboard.capacity")}

{t("dashboard.queueHealth")}

- {new Date().toLocaleTimeString()} +
+ {new Date().toLocaleTimeString()} + +
@@ -691,9 +1018,9 @@ function QueueHealthCard({ onSelectView, queue, runs }: { onSelectView: (view: V
-
+
{snapshots.length ? ( - snapshots.map((snapshot) => ( + pagination.items.map((snapshot) => (
{labelize(snapshot.executor)} @@ -808,7 +1135,7 @@ function TaskLifecyclePanel({
- {role} + {roleLabel(role, t)} {executor} {t("common.recent", { count: runs.length })}
@@ -849,7 +1176,7 @@ function TaskLifecyclePanel({ onClick={() => workflowMutation.mutate(action)} type="button" > - {labelize(action)} + {workflowActionLabel(action, t)} ))}
@@ -903,7 +1230,7 @@ function TaskLifecyclePanel({ rows={[ [t("tasks.executor"), latestRun?.executor ?? executor], [t("lifecycle.node"), latestRun?.executor_node_id ?? t("common.notAssigned")], - [t("lifecycle.worktree"), latestRun?.worktree_path ?? "ephemeral"] + [t("lifecycle.worktree"), latestRun?.worktree_path ?? t("common.ephemeral")] ]} /> @@ -965,6 +1292,8 @@ function EvidenceColumn({ toolCalls: ToolCall[]; }) { const { t } = useI18n(); + const toolPagination = useListPagination(toolCalls); + const auditPagination = useListPagination(auditLogs); return (
@@ -974,13 +1303,16 @@ function EvidenceColumn({

{t("dashboard.gateway")}

{t("dashboard.toolCalls")}

- +
+ + +
-
+
{toolCalls.length ? ( - toolCalls.slice(0, 6).map((call) => ( + toolPagination.items.map((call) => (
{call.tool_name} @@ -1002,11 +1334,14 @@ function EvidenceColumn({

{t("dashboard.evidence")}

{t("dashboard.auditTrail")}

- +
+ + +
- +
); @@ -1015,15 +1350,19 @@ function EvidenceColumn({ function LiveRunCard({ run }: { run?: Run }) { const { t } = useI18n(); const events = useRunEvents(run?.id, run?.status); + const eventPagination = useListPagination(events.events, { fromEnd: true }); return (

{t("dashboard.worker")}

-

{run ? `${run.role} ${t("nav.runs")}` : t("dashboard.liveRun")}

+

{run ? t("runs.runTitle", { role: roleLabel(run.role, t) }) : t("dashboard.liveRun")}

+
+
+ +
-
{run ? ( <> @@ -1034,7 +1373,7 @@ function LiveRunCard({ run }: { run?: Run }) {
{t("runs.started")}
-
{formatDate(run.started_at ?? run.created_at)}
+
{formatDate(run.started_at ?? run.created_at, t)}
{t("lifecycle.node")}
@@ -1045,7 +1384,7 @@ function LiveRunCard({ run }: { run?: Run }) {
{run.branch ?? t("status.pending")}
- + ) : (
{t("runs.noRuns")}
@@ -1062,6 +1401,8 @@ function ArtifactSummary({ run }: { run?: Run }) { enabled: Boolean(run), refetchInterval: pollWhileHealthy(run?.status === "running" ? 2_000 : 5_000) }); + const artifactList = artifacts.data ?? []; + const pagination = useListPagination(artifactList); return (
@@ -1069,18 +1410,21 @@ function ArtifactSummary({ run }: { run?: Run }) {

{t("dashboard.outputs")}

{t("dashboard.artifacts")}

-
- {t("common.files", { count: artifacts.data?.length ?? 0 })} +
+
+ {t("common.files", { count: artifactList.length })} + +
-
- {artifacts.data?.length ? ( - artifacts.data.slice(0, 5).map((artifact) => ( +
+ {artifactList.length ? ( + pagination.items.map((artifact) => (
{artifact.name} {artifact.kind}
- {artifact.size_bytes ?? 0} bytes + {t("common.bytes", { count: artifact.size_bytes ?? 0 })} {artifact.sha256?.slice(0, 12) ?? t("common.noHash")}
)) @@ -1132,7 +1476,7 @@ function ProjectRepoPanel({ activeProjectId }: { activeProjectId?: string }) { setProjectName(event.target.value)} /> ))}
) : null} -

{t("tasks.runs")}

-
+
+

{t("tasks.runs")}

+ +
+
{!access.has("runs:read") ? : null} {runs.data?.length ? ( - runs.data.map((run) => ( + runPagination.items.map((run) => (
- {run.role} + {roleLabel(run.role, t)} {run.executor} {run.executor_node_id || t("common.notAssigned")} @@ -1400,8 +1753,11 @@ function TaskDetail({ task }: { task?: Task }) { )}
-

{t("tasks.latestRunEvents")}

- +
+

{t("tasks.latestRunEvents")}

+ +
+

{t("tasks.latestRunArtifacts")}

@@ -1418,6 +1774,7 @@ function RunsView() { const runs = useQuery({ queryKey: ["runs"], queryFn: listAllRuns, enabled: access.has("runs:read"), refetchInterval: pollWhileHealthy(2_000) }); const [selectedRunId, setSelectedRunId] = useState(); const runList = useMemo(() => runs.data ?? [], [runs.data]); + const pagination = useListPagination(runList); const selectedRun = runList.find((run) => run.id === selectedRunId) ?? runList[0]; useEffect(() => { @@ -1431,15 +1788,18 @@ function RunsView() { }, [runList, selectedRunId]); return ( -
+

{t("nav.runs")}

- {runs.isLoading ? t("common.loading") : t("common.recent", { count: runList.length })} +
+ {runs.isLoading ? t("common.loading") : t("common.recent", { count: runList.length })} + +
-
+
{runList.length ? ( - runList.map((run) => ( + pagination.items.map((run) => (
-
+
{queuedRuns.length ? ( - queuedRuns.map((run) => ( + pagination.items.map((run) => (
- {run.role} + {roleLabel(run.role, t)} {run.id}
{run.executor} {queueValue(run, "queued_reason", "queued")} - priority {queueValue(run, "queue_priority", "0")} · attempt {queueValue(run, "retry_attempt", "1")}/ - {queueValue(run, "max_attempts", "1")} + {t("queue.priorityAttempt", { + priority: queueValue(run, "queue_priority", "0"), + attempt: queueValue(run, "retry_attempt", "1"), + max: queueValue(run, "max_attempts", "1") + })}
)) @@ -1523,7 +1888,7 @@ function QueueView() {
{dispatchMutation.isError ? ( -
{dispatchMutation.error instanceof Error ? dispatchMutation.error.message : "Dispatch failed."}
+
{dispatchMutation.error instanceof Error ? dispatchMutation.error.message : t("queue.dispatchFailed")}
) : null} @@ -1534,6 +1899,7 @@ function QueueView() { function BackpressureSection({ title, snapshot }: { title: string; snapshot?: Backpressure }) { const { t } = useI18n(); + const pagination = useListPagination(snapshot?.nodes ?? []); return (
@@ -1542,12 +1908,13 @@ function BackpressureSection({ title, snapshot }: { title: string; snapshot?: Ba
{t("common.free", { count: snapshot.available_slots })} {t("common.retrySeconds", { count: snapshot.retry_after_seconds })} +
) : null}
{snapshot?.nodes.length ? ( -
- {snapshot.nodes.map((node) => ( +
+ {pagination.items.map((node) => (
{node.name} @@ -1588,6 +1955,7 @@ function queueValue(run: Run, key: string, fallback: string) { function RunDetail({ run }: { run?: Run }) { const { t } = useI18n(); const events = useRunEvents(run?.id, run?.status); + const eventPagination = useListPagination(events.events, { fromEnd: true }); if (!run) { return ( @@ -1600,7 +1968,7 @@ function RunDetail({ run }: { run?: Run }) { return (
-

{run.role} Run

+

{t("runs.runTitle", { role: roleLabel(run.role, t) })}

@@ -1626,8 +1994,11 @@ function RunDetail({ run }: { run?: Run }) {
-

{t("runs.events")}

- +
+

{t("runs.events")}

+ +
+

{t("runs.artifacts")}

@@ -1649,6 +2020,7 @@ function RunArtifactInspector({ runId, runStatus }: { runId?: string; runStatus? refetchInterval: pollWhileHealthy(runStatus === "running" ? 2_000 : 5_000) }); const artifactList = useMemo(() => artifacts.data ?? [], [artifacts.data]); + const pagination = useListPagination(artifactList); const selectedArtifact = artifactList.find((artifact) => artifact.id === selectedArtifactId); const artifactContent = useQuery({ queryKey: ["artifact-content", selectedArtifactId], @@ -1673,9 +2045,13 @@ function RunArtifactInspector({ runId, runStatus }: { runId?: string; runStatus? return (
-
+
+ {t("common.files", { count: artifactList.length })} + +
+
{artifactList.length ? ( - artifactList.map((artifact) => ( + pagination.items.map((artifact) => (
- {artifact.size_bytes ?? 0} bytes + {t("common.bytes", { count: artifact.size_bytes ?? 0 })} {artifact.sha256?.slice(0, 16) ?? t("common.noHash")} {artifact.path} @@ -1723,6 +2099,7 @@ function SkillsView({ projectId }: { projectId?: string }) { const skills = useQuery({ queryKey: ["skills"], queryFn: listSkills, enabled: access.has("projects:read") }); const skillList = useMemo(() => skills.data ?? [], [skills.data]); const [selectedSkillId, setSelectedSkillId] = useState(); + const skillPagination = useListPagination(skillList); const selectedSkill = skillList.find((skill) => skill.id === selectedSkillId) ?? skillList[0]; const versions = useQuery({ queryKey: ["skill-versions", selectedSkill?.id], @@ -1734,6 +2111,7 @@ function SkillsView({ projectId }: { projectId?: string }) { queryFn: () => listAgentProfiles(projectId!), enabled: Boolean(projectId) && access.has("projects:read") }); + const profilePagination = useListPagination(profiles.data ?? []); const [skillName, setSkillName] = useState("company-docs-worker"); const [skillRole, setSkillRole] = useState("docs"); const [profileName, setProfileName] = useState("docs-worker"); @@ -1782,11 +2160,14 @@ function SkillsView({ projectId }: { projectId?: string }) { }, [skillList, selectedSkillId]); return ( -
+

{t("skills.skills")}

- {t("common.registered", { count: skills.data?.length ?? 0 })} +
+ {t("common.registered", { count: skills.data?.length ?? 0 })} + +
{!access.has("skills:write") ? : null}
submit(event, () => skillMutation.mutate())}> @@ -1802,8 +2183,8 @@ function SkillsView({ projectId }: { projectId?: string }) { {t("skills.registerSkill")}
-
- {skillList.map((skill) => ( +
+ {skillPagination.items.map((skill) => ( ))}
@@ -1821,7 +2202,10 @@ function SkillsView({ projectId }: { projectId?: string }) {

{t("skills.agentProfiles")}

- {t("common.profiles", { count: profiles.data?.length ?? 0 })} +
+ {t("common.profiles", { count: profiles.data?.length ?? 0 })} + +
{!access.has("projects:write") ? : null}
submit(event, () => profileMutation.mutate())}> @@ -1849,8 +2233,8 @@ function SkillsView({ projectId }: { projectId?: string }) { {t("skills.createProfile")}
-
- {profiles.data?.map((profile) => ( +
+ {profilePagination.items.map((profile) => (
{profile.name} @@ -1859,7 +2243,7 @@ function SkillsView({ projectId }: { projectId?: string }) { {profile.role} {profile.network_enabled ? t("skills.networkOn") : t("skills.networkOff")} - {profileSecretEnvLabel(profile)} + {profileSecretEnvLabel(profile, t)}
))}
@@ -1870,19 +2254,26 @@ function SkillsView({ projectId }: { projectId?: string }) { function SkillVersionList({ versions }: { versions: SkillVersion[] }) { const { t } = useI18n(); + const pagination = useListPagination(versions); return ( -
- {versions.length ? ( - versions.map((version) => ( -
- {version.version} - {version.content_hash} - {version.path} -
- )) - ) : ( -
{t("skills.noVersions")}
- )} +
+
+ {t("common.total", { count: versions.length })} + +
+
+ {versions.length ? ( + pagination.items.map((version) => ( +
+ {version.version} + {version.content_hash} + {version.path} +
+ )) + ) : ( +
{t("skills.noVersions")}
+ )} +
); } @@ -1901,22 +2292,328 @@ function parseListInput(value: string): string[] { return values; } -function profileSecretEnvLabel(profile: AgentProfile): string { +function profileSecretEnvLabel(profile: AgentProfile, t: (key: string) => string): string { const value = profile.config["worker_secret_env"] ?? profile.config["secret_env"]; if (Array.isArray(value)) { const names = value.filter((item): item is string => typeof item === "string"); - return names.length ? names.join(", ") : "no secret refs"; + return names.length ? names.join(", ") : t("common.noSecretRefs"); } if (typeof value === "string" && value.trim()) { return value; } - return "no secret refs"; + return t("common.noSecretRefs"); +} + +function AdminView({ + activeProject, + onSelectProject, + projects, + users +}: { + activeProject?: Project; + onSelectProject: (projectId: string) => void; + projects: Project[]; + users?: UserDirectory; +}) { + const { t } = useI18n(); + const access = useAccess(); + const [email, setEmail] = useState("teammate@example.com"); + const [displayName, setDisplayName] = useState("Teammate"); + const [orgRole, setOrgRole] = useState("viewer"); + const [password, setPassword] = useState("ChangeMe123"); + const [selectedUserId, setSelectedUserId] = useState(""); + const [projectRoleDraft, setProjectRoleDraft] = useState("developer"); + const directoryUsers = users?.users ?? []; + const memberships = users?.memberships ?? []; + const projectMemberships = users?.project_memberships ?? []; + const activeProjectMembers = useQuery({ + queryKey: ["project-members", activeProject?.id], + queryFn: () => listProjectMembers(activeProject!.id), + enabled: Boolean(activeProject) && access.has("projects:read") + }); + const projectMembers = activeProjectMembers.data ?? []; + const selectedUser = directoryUsers.find((user) => user.id === selectedUserId) ?? directoryUsers[0]; + const selectedUserProjectMemberships = selectedUser ? projectMembershipsForUser(projectMemberships, selectedUser.id) : []; + const userPagination = useListPagination(directoryUsers); + const memberPagination = useListPagination(projectMembers); + const projectPagination = useListPagination(projects); + const accessPagination = useListPagination(selectedUserProjectMemberships); + + const createUserMutation = useMutation({ + mutationFn: () => createUser({ email, display_name: displayName, role: orgRole, password: password.trim() || undefined }), + onSuccess: (created) => { + setSelectedUserId(created.user.id); + setPassword(""); + void queryClient.invalidateQueries({ queryKey: ["users"] }); + void queryClient.invalidateQueries({ queryKey: ["auth"] }); + void queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); + } + }); + const projectMemberMutation = useMutation({ + mutationFn: () => upsertProjectMember(activeProject!.id, { user_id: selectedUser!.id, role: projectRoleDraft }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["project-members", activeProject?.id] }); + void queryClient.invalidateQueries({ queryKey: ["users"] }); + void queryClient.invalidateQueries({ queryKey: ["projects"] }); + void queryClient.invalidateQueries({ queryKey: ["auth"] }); + void queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); + } + }); + + useEffect(() => { + if (!selectedUserId && directoryUsers[0]) { + setSelectedUserId(directoryUsers[0].id); + } + }, [directoryUsers, selectedUserId]); + + return ( +
+
+
+
+
+

{t("admin.identity")}

+

{t("admin.users")}

+
+
+ {t("common.total", { count: directoryUsers.length })} + +
+
+ {!access.has("users:write") ? : null} +
submit(event, () => createUserMutation.mutate())}> + + + + + +
+
+ {directoryUsers.length ? ( + userPagination.items.map((user) => { + const role = orgRoleForUser(memberships, user.id); + const projectCount = projectMemberships.filter((membership) => membership.user_id === user.id).length; + return ( + + ); + }) + ) : ( +
{t("admin.noUsers")}
+ )} +
+
+ +
+
+
+

{t("admin.activeProject")}

+

{activeProject?.name ?? t("admin.noProject")}

+
+
+ {t("common.total", { count: projectMembers.length })} + +
+
+ {!access.has("projects:write") ? : null} +
submit(event, () => projectMemberMutation.mutate())}> + + + +
+
+ {projectMembers.length ? ( + memberPagination.items.map((membership) => ( +
+
+ {membership.user_name || membership.user_email || membership.user_id} + {membership.user_email || membership.user_id} +
+ + {membership.project_name || activeProject?.name || membership.project_id} + {new Date(membership.created_at).toLocaleDateString()} +
+ )) + ) : ( +
{activeProject ? t("admin.noMembers") : t("admin.noProjectMembers")}
+ )} +
+
+
+ + +
+ ); +} + +const orgRoles = ["owner", "admin", "tech_lead", "operator", "reviewer", "auditor", "viewer"]; +const projectRoles = ["owner", "project_admin", "maintainer", "developer", "reviewer", "auditor", "viewer"]; + +function orgRoleForUser(memberships: UserDirectory["memberships"], userId: string) { + return memberships.find((membership) => membership.user_id === userId)?.role ?? "viewer"; +} + +function projectMembershipsForUser(memberships: ProjectMembership[], userId: string) { + return memberships + .filter((membership) => membership.user_id === userId) + .sort((first, second) => (first.project_name || first.project_id).localeCompare(second.project_name || second.project_id)); +} + +function roleLabel(role: string, t?: (key: string) => string) { + const normalized = role.toLowerCase().replaceAll("_", "-"); + if (t) { + const translated = t(`status.${normalized}`); + if (translated !== `status.${normalized}`) { + return translated; + } + } + return labelize(role); } function ApprovalsView() { const { t } = useI18n(); const access = useAccess(); const approvals = useQuery({ queryKey: ["approvals"], queryFn: listApprovals, enabled: access.has("projects:read"), refetchInterval: pollWhileHealthy(3_000) }); + const approvalList = approvals.data ?? []; + const pagination = useListPagination(approvalList); const mutation = useMutation({ mutationFn: ({ approval, status }: { approval: Approval; status: "approved" | "rejected" }) => decideApproval(approval.id, status, status === "approved" ? "Approved in Web Console." : "Rejected in Web Console."), @@ -1930,11 +2627,14 @@ function ApprovalsView() {

{t("approvals.center")}

- {t("common.requests", { count: approvals.data?.length ?? 0 })} +
+ {t("common.requests", { count: approvalList.length })} + +
-
+
{approvals.data?.length ? ( - approvals.data.map((approval) => ( + pagination.items.map((approval) => (
{approval.approval_type} @@ -1964,6 +2664,8 @@ function NodesView() { const { t } = useI18n(); const access = useAccess(); const nodes = useQuery({ queryKey: ["executor-nodes"], queryFn: listExecutorNodes, enabled: access.has("nodes:read") }); + const nodeList = nodes.data ?? []; + const pagination = useListPagination(nodeList); const [name, setName] = useState("ssh-worker-1"); const [address, setAddress] = useState("codex-worker@example.invalid:22"); const [agentDURL, setAgentDURL] = useState("http://worker-agentd-dev:7070"); @@ -1991,7 +2693,10 @@ function NodesView() {

{t("nodes.executorNodes")}

- {t("common.available", { count: nodes.data?.length ?? 0 })} +
+ {t("common.available", { count: nodeList.length })} + +
{!access.has("nodes:write") ? : null}
submit(event, () => mutation.mutate())}> @@ -2019,8 +2724,8 @@ function NodesView() { {t("nodes.register")}
-
- {nodes.data?.map((node) => ( +
+ {pagination.items.map((node) => (
{node.name} @@ -2049,6 +2754,7 @@ function NodesView() { function OrganizationsView({ organizations }: { organizations: Organization[] }) { const { t } = useI18n(); const access = useAccess(); + const pagination = useListPagination(organizations); const [name, setName] = useState("Engineering"); const [slug, setSlug] = useState("engineering"); const mutation = useMutation({ @@ -2063,7 +2769,10 @@ function OrganizationsView({ organizations }: { organizations: Organization[] })

{t("organizations.organizations")}

- {t("common.provisioned", { count: organizations.length })} +
+ {t("common.provisioned", { count: organizations.length })} + +
{!access.has("organizations:write") ? : null}
submit(event, () => mutation.mutate())}> @@ -2079,9 +2788,9 @@ function OrganizationsView({ organizations }: { organizations: Organization[] }) {t("organizations.provision")}
-
+
{organizations.length ? ( - organizations.map((org) => ( + pagination.items.map((org) => (
{org.name} @@ -2105,23 +2814,33 @@ function AuditView() { const access = useAccess(); const auditLogs = useQuery({ queryKey: ["audit-logs"], queryFn: listAuditLogs, enabled: access.has("audit:read"), refetchInterval: pollWhileHealthy(5_000) }); const toolCalls = useQuery({ queryKey: ["tool-calls"], queryFn: listToolCalls, enabled: access.has("audit:read"), refetchInterval: pollWhileHealthy(5_000) }); + const auditList = auditLogs.data ?? []; + const toolCallList = toolCalls.data ?? []; + const auditPagination = useListPagination(auditList); + const toolPagination = useListPagination(toolCallList); return ( -
+

{t("audit.logs")}

- {t("common.recent", { count: auditLogs.data?.length ?? 0 })} +
+ {t("common.recent", { count: auditList.length })} + +
- +

{t("dashboard.toolCalls")}

- {t("common.recent", { count: toolCalls.data?.length ?? 0 })} +
+ {t("common.recent", { count: toolCallList.length })} + +
-
- {toolCalls.data?.map((call) => ( +
+ {toolPagination.items.map((call) => (
{call.tool_name} @@ -2139,17 +2858,17 @@ function AuditView() { } function CompactAuditList({ - entries, - limit = 8 + className, + entries }: { + className?: string; entries: Array<{ id: string; action: string; resource_type: string; resource_id: string; entry_hash?: string; prev_hash?: string }>; - limit?: number; }) { const { t } = useI18n(); return ( -
+
{entries.length ? ( - entries.slice(0, limit).map((entry) => ( + entries.map((entry) => (
{entry.action}
@@ -2219,7 +2938,13 @@ function mergeRunEvents(first: RunEvent[], second: RunEvent[]) { return Array.from(byID.values()).sort((a, b) => a.seq - b.seq); } -function EventList({ events, streamState }: { events: RunEvent[]; streamState?: EventStreamState }) { +function EventList({ + events, + streamState +}: { + events: RunEvent[]; + streamState?: EventStreamState; +}) { const { t } = useI18n(); const stateLabel = streamState === "live" ? t("runs.live") : streamState === "connecting" ? t("runs.connecting") : streamState === "fallback" ? t("runs.polling") : ""; return ( @@ -2247,13 +2972,19 @@ function labelize(value: string) { .replace(/\b\w/g, (match) => match.toUpperCase()); } +function workflowActionLabel(action: string, t: (key: string) => string) { + const key = `workflowAction.${action}`; + const translated = t(key); + return translated === key ? labelize(action) : translated; +} + function pollWhileHealthy(interval: number) { return (query: { state: { error: unknown } }) => (query.state.error ? false : interval); } -function formatDate(value?: string) { +function formatDate(value: string | undefined, t: (key: string) => string) { if (!value) { - return "not recorded"; + return t("common.notRecorded"); } return new Date(value).toLocaleString(); } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c4b4f37..2c5358e 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -6,6 +6,7 @@ const recordSchema = z.record(z.string(), z.unknown()); const projectSchema = z.object({ id: z.string(), + org_id: z.string().optional(), name: z.string(), slug: z.string(), description: z.string(), @@ -19,6 +20,33 @@ const organizationSchema = z.object({ created_at: z.string() }); +const userSchema = z.object({ + id: z.string(), + email: z.string(), + display_name: z.string(), + external_provider: z.string().optional(), + external_subject: z.string().optional(), + created_at: z.string() +}); + +const membershipSchema = z.object({ + org_id: z.string(), + user_id: z.string(), + role: z.string(), + created_at: z.string() +}); + +const projectMembershipSchema = z.object({ + project_id: z.string(), + user_id: z.string(), + role: z.string(), + project_name: z.string().optional(), + project_slug: z.string().optional(), + user_email: z.string().optional(), + user_name: z.string().optional(), + created_at: z.string() +}); + const repositorySchema = z.object({ id: z.string(), project_id: z.string(), @@ -229,30 +257,32 @@ const queueSnapshotSchema = z.object({ }); const authContextSchema = z.object({ - user: z.object({ - id: z.string(), - email: z.string(), - display_name: z.string(), - created_at: z.string() - }), - membership: z.object({ - org_id: z.string(), - user_id: z.string(), - role: z.string(), - created_at: z.string() - }), + user: userSchema, + membership: membershipSchema, + project_memberships: z.array(projectMembershipSchema).default([]), permissions: z.array(z.string()) }); +const userDirectorySchema = z.object({ + users: z.array(userSchema), + memberships: z.array(membershipSchema), + project_memberships: z.array(projectMembershipSchema) +}); + const authCapabilitiesSchema = z.object({ auth_mode: z.string(), oidc_configured: z.boolean(), session_ttl_seconds: z.number(), - default_role: z.string() + default_role: z.string(), + local_admin_email: z.string().optional() }); export type Project = z.infer; export type Organization = z.infer; +export type User = z.infer; +export type Membership = z.infer; +export type ProjectMembership = z.infer; +export type UserDirectory = z.infer; export type Repository = z.infer; export type Task = z.infer; export type Run = z.infer; @@ -324,15 +354,26 @@ export function beginOIDCLogin() { } export async function createBrowserSession(token: string) { + const trimmed = token.trim(); const response = await apiFetch("/api/v1/auth/session", { method: "POST", credentials: "include", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token.trim()}` }, + headers: { "Content-Type": "application/json", ...(trimmed ? { Authorization: `Bearer ${trimmed}` } : {}) }, body: "{}" }); return parseResponse(response, authContextSchema); } +export async function loginWithPassword(input: { email: string; password: string }) { + const response = await apiFetch("/api/v1/auth/session", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input) + }); + return parseResponse(response, authContextSchema); +} + export function getAuthToken() { if (typeof window === "undefined") { return ""; @@ -369,6 +410,14 @@ export function listOrganizations() { return getJSON("/api/v1/organizations", z.array(organizationSchema)); } +export function listUsers() { + return getJSON("/api/v1/users", userDirectorySchema); +} + +export function createUser(input: { email: string; display_name: string; role: string; password?: string; external_provider?: string; external_subject?: string }) { + return postJSON("/api/v1/users", input, authContextSchema); +} + export function createOrganization(input: { name: string; slug: string }) { return postJSON("/api/v1/organizations", input, organizationSchema); } @@ -381,6 +430,14 @@ export function listRepositories(projectId: string) { return getJSON(`/api/v1/projects/${projectId}/repositories`, z.array(repositorySchema)); } +export function listProjectMembers(projectId: string) { + return getJSON(`/api/v1/projects/${projectId}/members`, z.array(projectMembershipSchema)); +} + +export function upsertProjectMember(projectId: string, input: { user_id: string; role: string }) { + return postJSON(`/api/v1/projects/${projectId}/members`, input, projectMembershipSchema); +} + export function createRepository(projectId: string, input: { name: string; provider: string; remote_url: string; default_branch: string }) { return postJSON(`/api/v1/projects/${projectId}/repositories`, input, repositorySchema); } diff --git a/apps/web/src/lib/i18n.tsx b/apps/web/src/lib/i18n.tsx index 23af8aa..74b2d8d 100644 --- a/apps/web/src/lib/i18n.tsx +++ b/apps/web/src/lib/i18n.tsx @@ -17,11 +17,14 @@ const en: Dictionary = { "nav.nodes": "Nodes", "nav.organizations": "Organizations", "nav.skills": "Skills", + "nav.admin": "Admin", "nav.audit": "Audit", + "nav.primary": "Primary navigation", "language.label": "Language", "language.en": "EN", "language.zh": "中文", "auth.session": "Session", + "auth.sessionUserRole": "{user} · {role}", "auth.required": "Authentication required", "auth.checking": "Checking", "auth.mode": "Auth mode", @@ -39,20 +42,37 @@ const en: Dictionary = { "auth.loginBody": "multi-codex uses the API session and role permissions before loading protected project data.", "auth.loginOidcReady": "OIDC login is configured for this environment.", "auth.loginOidcMissing": "OIDC mode is enabled, but login client settings are incomplete. Use a valid bearer token or update server config.", - "auth.loginLocal": "Local mode is active. Refresh the session to use the seeded owner account.", + "auth.loginLocal": "Local account login is active. The seeded admin can sign in with email and password.", "auth.apiUnavailable": "API capabilities are unavailable. Check the API service or dev proxy.", "auth.tokenHelp": "Paste an OIDC bearer token to exchange it for a browser session.", "auth.sessionTtl": "Session TTL {hours}h", "auth.defaultRole": "Default role {role}", + "auth.enterpriseSubtitle": "Enterprise orchestration console", + "auth.secureWorkspace": "Secure workspace", + "auth.loginHeroTitle": "Coordinate Codex work with clear account, project, and audit boundaries.", + "auth.loginHeroBody": "Sign in before the console loads repositories, workers, approvals, or MCP activity.", + "auth.assuranceEnvelope": "Task Envelope", + "auth.assuranceRbac": "RBAC", + "auth.assuranceAudit": "Audit trail", + "auth.openConsole": "Open console", + "auth.email": "Email", + "auth.emailPlaceholder": "name@company.com", + "auth.password": "Password", + "auth.passwordPlaceholder": "Enter password", + "auth.advancedLogin": "Advanced: Bearer / OIDC session exchange", "topbar.project": "Project", "topbar.repository": "Repository", "topbar.branch": "Branch", + "topbar.projectRole": "Project role", "topbar.refresh": "Refresh", + "summary.workspace": "Workspace summary", + "summary.projects": "Projects", "summary.repositories": "Repositories", "summary.tasks": "Tasks", "summary.runs": "Runs", "summary.queued": "Queued", "summary.approvals": "Approvals", + "summary.users": "Users", "access.lockedTitle": "Permission needed", "access.lockedBody": "Your current role cannot access this workspace area.", "access.missing": "Required: {permission}", @@ -81,6 +101,23 @@ const en: Dictionary = { "common.noRun": "no run", "common.noEndpoint": "no endpoint", "common.noSecretRefs": "no secret refs", + "common.noPath": "no path", + "common.slug": "Slug", + "common.remoteUrl": "Remote URL", + "common.bytes": "{count} bytes", + "common.yes": "yes", + "common.no": "no", + "common.ephemeral": "ephemeral", + "common.local": "local", + "common.show": "Show", + "common.displayCount": "Display count", + "common.rows": "{count} rows", + "common.pagination": "Pagination", + "common.prev": "Prev", + "common.next": "Next", + "common.previousPage": "Previous page", + "common.nextPage": "Next page", + "common.pageStatus": "{page}/{pages}", "dashboard.workQueue": "Work Queue", "dashboard.activeTasks": "Active Tasks", "dashboard.capacity": "Capacity", @@ -106,6 +143,7 @@ const en: Dictionary = { "tasks.connectFirst": "Connect a project and repository first.", "tasks.lifecycleHelp": "Once a task exists, this panel tracks envelope validation, scope checks, executor isolation, audit evidence, and approvals.", "tasks.openTasks": "Open Tasks", + "tasks.filters": "Task filters", "tasks.noFilterMatch": "No tasks match this filter.", "tasks.noTasks": "No tasks yet.", "tasks.selectTask": "Select or create a task to inspect runs, logs, scope checks, and audit state.", @@ -135,6 +173,8 @@ const en: Dictionary = { "tasks.latestRunEvents": "Latest Run Events", "tasks.latestRunArtifacts": "Latest Run Artifacts", "tasks.envelope": "Task Envelope", + "tasks.defaultTitle": "Implement governed lifecycle check", + "tasks.defaultObjective": "Run a scoped feature worker and collect verifiable artifacts.", "lifecycle.taskEnvelope": "Task Envelope", "lifecycle.policyValidation": "Policy Validation", "lifecycle.executorIsolation": "Executor Isolation", @@ -169,10 +209,13 @@ const en: Dictionary = { "runs.live": "Live", "runs.connecting": "Connecting", "runs.noEvents": "No events yet.", + "runs.runTitle": "{role} Run", "queue.workerQueue": "Worker Queue", "queue.dispatch": "Dispatch", "queue.open": "Open Queue", "queue.backpressure": "Backpressure", + "queue.priorityAttempt": "priority {priority} · attempt {attempt}/{max}", + "queue.dispatchFailed": "Dispatch failed.", "queue.noCapacity": "Executor capacity appears here after nodes report backpressure.", "queue.noQueued": "No worker runs are waiting for capacity.", "queue.noNodes": "No nodes registered.", @@ -205,6 +248,32 @@ const en: Dictionary = { "organizations.slug": "Slug", "organizations.provision": "Provision", "organizations.empty": "No organizations have been provisioned.", + "admin.identity": "Identity", + "admin.users": "Users", + "admin.email": "Email", + "admin.displayName": "Display name", + "admin.orgRole": "Organization role", + "admin.projectRole": "Project role", + "admin.user": "User", + "admin.saveUser": "Save user", + "admin.addMember": "Add member", + "admin.localUser": "local", + "admin.noUsers": "No users are available yet.", + "admin.projectCount": "{count} projects", + "admin.activeProject": "Active project", + "admin.noProject": "No project selected", + "admin.noMembers": "No project members yet.", + "admin.noProjectMembers": "Select a project to manage members.", + "admin.scope": "Scope", + "admin.projects": "Projects", + "admin.projectMembers": "{count} members", + "admin.noProjects": "No projects are available.", + "admin.selectedUser": "Selected user", + "admin.noUser": "No user selected", + "admin.createdAt": "Created", + "admin.noProjectAccess": "No project access", + "admin.password": "Initial or reset password", + "admin.passwordPlaceholder": "Leave blank to keep current password", "approvals.center": "Approval Center", "approvals.noReason": "No reason provided", "approvals.approve": "Approve", @@ -241,7 +310,41 @@ const en: Dictionary = { "status.unverified": "unverified", "status.docker": "docker", "status.ssh": "ssh", - "status.registered": "registered" + "status.registered": "registered", + "status.owner": "Owner", + "status.admin": "Admin", + "status.tech-lead": "Tech lead", + "status.operator": "Operator", + "status.reviewer": "Reviewer", + "status.auditor": "Auditor", + "status.viewer": "Viewer", + "status.project-admin": "Project admin", + "status.maintainer": "Maintainer", + "status.developer": "Developer", + "status.feature": "Feature", + "status.test": "Test", + "status.audit": "Audit", + "status.git-sync": "Git sync", + "status.git_sync": "Git sync", + "status.docs": "Docs", + "status.release": "Release", + "status.main": "Main", + "workflowAction.worker_spawn": "Spawn worker", + "workflowAction.worker_status": "Check worker", + "workflowAction.policy_validate_task": "Validate policy", + "workflowAction.repo_scope_check": "Run scope check", + "workflowAction.test_run_required": "Run tests", + "workflowAction.audit_run": "Run audit", + "workflowAction.approval_request": "Request approval", + "workflowAction.approval_request_pr_publish": "Request publish approval", + "workflowAction.git_prepare_pr": "Prepare PR", + "workflowAction.git_publish_pr": "Publish PR", + "workflowAction.resolve_blocker": "Resolve blocker", + "workflowAction.approval_status": "Check approvals", + "workflowAction.completed": "Completed", + "workflowAction.scope_check": "Run scope check", + "workflowAction.pr_prepare": "Prepare PR", + "workflowAction.pr_publish": "Publish PR" }; const zh: Dictionary = { @@ -254,11 +357,14 @@ const zh: Dictionary = { "nav.nodes": "节点", "nav.organizations": "组织", "nav.skills": "技能", + "nav.admin": "管理", "nav.audit": "审计", + "nav.primary": "主导航", "language.label": "语言", "language.en": "EN", "language.zh": "中文", "auth.session": "会话", + "auth.sessionUserRole": "{user} · {role}", "auth.required": "需要登录", "auth.checking": "检查中", "auth.mode": "认证模式", @@ -276,20 +382,37 @@ const zh: Dictionary = { "auth.loginBody": "multi-codex 会先确认 API 会话和角色权限,再加载受保护的项目数据。", "auth.loginOidcReady": "当前环境已配置 OIDC 登录。", "auth.loginOidcMissing": "当前启用了 OIDC,但登录客户端配置不完整。请使用有效 Bearer 令牌,或更新服务端配置。", - "auth.loginLocal": "当前为本地模式。刷新会话即可使用种子 owner 账户。", + "auth.loginLocal": "当前启用本地账号登录。种子管理员可使用邮箱和密码登录。", "auth.apiUnavailable": "暂时拿不到 API 能力信息,请检查 API 服务或开发代理。", "auth.tokenHelp": "粘贴 OIDC Bearer 令牌,可换取浏览器会话。", "auth.sessionTtl": "会话 TTL {hours} 小时", "auth.defaultRole": "默认角色 {role}", + "auth.enterpriseSubtitle": "企业级编排控制台", + "auth.secureWorkspace": "安全工作区", + "auth.loginHeroTitle": "用清晰的账号、项目和审计边界协调 Codex 工作。", + "auth.loginHeroBody": "登录后再加载仓库、Worker、审批和 MCP 活动数据。", + "auth.assuranceEnvelope": "任务 Envelope", + "auth.assuranceRbac": "RBAC", + "auth.assuranceAudit": "审计轨迹", + "auth.openConsole": "进入控制台", + "auth.email": "邮箱", + "auth.emailPlaceholder": "name@company.com", + "auth.password": "密码", + "auth.passwordPlaceholder": "输入密码", + "auth.advancedLogin": "高级:Bearer / OIDC 会话交换", "topbar.project": "项目", "topbar.repository": "仓库", "topbar.branch": "分支", + "topbar.projectRole": "项目角色", "topbar.refresh": "刷新", + "summary.workspace": "工作区概览", + "summary.projects": "项目", "summary.repositories": "仓库", "summary.tasks": "任务", "summary.runs": "运行", "summary.queued": "排队", "summary.approvals": "审批", + "summary.users": "用户", "access.lockedTitle": "需要权限", "access.lockedBody": "当前角色不能访问这个工作区。", "access.missing": "需要:{permission}", @@ -318,6 +441,23 @@ const zh: Dictionary = { "common.noRun": "无运行", "common.noEndpoint": "无端点", "common.noSecretRefs": "无密钥引用", + "common.noPath": "无路径", + "common.slug": "标识", + "common.remoteUrl": "远程 URL", + "common.bytes": "{count} 字节", + "common.yes": "是", + "common.no": "否", + "common.ephemeral": "临时", + "common.local": "本地", + "common.show": "显示", + "common.displayCount": "显示条数", + "common.rows": "{count} 条", + "common.pagination": "分页", + "common.prev": "上一页", + "common.next": "下一页", + "common.previousPage": "上一页", + "common.nextPage": "下一页", + "common.pageStatus": "{page}/{pages}", "dashboard.workQueue": "工作队列", "dashboard.activeTasks": "活跃任务", "dashboard.capacity": "容量", @@ -343,6 +483,7 @@ const zh: Dictionary = { "tasks.connectFirst": "先连接项目和仓库。", "tasks.lifecycleHelp": "任务创建后,这里会跟踪 Envelope 校验、范围检查、执行器隔离、审计证据和审批。", "tasks.openTasks": "打开任务", + "tasks.filters": "任务筛选", "tasks.noFilterMatch": "没有任务匹配当前筛选。", "tasks.noTasks": "还没有任务。", "tasks.selectTask": "选择或创建任务,以查看运行、日志、范围检查和审计状态。", @@ -372,6 +513,8 @@ const zh: Dictionary = { "tasks.latestRunEvents": "最新运行事件", "tasks.latestRunArtifacts": "最新运行产物", "tasks.envelope": "任务 Envelope", + "tasks.defaultTitle": "实现受控生命周期检查", + "tasks.defaultObjective": "运行受限范围的功能 worker,并收集可验证产物。", "lifecycle.taskEnvelope": "Task Envelope", "lifecycle.policyValidation": "策略校验", "lifecycle.executorIsolation": "执行器隔离", @@ -406,10 +549,13 @@ const zh: Dictionary = { "runs.live": "实时", "runs.connecting": "连接中", "runs.noEvents": "还没有事件。", + "runs.runTitle": "{role} 运行", "queue.workerQueue": "Worker 队列", "queue.dispatch": "调度", "queue.open": "打开队列", "queue.backpressure": "背压", + "queue.priorityAttempt": "优先级 {priority} · 尝试 {attempt}/{max}", + "queue.dispatchFailed": "调度失败。", "queue.noCapacity": "执行器上报背压后,这里会显示容量。", "queue.noQueued": "没有等待容量的 worker 运行。", "queue.noNodes": "还没有注册节点。", @@ -442,6 +588,32 @@ const zh: Dictionary = { "organizations.slug": "Slug", "organizations.provision": "创建", "organizations.empty": "还没有创建组织。", + "admin.identity": "身份", + "admin.users": "用户", + "admin.email": "邮箱", + "admin.displayName": "显示名称", + "admin.orgRole": "组织角色", + "admin.projectRole": "项目角色", + "admin.user": "用户", + "admin.saveUser": "保存用户", + "admin.addMember": "添加成员", + "admin.localUser": "本地", + "admin.noUsers": "还没有可用用户。", + "admin.projectCount": "{count} 个项目", + "admin.activeProject": "当前项目", + "admin.noProject": "未选择项目", + "admin.noMembers": "当前项目还没有成员。", + "admin.noProjectMembers": "选择一个项目来管理成员。", + "admin.scope": "范围", + "admin.projects": "项目", + "admin.projectMembers": "{count} 个成员", + "admin.noProjects": "还没有可用项目。", + "admin.selectedUser": "已选用户", + "admin.noUser": "未选择用户", + "admin.createdAt": "创建时间", + "admin.noProjectAccess": "暂无项目权限", + "admin.password": "初始或重置密码", + "admin.passwordPlaceholder": "留空则保留当前密码", "approvals.center": "审批中心", "approvals.noReason": "未提供原因", "approvals.approve": "通过", @@ -478,7 +650,41 @@ const zh: Dictionary = { "status.unverified": "未验证", "status.docker": "docker", "status.ssh": "ssh", - "status.registered": "已注册" + "status.registered": "已注册", + "status.owner": "所有者", + "status.admin": "管理员", + "status.tech-lead": "技术负责人", + "status.operator": "操作员", + "status.reviewer": "评审员", + "status.auditor": "审计员", + "status.viewer": "只读成员", + "status.project-admin": "项目管理员", + "status.maintainer": "维护者", + "status.developer": "开发者", + "status.feature": "功能", + "status.test": "测试", + "status.audit": "审计", + "status.git-sync": "Git 同步", + "status.git_sync": "Git 同步", + "status.docs": "文档", + "status.release": "发布", + "status.main": "主流程", + "workflowAction.worker_spawn": "启动 worker", + "workflowAction.worker_status": "检查 worker", + "workflowAction.policy_validate_task": "校验策略", + "workflowAction.repo_scope_check": "执行范围检查", + "workflowAction.test_run_required": "运行测试", + "workflowAction.audit_run": "运行审计", + "workflowAction.approval_request": "申请审批", + "workflowAction.approval_request_pr_publish": "申请发布审批", + "workflowAction.git_prepare_pr": "准备 PR", + "workflowAction.git_publish_pr": "发布 PR", + "workflowAction.resolve_blocker": "处理阻塞", + "workflowAction.approval_status": "检查审批", + "workflowAction.completed": "已完成", + "workflowAction.scope_check": "执行范围检查", + "workflowAction.pr_prepare": "准备 PR", + "workflowAction.pr_publish": "发布 PR" }; const dictionaries: Record = { en, zh }; diff --git a/apps/web/src/lib/permissions.tsx b/apps/web/src/lib/permissions.tsx index 2c5080e..5fd3c1e 100644 --- a/apps/web/src/lib/permissions.tsx +++ b/apps/web/src/lib/permissions.tsx @@ -3,6 +3,8 @@ import type { AuthContext } from "./api"; export type Permission = | "*" + | "users:read" + | "users:write" | "organizations:read" | "organizations:write" | "projects:read" @@ -61,3 +63,10 @@ export function visiblePermissions(auth: AuthContext | undefined) { } return auth.permissions; } + +export function projectRole(auth: AuthContext | undefined, projectId?: string) { + if (!auth || !projectId) { + return ""; + } + return auth.project_memberships.find((membership) => membership.project_id === projectId)?.role ?? ""; +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 4995375..50109d5 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -12,6 +12,7 @@ --surface-tint: #eef7f5; --line: #dce3ea; --line-soft: #e8edf2; + --line-strong: #c8d3dd; --text: #17212c; --muted: #607082; --muted-strong: #415063; @@ -29,6 +30,7 @@ --radius: 8px; --radius-sm: 6px; --shadow-soft: 0 8px 28px rgba(28, 44, 62, 0.06); + --shadow-panel: 0 1px 2px rgba(28, 44, 62, 0.04), 0 10px 26px rgba(28, 44, 62, 0.05); } * { @@ -39,7 +41,7 @@ body { min-width: 320px; min-height: 100vh; margin: 0; - background: var(--bg); + background: #eef2f5; } a { @@ -65,7 +67,7 @@ pre { .shell { display: grid; - grid-template-columns: 228px minmax(0, 1fr); + grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; } @@ -79,6 +81,8 @@ pre { border-right: 1px solid var(--line); background: var(--surface); padding: 18px 14px; + overflow-y: auto; + scrollbar-gutter: stable; } .brand { @@ -89,6 +93,10 @@ pre { border-bottom: 1px solid var(--line-soft); } +.brand > div { + min-width: 0; +} + .brand-mark { display: grid; place-items: center; @@ -116,6 +124,7 @@ pre { .brand h1 { font-size: 18px; line-height: 1.1; + white-space: nowrap; } .brand p { @@ -129,6 +138,7 @@ pre { display: grid; gap: 3px; margin-top: 18px; + padding-bottom: 12px; } .nav-list a { @@ -271,7 +281,7 @@ pre { .workspace { min-width: 0; - padding: 18px; + padding: 18px 20px 26px; } .topbar { @@ -285,9 +295,9 @@ pre { .topbar-selectors { display: grid; - grid-template-columns: minmax(200px, 1fr) minmax(200px, 1fr) minmax(116px, 0.45fr); + grid-template-columns: minmax(190px, 1fr) minmax(190px, 1fr) minmax(128px, 0.48fr); gap: 12px; - width: min(760px, 100%); + width: min(840px, 100%); } .topbar-selectors label, @@ -344,7 +354,7 @@ pre { .summary-strip { display: grid; - grid-template-columns: repeat(5, minmax(110px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 10px; margin-bottom: 12px; } @@ -369,6 +379,85 @@ pre { min-width: 0; } +.login-page { + display: grid; + grid-template-columns: minmax(320px, 0.9fr) minmax(320px, 520px); + gap: 28px; + align-items: center; + width: min(1120px, calc(100% - 32px)); + min-height: 100dvh; + margin: 0 auto; + padding: 32px 0; +} + +.login-hero { + display: grid; + gap: 28px; + min-width: 0; +} + +.login-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.login-brand h1, +.login-brand p, +.login-copy h2, +.login-copy p { + margin: 0; +} + +.login-brand h1 { + color: var(--ink); + font-size: 22px; + line-height: 1.1; +} + +.login-brand p { + margin-top: 4px; + color: var(--muted); + font-size: 13px; + font-weight: 750; +} + +.login-copy { + display: grid; + gap: 12px; + max-width: 620px; +} + +.login-copy h2 { + color: var(--ink); + font-size: 42px; + line-height: 1.08; + max-width: 12ch; +} + +.login-copy p:not(.eyebrow) { + max-width: 560px; + color: var(--muted-strong); + font-size: 16px; + line-height: 1.65; +} + +.login-assurance { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.login-assurance span { + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--muted-strong); + font-size: 12px; + font-weight: 850; + padding: 7px 9px; +} + .login-panel, .access-panel { display: grid; @@ -395,6 +484,8 @@ pre { .login-meta, .login-actions, +.credential-login, +.advanced-login, .token-exchange { display: grid; gap: 9px; @@ -415,6 +506,11 @@ pre { align-items: end; } +.credential-login { + grid-template-columns: 1fr; +} + +.credential-login label, .token-exchange label { display: grid; gap: 6px; @@ -423,6 +519,7 @@ pre { font-weight: 800; } +.credential-login input, .token-exchange input { width: 100%; min-height: 38px; @@ -432,6 +529,18 @@ pre { padding: 8px 10px; } +.advanced-login { + border-top: 1px solid var(--line-soft); + padding-top: 4px; +} + +.advanced-login summary { + color: var(--muted-strong); + cursor: pointer; + font-size: 12px; + font-weight: 850; +} + .access-notice { display: grid; gap: 6px; @@ -453,7 +562,7 @@ pre { .cockpit-grid { display: grid; - grid-template-columns: minmax(270px, 0.82fr) minmax(360px, 1.2fr) minmax(300px, 0.95fr); + grid-template-columns: minmax(260px, 0.78fr) minmax(380px, 1.14fr) minmax(280px, 0.9fr); gap: 12px; align-items: start; } @@ -469,24 +578,31 @@ pre { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); - box-shadow: var(--shadow-soft); + box-shadow: var(--shadow-panel); } .cockpit-panel { - overflow: hidden; + min-width: 0; } .panel-heading { display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; gap: 12px; min-height: 54px; border-bottom: 1px solid var(--line-soft); padding: 12px 14px; } +.panel-heading > div:first-child { + min-width: 0; +} + .panel-heading h3 { + min-width: 0; + overflow-wrap: anywhere; color: var(--ink); font-size: 15px; line-height: 1.2; @@ -497,6 +613,122 @@ pre { font-size: 12px; } +.panel-heading-actions, +.inline-list-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.panel-heading-actions { + margin-left: auto; +} + +.inline-list-toolbar { + justify-content: space-between; + border-top: 1px solid var(--line-soft); + padding: 10px 14px; +} + +.inline-list-toolbar span { + color: var(--muted); + font-size: 12px; +} + +.list-pagination-control { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + min-width: 0; +} + +.list-limit-control { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + gap: 2px; + min-height: 28px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface-soft); + padding: 2px; +} + +.list-limit-control span { + padding: 0 6px; + color: var(--muted); + font-size: 11px; + font-weight: 800; +} + +.list-limit-control button { + min-width: 30px; + min-height: 22px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--muted-strong); + cursor: pointer; + font-size: 11px; + font-weight: 850; + line-height: 1; + padding: 4px 6px; +} + +.list-limit-control button:hover, +.list-limit-control button.active { + border-color: #b9d8d5; + background: #ffffff; + color: #163d3b; +} + +.pager-control { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + gap: 4px; + min-height: 28px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: #ffffff; + padding: 2px; +} + +.pager-control button { + min-height: 22px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--muted-strong); + cursor: pointer; + font-size: 11px; + font-weight: 850; + line-height: 1; + padding: 4px 6px; +} + +.pager-control button:hover:not(:disabled) { + border-color: #c7d0da; + background: var(--surface-soft); +} + +.pager-control button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.pager-control span { + min-width: 36px; + color: var(--muted); + font-size: 11px; + font-weight: 850; + text-align: center; +} + .segment-group { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); @@ -542,9 +774,71 @@ pre { display: grid; } +.dashboard-list, +.bounded-list, .cockpit-task-list { - max-height: 360px; overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.dashboard-list { + max-height: clamp(260px, 34vh, 390px); +} + +.bounded-list { + max-height: min(640px, calc(100vh - 260px)); +} + +.compact-bounded-list { + max-height: min(360px, calc(100vh - 340px)); +} + +.cockpit-task-list { + max-height: clamp(320px, 44vh, 520px); +} + +.table-list.bounded-list, +.task-list.bounded-list, +.run-list.bounded-list, +.audit-list.bounded-list, +.project-access-list.bounded-list, +.permission-stack.bounded-list { + min-height: 180px; +} + +.table-list.dashboard-list, +.audit-list.dashboard-list, +.capacity-list.dashboard-list, +.cockpit-task-list.dashboard-list { + min-height: 180px; +} + +.event-list { + min-height: 140px; +} + +.version-shell { + border-top: 1px solid var(--line-soft); +} + +.version-shell .version-list { + border-top: 0; +} + +.artifact-shell .inline-list-toolbar { + border-bottom: 1px solid var(--line-soft); +} + +.artifact-list.bounded-list { + min-height: 140px; +} + +.event-list { + max-height: min(420px, calc(100vh - 340px)); + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; } .task-row { @@ -579,12 +873,14 @@ pre { .task-row h4 { margin-top: 3px; - overflow: hidden; color: var(--ink); font-size: 13px; line-height: 1.35; - text-overflow: ellipsis; - white-space: nowrap; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: normal; } .queue-stats, @@ -653,7 +949,9 @@ pre { cursor: pointer; font-size: 13px; font-weight: 800; + line-height: 1.2; padding: 7px 11px; + text-align: center; } .primary-button { @@ -705,7 +1003,7 @@ pre { } .lifecycle-panel { - min-height: 720px; + min-height: 0; } .lifecycle-header { @@ -736,12 +1034,10 @@ pre { .task-title-line h3 { min-width: 0; - overflow: hidden; + overflow-wrap: anywhere; color: var(--ink); font-size: 18px; line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; } .task-meta, @@ -824,6 +1120,7 @@ pre { margin-top: 2px; color: var(--muted); font-size: 12px; + overflow-wrap: anywhere; } .step-rows, @@ -854,11 +1151,9 @@ pre { .run-facts dd, .detail-list dd { margin: 3px 0 0; - overflow: hidden; color: var(--muted-strong); font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } .scope-form { @@ -995,17 +1290,126 @@ pre { grid-template-columns: minmax(0, 1fr) auto minmax(80px, 0.65fr); } +.compact-evidence .data-row code, +.compact-evidence .data-row span { + font-size: 11px; +} + .evidence-row { padding-block: 9px; } .content-grid { display: grid; - grid-template-columns: minmax(300px, 420px) minmax(0, 1fr); + grid-template-columns: minmax(300px, 400px) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.tasks-layout, +.runs-layout { + grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); +} + +.queue-layout, +.audit-layout { + grid-template-columns: minmax(0, 1fr) minmax(340px, 0.72fr); +} + +.skills-layout { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.admin-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); gap: 12px; align-items: start; } +.admin-main, +.admin-side { + display: grid; + gap: 12px; + min-width: 0; +} + +.admin-user-form { + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + align-items: end; +} + +.admin-member-form { + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + align-items: end; +} + +.admin-user-row { + grid-template-columns: minmax(220px, 1.3fr) auto minmax(96px, 0.45fr) minmax(120px, 0.55fr); +} + +.admin-member-row { + grid-template-columns: minmax(220px, 1.2fr) auto minmax(150px, 0.7fr) minmax(110px, 0.45fr); +} + +.project-access-list { + display: grid; + gap: 8px; + padding: 12px; +} + +.project-access-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); + background: #ffffff; + color: inherit; + cursor: pointer; + padding: 10px; + text-align: left; +} + +.project-access-row:hover, +.project-access-row.is-selected { + border-color: #b9d8d5; + background: var(--surface-tint); +} + +.project-access-row span, +.project-access-row strong, +.project-access-row code { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-access-row strong { + color: var(--ink); + font-size: 13px; +} + +.project-access-row code, +.project-access-row em { + color: var(--muted); + font-size: 12px; + font-style: normal; +} + +.user-access-card { + display: grid; + gap: 10px; +} + +.user-project-stack { + padding: 0 14px 14px; +} + .dashboard-grid { display: grid; grid-template-columns: minmax(320px, 0.9fr) minmax(320px, 1.1fr); @@ -1023,12 +1427,12 @@ pre { } .inline-form { - grid-template-columns: minmax(180px, 1fr) minmax(200px, 1.2fr) auto; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); align-items: end; } .node-form { - grid-template-columns: repeat(2, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); } .node-form button { @@ -1098,11 +1502,13 @@ pre { .data-row span, .data-row code { display: block; - overflow: hidden; color: var(--muted); font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; +} + +.data-row:has(> :nth-child(5)) { + grid-template-columns: minmax(190px, 1.2fr) auto minmax(90px, 0.45fr) minmax(150px, 0.9fr) minmax(130px, 0.7fr); } .artifact-row, @@ -1210,11 +1616,9 @@ pre { .run-row code { min-width: 0; - overflow: hidden; color: var(--muted); font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } .gate-strip { @@ -1256,9 +1660,8 @@ pre { .audit-row div, .audit-row code { display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + min-width: 0; + overflow-wrap: anywhere; } .audit-row code { @@ -1424,7 +1827,9 @@ pre { .shell { grid-template-columns: 216px minmax(0, 1fr); } +} +@media (max-width: 1180px) { .cockpit-grid { grid-template-columns: minmax(270px, 0.9fr) minmax(0, 1.2fr); } @@ -1448,7 +1853,14 @@ pre { } .auth-controls { - margin-top: 14px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 12px; + } + + .language-toggle, + .permission-stack, + .auth-actions { + grid-column: 1 / -1; } .nav-list { @@ -1473,6 +1885,9 @@ pre { .summary-strip, .cockpit-grid, .content-grid, + .admin-grid, + .admin-user-form, + .admin-member-form, .dashboard-grid, .inline-form, .node-form, @@ -1490,6 +1905,17 @@ pre { .actions { justify-content: flex-start; } + + .login-page { + align-items: stretch; + grid-template-columns: 1fr; + min-height: auto; + } + + .login-copy h2 { + max-width: 760px; + font-size: 34px; + } } @media (max-width: 680px) { @@ -1497,7 +1923,10 @@ pre { padding: 12px; } - .summary-strip, + .summary-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .queue-stats, .step-rows, .run-facts, @@ -1516,10 +1945,25 @@ pre { .run-row, .capacity-row, .node-row, + .admin-user-row, + .admin-member-row, .compact-evidence .data-row { grid-template-columns: 1fr; } + .login-page { + width: min(100% - 24px, 560px); + padding: 18px 0; + } + + .login-copy h2 { + font-size: 28px; + } + + .project-access-row { + grid-template-columns: 1fr; + } + .task-title-line { align-items: flex-start; flex-direction: column; diff --git a/cmd/api/main.go b/cmd/api/main.go index 92429eb..ddc2b88 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -21,7 +21,7 @@ func main() { log.Error("production configuration rejected", "error", err) os.Exit(1) } - runtimeStore, err := store.Open(context.Background(), cfg.DatabaseURL, log) + runtimeStore, err := store.OpenWithConfig(context.Background(), cfg, log) if err != nil { log.Error("open store failed", "error", err) os.Exit(1) diff --git a/cmd/mcp-gateway/main.go b/cmd/mcp-gateway/main.go index cfe1d68..6889883 100644 --- a/cmd/mcp-gateway/main.go +++ b/cmd/mcp-gateway/main.go @@ -21,7 +21,7 @@ func main() { log.Error("production configuration rejected", "error", err) os.Exit(1) } - runtimeStore, err := store.Open(context.Background(), cfg.DatabaseURL, log) + runtimeStore, err := store.OpenWithConfig(context.Background(), cfg, log) if err != nil { log.Error("open store failed", "error", err) os.Exit(1) diff --git a/deployments/docker/compose.dev.yaml b/deployments/docker/compose.dev.yaml index e7415b0..dcbf4e8 100644 --- a/deployments/docker/compose.dev.yaml +++ b/deployments/docker/compose.dev.yaml @@ -32,6 +32,8 @@ services: MULTICODEX_AUTH_SESSION_TTL: ${MULTICODEX_AUTH_SESSION_TTL:-12h} MULTICODEX_AUTH_COOKIE_SECURE: ${MULTICODEX_AUTH_COOKIE_SECURE:-false} MULTICODEX_AUTH_LOGIN_STATE_TTL: ${MULTICODEX_AUTH_LOGIN_STATE_TTL:-10m} + MULTICODEX_LOCAL_ADMIN_EMAIL: ${MULTICODEX_LOCAL_ADMIN_EMAIL:-local-dev@multi-codex.invalid} + MULTICODEX_LOCAL_ADMIN_PASSWORD: ${MULTICODEX_LOCAL_ADMIN_PASSWORD:-admin123} MULTICODEX_OIDC_ISSUER: ${MULTICODEX_OIDC_ISSUER:-} MULTICODEX_OIDC_AUDIENCE: ${MULTICODEX_OIDC_AUDIENCE:-} MULTICODEX_OIDC_JWKS_URL: ${MULTICODEX_OIDC_JWKS_URL:-} @@ -108,6 +110,8 @@ services: MULTICODEX_AUTH_SESSION_TTL: ${MULTICODEX_AUTH_SESSION_TTL:-12h} MULTICODEX_AUTH_COOKIE_SECURE: ${MULTICODEX_AUTH_COOKIE_SECURE:-false} MULTICODEX_AUTH_LOGIN_STATE_TTL: ${MULTICODEX_AUTH_LOGIN_STATE_TTL:-10m} + MULTICODEX_LOCAL_ADMIN_EMAIL: ${MULTICODEX_LOCAL_ADMIN_EMAIL:-local-dev@multi-codex.invalid} + MULTICODEX_LOCAL_ADMIN_PASSWORD: ${MULTICODEX_LOCAL_ADMIN_PASSWORD:-admin123} MULTICODEX_OIDC_ISSUER: ${MULTICODEX_OIDC_ISSUER:-} MULTICODEX_OIDC_AUDIENCE: ${MULTICODEX_OIDC_AUDIENCE:-} MULTICODEX_OIDC_JWKS_URL: ${MULTICODEX_OIDC_JWKS_URL:-} @@ -200,6 +204,8 @@ services: MULTICODEX_AGENTD_URL: http://worker-agentd-dev:7070 MULTICODEX_AGENTD_TOKEN: ${MULTICODEX_AGENTD_TOKEN:-} MULTICODEX_AUTH_MODE: ${MULTICODEX_AUTH_MODE:-local} + MULTICODEX_LOCAL_ADMIN_EMAIL: ${MULTICODEX_LOCAL_ADMIN_EMAIL:-local-dev@multi-codex.invalid} + MULTICODEX_LOCAL_ADMIN_PASSWORD: ${MULTICODEX_LOCAL_ADMIN_PASSWORD:-admin123} MULTICODEX_OIDC_ISSUER: ${MULTICODEX_OIDC_ISSUER:-} MULTICODEX_OIDC_AUDIENCE: ${MULTICODEX_OIDC_AUDIENCE:-} MULTICODEX_OIDC_JWKS_URL: ${MULTICODEX_OIDC_JWKS_URL:-} diff --git a/internal/api/auth_logout_test.go b/internal/api/auth_logout_test.go index d9dceb0..b2b4504 100644 --- a/internal/api/auth_logout_test.go +++ b/internal/api/auth_logout_test.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "crypto/rand" "crypto/rsa" "encoding/json" @@ -18,7 +19,9 @@ import ( func TestAuthLogoutAuditsDecision(t *testing.T) { st := store.NewMemoryStore() server := NewServer(config.Config{AuthMode: "local"}, st, slog.New(slog.NewTextHandler(io.Discard, nil))) + cookie := localSessionCookie(t, server.Handler()) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) + req.AddCookie(cookie) resp := httptest.NewRecorder() server.Handler().ServeHTTP(resp, req) if resp.Code != http.StatusOK { @@ -32,6 +35,46 @@ func TestAuthLogoutAuditsDecision(t *testing.T) { t.Fatalf("expected api.auth_logout audit row") } +func TestLocalAuthRequiresBrowserSession(t *testing.T) { + st := store.NewMemoryStore() + server := NewServer(config.Config{AuthMode: "local"}, st, slog.New(slog.NewTextHandler(io.Discard, nil))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + resp := httptest.NewRecorder() + server.Handler().ServeHTTP(resp, req) + if resp.Code != http.StatusUnauthorized { + t.Fatalf("anonymous local auth status = %d, body = %s", resp.Code, resp.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/session", bytes.NewReader([]byte(`{"email":"local-dev@multi-codex.invalid","password":"wrong-password"}`))) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + server.Handler().ServeHTTP(resp, req) + if resp.Code != http.StatusUnauthorized { + t.Fatalf("bad password session status = %d, body = %s", resp.Code, resp.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/session", bytes.NewReader([]byte(`{"email":"local-dev@multi-codex.invalid","password":"admin123"}`))) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + server.Handler().ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("session status = %d, body = %s", resp.Code, resp.Body.String()) + } + cookies := resp.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != authSessionCookieName || !cookies[0].HttpOnly { + t.Fatalf("session cookies = %#v", cookies) + } + + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.AddCookie(cookies[0]) + resp = httptest.NewRecorder() + server.Handler().ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("session local auth status = %d, body = %s", resp.Code, resp.Body.String()) + } +} + func TestOIDCAuthLogoutRevokesBearerToken(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { diff --git a/internal/api/git_publish_test.go b/internal/api/git_publish_test.go index fe389db..6f8da5c 100644 --- a/internal/api/git_publish_test.go +++ b/internal/api/git_publish_test.go @@ -18,8 +18,10 @@ func TestGitPublishPRAuditsCredentialMetadata(t *testing.T) { st := store.NewMemoryStore() task := apiGitPublishReadyTask(t, st) server := NewServer(config.Config{GitSyncMode: "dry-run"}, st, slog.New(slog.NewTextHandler(io.Discard, nil))) + cookie := localSessionCookie(t, server.Handler()) req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+task.ID+"/workflow/git_publish_pr", nil) + req.AddCookie(cookie) resp := httptest.NewRecorder() server.Handler().ServeHTTP(resp, req) if resp.Code != http.StatusCreated { @@ -62,8 +64,10 @@ func TestGitPublishPRLiveCreatesProviderPRWithoutAutoMerge(t *testing.T) { GitHubAPIURL: provider.URL, GitHubToken: "gh-live-token", }, st, slog.New(slog.NewTextHandler(io.Discard, nil))) + cookie := localSessionCookie(t, server.Handler()) req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+task.ID+"/workflow/git_publish_pr", nil) + req.AddCookie(cookie) resp := httptest.NewRecorder() server.Handler().ServeHTTP(resp, req) if resp.Code != http.StatusCreated { diff --git a/internal/api/organizations_test.go b/internal/api/organizations_test.go index b864b0b..f84a8b6 100644 --- a/internal/api/organizations_test.go +++ b/internal/api/organizations_test.go @@ -17,8 +17,10 @@ import ( func TestOrganizationsAPIProvisioningAuditsCreate(t *testing.T) { st := store.NewMemoryStore() server := NewServer(config.Config{}, st, slog.New(slog.NewTextHandler(io.Discard, nil))) + cookie := localSessionCookie(t, server.Handler()) req := httptest.NewRequest(http.MethodPost, "/api/v1/organizations", strings.NewReader(`{"name":"Engineering","slug":"engineering"}`)) + req.AddCookie(cookie) req.Header.Set("Content-Type", "application/json") resp := httptest.NewRecorder() server.Handler().ServeHTTP(resp, req) @@ -34,6 +36,7 @@ func TestOrganizationsAPIProvisioningAuditsCreate(t *testing.T) { } req = httptest.NewRequest(http.MethodGet, "/api/v1/organizations", nil) + req.AddCookie(cookie) resp = httptest.NewRecorder() server.Handler().ServeHTTP(resp, req) if resp.Code != http.StatusOK { @@ -62,8 +65,10 @@ func TestOrganizationsAPIProvisioningAuditsCreate(t *testing.T) { func TestOrganizationsAPIRejectsDuplicateSlug(t *testing.T) { st := store.NewMemoryStore() server := NewServer(config.Config{}, st, slog.New(slog.NewTextHandler(io.Discard, nil))) + cookie := localSessionCookie(t, server.Handler()) for i := 0; i < 2; i++ { req := httptest.NewRequest(http.MethodPost, "/api/v1/organizations", strings.NewReader(`{"name":"Engineering","slug":"engineering"}`)) + req.AddCookie(cookie) req.Header.Set("Content-Type", "application/json") resp := httptest.NewRecorder() server.Handler().ServeHTTP(resp, req) diff --git a/internal/api/pilot_workflow_test.go b/internal/api/pilot_workflow_test.go index 0df274a..b3d80ad 100644 --- a/internal/api/pilot_workflow_test.go +++ b/internal/api/pilot_workflow_test.go @@ -315,6 +315,7 @@ func assertPilotAuditEvidence(t *testing.T, st *store.MemoryStore, dryRunID stri func apiDo(t *testing.T, handler http.Handler, method string, path string, body any, wantStatus int, out any) { t.Helper() + cookie := localSessionCookie(t, handler) var reader io.Reader if body != nil { data, err := json.Marshal(body) @@ -324,6 +325,9 @@ func apiDo(t *testing.T, handler http.Handler, method string, path string, body reader = bytes.NewReader(data) } req := httptest.NewRequest(method, path, reader) + if cookie != nil { + req.AddCookie(cookie) + } if body != nil { req.Header.Set("Content-Type", "application/json") } @@ -339,6 +343,24 @@ func apiDo(t *testing.T, handler http.Handler, method string, path string, body } } +func localSessionCookie(t *testing.T, handler http.Handler) *http.Cookie { + t.Helper() + body := bytes.NewReader([]byte(`{"email":"local-dev@multi-codex.invalid","password":"admin123"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/session", body) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + return nil + } + for _, cookie := range resp.Result().Cookies() { + if cookie.Name == authSessionCookieName { + return cookie + } + } + return nil +} + func createPilotMirror(t *testing.T) string { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) diff --git a/internal/api/queue_test.go b/internal/api/queue_test.go index b3e654e..1d77914 100644 --- a/internal/api/queue_test.go +++ b/internal/api/queue_test.go @@ -26,8 +26,10 @@ func TestQueueAPIDispatchStartsQueuedRunAndAudits(t *testing.T) { WorktreeRoot: t.TempDir(), RepoCacheRoot: t.TempDir(), }, st, slog.New(slog.NewTextHandler(io.Discard, nil))) + cookie := localSessionCookie(t, server.Handler()) req := httptest.NewRequest(http.MethodGet, "/api/v1/queue", nil) + req.AddCookie(cookie) resp := httptest.NewRecorder() server.Handler().ServeHTTP(resp, req) if resp.Code != http.StatusOK { @@ -44,6 +46,7 @@ func TestQueueAPIDispatchStartsQueuedRunAndAudits(t *testing.T) { } req = httptest.NewRequest(http.MethodPost, "/api/v1/queue/dispatch", nil) + req.AddCookie(cookie) resp = httptest.NewRecorder() server.Handler().ServeHTTP(resp, req) if resp.Code != http.StatusAccepted { diff --git a/internal/api/resource_authorization_test.go b/internal/api/resource_authorization_test.go index a0a946a..ca72289 100644 --- a/internal/api/resource_authorization_test.go +++ b/internal/api/resource_authorization_test.go @@ -59,24 +59,25 @@ func TestAPIResourceAuthorizationDeniesCrossOrganizationResources(t *testing.T) foreignAudit := st.RecordAuditLog(domain.AuditLog{OrgID: foreignOrg.ID, ActorType: "human", ActorID: "foreign", Action: "foreign.action", ResourceType: "project", ResourceID: foreignProject.ID}) server := NewServer(config.Config{AuthMode: "local"}, st, slog.New(slog.NewTextHandler(io.Discard, nil))) + cookie := localSessionCookie(t, server.Handler()) - assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+foreignProject.ID, nil) - assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+foreignProject.ID+"/repositories", nil) - assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+foreignProject.ID+"/agent-profiles", nil) - assertForbidden(t, server, http.MethodGet, "/api/v1/tasks/"+foreignTask.ID, nil) - assertForbidden(t, server, http.MethodGet, "/api/v1/tasks/"+foreignTask.ID+"/runs", nil) - assertForbidden(t, server, http.MethodGet, "/api/v1/runs/"+foreignRun.ID, nil) - assertForbidden(t, server, http.MethodGet, "/api/v1/runs/"+foreignRun.ID+"/artifacts", nil) - assertForbidden(t, server, http.MethodGet, "/api/v1/artifacts/"+foreignArtifact.ID+"/content", nil) - assertForbidden(t, server, http.MethodPost, "/api/v1/approvals/"+foreignApproval.ID+"/decision", []byte(`{"status":"approved"}`)) - assertForbidden(t, server, http.MethodPost, "/api/v1/executor-nodes/"+foreignNode.ID+"/verify-host-key", []byte(`{"observed_fingerprint":"SHA256:test"}`)) - assertForbidden(t, server, http.MethodGet, "/api/v1/skills/"+foreignSkill.ID+"/versions", nil) - - assertListDoesNotContain(t, server, "/api/v1/projects", foreignProject.ID) - assertListDoesNotContain(t, server, "/api/v1/approvals", foreignApproval.ID) - assertListDoesNotContain(t, server, "/api/v1/executor-nodes", foreignNode.ID) - assertListDoesNotContain(t, server, "/api/v1/skills", foreignSkill.ID) - assertListDoesNotContain(t, server, "/api/v1/audit-logs", foreignAudit.ID) + assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+foreignProject.ID, nil, cookie) + assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+foreignProject.ID+"/repositories", nil, cookie) + assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+foreignProject.ID+"/agent-profiles", nil, cookie) + assertForbidden(t, server, http.MethodGet, "/api/v1/tasks/"+foreignTask.ID, nil, cookie) + assertForbidden(t, server, http.MethodGet, "/api/v1/tasks/"+foreignTask.ID+"/runs", nil, cookie) + assertForbidden(t, server, http.MethodGet, "/api/v1/runs/"+foreignRun.ID, nil, cookie) + assertForbidden(t, server, http.MethodGet, "/api/v1/runs/"+foreignRun.ID+"/artifacts", nil, cookie) + assertForbidden(t, server, http.MethodGet, "/api/v1/artifacts/"+foreignArtifact.ID+"/content", nil, cookie) + assertForbidden(t, server, http.MethodPost, "/api/v1/approvals/"+foreignApproval.ID+"/decision", []byte(`{"status":"approved"}`), cookie) + assertForbidden(t, server, http.MethodPost, "/api/v1/executor-nodes/"+foreignNode.ID+"/verify-host-key", []byte(`{"observed_fingerprint":"SHA256:test"}`), cookie) + assertForbidden(t, server, http.MethodGet, "/api/v1/skills/"+foreignSkill.ID+"/versions", nil, cookie) + + assertListDoesNotContain(t, server, "/api/v1/projects", foreignProject.ID, cookie) + assertListDoesNotContain(t, server, "/api/v1/approvals", foreignApproval.ID, cookie) + assertListDoesNotContain(t, server, "/api/v1/executor-nodes", foreignNode.ID, cookie) + assertListDoesNotContain(t, server, "/api/v1/skills", foreignSkill.ID, cookie) + assertListDoesNotContain(t, server, "/api/v1/audit-logs", foreignAudit.ID, cookie) var denied bool for _, entry := range st.ListAuditLogs() { @@ -133,13 +134,125 @@ func TestAPIViewerCannotMutateResources(t *testing.T) { } } -func assertForbidden(t *testing.T, server *Server, method string, path string, body []byte) { +func TestAPIProjectMembershipScopesProjectAccess(t *testing.T) { + st := store.NewMemoryStore() + member, err := st.UpsertUser(domain.User{Email: "dev@example.com", DisplayName: "Dev User"}, "org_default", "viewer") + if err != nil { + t.Fatal(err) + } + allowedProject := st.CreateProject(domain.Project{OrgID: "org_default", Name: "Allowed Project", Slug: "allowed-project"}) + allowedRepo := st.CreateRepository(domain.Repository{ProjectID: allowedProject.ID, Name: "repo", Provider: "local", RemoteURL: "file:///allowed.git"}) + allowedTask := st.CreateTask(domain.TaskEnvelope{ + TaskID: "ALLOWED-1", + ProjectID: allowedProject.ID, + RepositoryID: allowedRepo.ID, + Title: "Allowed task", + Role: "feature", + Skill: "company-feature-worker", + AgentProfile: "feature-worker-go-node", + Executor: "docker", + AllowedPaths: []string{"internal/**"}, + ForbiddenPaths: []string{".env*"}, + }) + deniedProject := st.CreateProject(domain.Project{OrgID: "org_default", Name: "Denied Project", Slug: "denied-project"}) + if _, err := st.UpsertProjectMembership(domain.ProjectMembership{ProjectID: allowedProject.ID, UserID: member.User.ID, Role: "developer"}); err != nil { + t.Fatal(err) + } + memberAuth, err := st.UpsertUser(domain.User{Email: "dev@example.com", DisplayName: "Dev User"}, "org_default", "viewer") + if err != nil { + t.Fatal(err) + } + cfg := config.Config{AuthMode: "local"} + server := NewServer(cfg, authOverrideStore{Store: st, auth: memberAuth}, slog.New(slog.NewTextHandler(io.Discard, nil))) + cookie := localSessionCookie(t, server.Handler()) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+allowedProject.ID+"/tasks", nil) + req.AddCookie(cookie) + resp := httptest.NewRecorder() + server.Handler().ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("allowed project status = %d, body = %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), allowedTask.ID) { + t.Fatalf("allowed task missing: %s", resp.Body.String()) + } + + assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+deniedProject.ID, nil, cookie) + assertListDoesNotContain(t, server, "/api/v1/projects", deniedProject.ID, cookie) +} + +func TestAPIProjectAdminCannotCreateOrganizationProject(t *testing.T) { + st := store.NewMemoryStore() + member, err := st.UpsertUser(domain.User{Email: "project-admin@example.com", DisplayName: "Project Admin"}, "org_default", "viewer") + if err != nil { + t.Fatal(err) + } + project := st.CreateProject(domain.Project{OrgID: "org_default", Name: "Scoped Project", Slug: "scoped-project"}) + if _, err := st.UpsertProjectMembership(domain.ProjectMembership{ProjectID: project.ID, UserID: member.User.ID, Role: "project_admin"}); err != nil { + t.Fatal(err) + } + memberAuth, err := st.UpsertUser(domain.User{Email: "project-admin@example.com", DisplayName: "Project Admin"}, "org_default", "viewer") + if err != nil { + t.Fatal(err) + } + server := NewServer(config.Config{AuthMode: "local"}, authOverrideStore{Store: st, auth: memberAuth}, slog.New(slog.NewTextHandler(io.Discard, nil))) + cookie := localSessionCookie(t, server.Handler()) + + assertForbidden(t, server, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Should Not Exist","slug":"should-not-exist"}`), cookie) +} + +func TestAdminUsersAndProjectMembersAPI(t *testing.T) { + st := store.NewMemoryStore() + server := NewServer(config.Config{AuthMode: "local"}, st, slog.New(slog.NewTextHandler(io.Discard, nil))) + + var auth domain.AuthContext + apiDo(t, server.Handler(), http.MethodPost, "/api/v1/users", map[string]any{ + "email": "admin-target@example.com", + "display_name": "Admin Target", + "role": "viewer", + }, http.StatusCreated, &auth) + if auth.User.ID == "" || auth.Membership.Role != "viewer" { + t.Fatalf("created auth = %#v", auth) + } + + project := st.CreateProject(domain.Project{OrgID: "org_default", Name: "Enterprise Project", Slug: "enterprise-project"}) + var membership domain.ProjectMembership + apiDo(t, server.Handler(), http.MethodPost, "/api/v1/projects/"+project.ID+"/members", map[string]any{ + "user_id": auth.User.ID, + "role": "maintainer", + }, http.StatusCreated, &membership) + if membership.ProjectID != project.ID || membership.UserID != auth.User.ID || membership.Role != "maintainer" { + t.Fatalf("membership = %#v", membership) + } +} + +type authOverrideStore struct { + store.Store + auth domain.AuthContext +} + +func (s authOverrideStore) GetAuthContext() domain.AuthContext { + return s.auth +} + +func (s authOverrideStore) GetUserByEmail(email string) (domain.AuthContext, string, error) { + _, passwordHash, err := s.Store.GetUserByEmail(email) + if err != nil { + return domain.AuthContext{}, "", err + } + return s.auth, passwordHash, nil +} + +func assertForbidden(t *testing.T, server *Server, method string, path string, body []byte, cookie *http.Cookie) { t.Helper() var reader io.Reader if body != nil { reader = bytes.NewReader(body) } req := httptest.NewRequest(method, path, reader) + if cookie != nil { + req.AddCookie(cookie) + } if body != nil { req.Header.Set("Content-Type", "application/json") } @@ -150,9 +263,12 @@ func assertForbidden(t *testing.T, server *Server, method string, path string, b } } -func assertListDoesNotContain(t *testing.T, server *Server, path string, forbiddenID string) { +func assertListDoesNotContain(t *testing.T, server *Server, path string, forbiddenID string, cookie *http.Cookie) { t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) + if cookie != nil { + req.AddCookie(cookie) + } resp := httptest.NewRecorder() server.Handler().ServeHTTP(resp, req) if resp.Code != http.StatusOK { diff --git a/internal/api/router.go b/internal/api/router.go index 308d623..cfed829 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -100,6 +100,8 @@ func (s *Server) routeAPI(w http.ResponseWriter, r *http.Request) { s.authLogout(w, r) case len(parts) == 3 && parts[0] == "auth" && parts[1] == "backchannel" && parts[2] == "logout": s.authBackchannelLogout(w, r) + case len(parts) == 1 && parts[0] == "users": + s.users(w, r) case len(parts) == 1 && parts[0] == "organizations": s.organizations(w, r) case len(parts) == 1 && parts[0] == "projects": @@ -110,6 +112,8 @@ func (s *Server) routeAPI(w http.ResponseWriter, r *http.Request) { s.queueDispatch(w, r) case len(parts) == 2 && parts[0] == "projects": s.project(w, r, parts[1]) + case len(parts) == 3 && parts[0] == "projects" && parts[2] == "members": + s.projectMembers(w, r, parts[1]) case len(parts) == 3 && parts[0] == "projects" && parts[2] == "repositories": s.repositories(w, r, parts[1]) case len(parts) == 3 && parts[0] == "projects" && parts[2] == "agent-profiles": @@ -592,6 +596,7 @@ func (s *Server) authCapabilities(w http.ResponseWriter, r *http.Request) { "oidc_configured": oidcConfigured, "session_ttl_seconds": int64(s.cfg.AuthSessionTTL.Seconds()), "default_role": s.cfg.OIDCDefaultRole, + "local_admin_email": localAdminEmail(s.cfg.LocalAdminEmail), }) } @@ -769,7 +774,30 @@ func (s *Server) authSession(w http.ResponseWriter, r *http.Request) { return } if !strings.EqualFold(s.cfg.AuthMode, "oidc") { - auth := s.store.GetAuthContext() + var req struct { + Email string `json:"email"` + Password string `json:"password"` + } + if !decodeJSON(w, r, &req) { + return + } + auth, passwordHash, err := s.store.GetUserByEmail(strings.TrimSpace(req.Email)) + if err != nil || !authn.VerifyPassword(passwordHash, req.Password) { + s.audit("anonymous", "anonymous", "api.auth_denied", "auth_session", "local", map[string]any{ + "method": r.Method, + "path": r.URL.Path, + "mode": s.cfg.AuthMode, + "email": strings.TrimSpace(req.Email), + "error": "invalid local credentials", + "trace_id": observability.TraceID(r.Context()), + }) + writeError(w, http.StatusUnauthorized, "email or password is incorrect") + return + } + if _, err := s.createAuthSession(w, r, auth, "local", auth.User.ID, "local"); err != nil { + writeError(w, http.StatusInternalServerError, "session persistence failed") + return + } writeJSON(w, http.StatusOK, auth) return } @@ -953,6 +981,82 @@ func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out", "mode": s.cfg.AuthMode}) } +func (s *Server) users(w http.ResponseWriter, r *http.Request) { + auth, err := s.requestAuth(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + switch r.Method { + case http.MethodGet: + users := s.store.ListUsers(auth.Membership.OrgID) + memberships := s.store.ListMemberships(auth.Membership.OrgID) + projectMemberships := []domain.ProjectMembership{} + for _, user := range users { + projectMemberships = append(projectMemberships, s.store.ListProjectMembershipsForUser(user.ID)...) + } + writeJSON(w, http.StatusOK, map[string]any{ + "users": users, + "memberships": memberships, + "project_memberships": projectMemberships, + }) + case http.MethodPost: + var req struct { + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Password string `json:"password"` + ExternalProvider string `json:"external_provider"` + ExternalSubject string `json:"external_subject"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.Email == "" { + writeError(w, http.StatusBadRequest, "email is required") + return + } + created, err := s.store.UpsertUser(domain.User{ + Email: req.Email, + DisplayName: req.DisplayName, + ExternalProvider: req.ExternalProvider, + ExternalSubject: req.ExternalSubject, + }, auth.Membership.OrgID, req.Role) + if err != nil { + if errors.Is(err, store.ErrInvalidID) { + writeError(w, http.StatusBadRequest, "org_id must be a UUID") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + passwordUpdated := strings.TrimSpace(req.Password) != "" + if passwordUpdated { + passwordHash, err := authn.HashPassword(req.Password) + if err != nil { + if errors.Is(err, authn.ErrPasswordTooShort) { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "password hashing failed") + return + } + if err := s.store.SetUserPassword(created.User.ID, passwordHash); err != nil { + if errors.Is(err, store.ErrInvalidID) || errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusBadRequest, "user_id is invalid") + return + } + writeError(w, http.StatusInternalServerError, "password update failed") + return + } + } + s.auditHuman(r, "api.user_upsert", "user", created.User.ID, map[string]any{"email": created.User.Email, "role": created.Membership.Role, "password_updated": passwordUpdated}) + writeJSON(w, http.StatusCreated, created) + default: + methodNotAllowed(w) + } +} + func (s *Server) organizations(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -1003,6 +1107,10 @@ func (s *Server) projects(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "authentication required") return } + if !canAdminOrg(auth, auth.Membership.OrgID) { + s.denyResource(w, r, auth, "api.project_create", "organization", auth.Membership.OrgID, auth.Membership.OrgID) + return + } project := s.store.CreateProject(domain.Project{OrgID: auth.Membership.OrgID, Name: req.Name, Slug: req.Slug, Description: req.Description}) s.auditHuman(r, "api.project_create", "project", project.ID, map[string]any{"slug": project.Slug}) writeJSON(w, http.StatusCreated, project) @@ -1023,6 +1131,45 @@ func (s *Server) project(w http.ResponseWriter, r *http.Request, projectID strin writeJSON(w, http.StatusOK, project) } +func (s *Server) projectMembers(w http.ResponseWriter, r *http.Request, projectID string) { + if _, ok := s.authorizeProject(w, r, projectID, "api.project_members_access"); !ok { + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, s.store.ListProjectMemberships(projectID)) + case http.MethodPost: + var req struct { + UserID string `json:"user_id"` + Role string `json:"role"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.UserID == "" { + writeError(w, http.StatusBadRequest, "user_id is required") + return + } + membership, err := s.store.UpsertProjectMembership(domain.ProjectMembership{ + ProjectID: projectID, + UserID: req.UserID, + Role: req.Role, + }) + if err != nil { + if errors.Is(err, store.ErrInvalidID) { + writeError(w, http.StatusBadRequest, "project_id and user_id must be UUIDs") + return + } + notFound(w, err) + return + } + s.auditHuman(r, "api.project_member_upsert", "project", projectID, map[string]any{"user_id": req.UserID, "role": membership.Role}) + writeJSON(w, http.StatusCreated, membership) + default: + methodNotAllowed(w) + } +} + func (s *Server) repositories(w http.ResponseWriter, r *http.Request, projectID string) { if _, ok := s.authorizeProject(w, r, projectID, "api.repositories_access"); !ok { return @@ -2029,9 +2176,15 @@ func requiredPermission(method string, path string) string { return "" } if method == http.MethodGet { + if strings.Contains(path, "/users") { + return "users:read" + } if strings.Contains(path, "/organizations") { return "organizations:read" } + if strings.Contains(path, "/members") { + return "projects:read" + } if strings.Contains(path, "/audit-logs") || strings.Contains(path, "/tool-calls") { return "audit:read" } @@ -2046,9 +2199,15 @@ func requiredPermission(method string, path string) string { } return "projects:read" } + if strings.Contains(path, "/users") { + return "users:write" + } if strings.Contains(path, "/organizations") { return "organizations:write" } + if strings.Contains(path, "/members") { + return "projects:write" + } if strings.Contains(path, "/executor-nodes") { return "nodes:write" } @@ -2096,6 +2255,25 @@ func canAccessOrg(auth domain.AuthContext, orgID string) bool { return orgID == "" || auth.Membership.OrgID == "" || auth.Membership.OrgID == orgID } +func canAdminOrg(auth domain.AuthContext, orgID string) bool { + return canAccessOrg(auth, orgID) && hasPermission(auth.Permissions, "*") +} + +func canAccessProject(auth domain.AuthContext, project domain.Project) bool { + if canAdminOrg(auth, project.OrgID) { + return true + } + if !canAccessOrg(auth, project.OrgID) { + return false + } + for _, membership := range auth.ProjectMemberships { + if membership.ProjectID == project.ID { + return true + } + } + return false +} + func (s *Server) denyResource(w http.ResponseWriter, r *http.Request, auth domain.AuthContext, action string, resourceType string, resourceID string, resourceOrgID string) { actorID := auth.User.ID if actorID == "" { @@ -2121,7 +2299,7 @@ func (s *Server) authorizeProject(w http.ResponseWriter, r *http.Request, projec writeError(w, http.StatusUnauthorized, "authentication required") return domain.Project{}, false } - if !canAccessOrg(auth, project.OrgID) { + if !canAccessProject(auth, project) { s.denyResource(w, r, auth, action, "project", project.ID, project.OrgID) return domain.Project{}, false } @@ -2227,7 +2405,7 @@ func (s *Server) filterProjectsForAuth(r *http.Request, projects []domain.Projec } filtered := []domain.Project{} for _, project := range projects { - if canAccessOrg(auth, project.OrgID) { + if canAccessProject(auth, project) { filtered = append(filtered, project) } } @@ -2274,7 +2452,7 @@ func (s *Server) filterRunsForAuth(r *http.Request, runs []domain.Run) []domain. continue } auth, err := s.requestAuth(r) - if err != nil || !canAccessOrg(auth, project.OrgID) { + if err != nil || !canAccessProject(auth, project) { continue } filtered = append(filtered, run) @@ -2294,7 +2472,7 @@ func (s *Server) filterApprovalsForAuth(r *http.Request, approvals []domain.Appr continue } auth, err := s.requestAuth(r) - if err != nil || !canAccessOrg(auth, project.OrgID) { + if err != nil || !canAccessProject(auth, project) { continue } filtered = append(filtered, approval) @@ -2317,18 +2495,18 @@ func (s *Server) filterAuditLogsForAuth(r *http.Request, logs []domain.AuditLog) } func (s *Server) authContext(r *http.Request) (domain.AuthContext, error) { - if !strings.EqualFold(s.cfg.AuthMode, "oidc") { - return s.store.GetAuthContext(), nil - } - if s.oidc == nil { - return domain.AuthContext{}, fmt.Errorf("OIDC verifier is not configured") - } if cookie, err := r.Cookie(authSessionCookieName); err == nil && strings.TrimSpace(cookie.Value) != "" { auth, _, err := s.store.GetAuthSession(authn.TokenHash(cookie.Value), time.Now().UTC()) if err == nil { return auth, nil } } + if !strings.EqualFold(s.cfg.AuthMode, "oidc") { + return domain.AuthContext{}, authn.ErrMissingBearer + } + if s.oidc == nil { + return domain.AuthContext{}, fmt.Errorf("OIDC verifier is not configured") + } token, ok := authn.BearerToken(r.Header.Get("Authorization")) if !ok { return domain.AuthContext{}, authn.ErrMissingBearer @@ -2374,28 +2552,44 @@ func (s *Server) authContextFromClaims(r *http.Request, claims authn.Claims) (do } func (s *Server) createOIDCSession(w http.ResponseWriter, r *http.Request, auth domain.AuthContext, claims authn.Claims, source string) (domain.AuthSession, error) { - sessionToken, err := newOpaqueToken() - if err != nil { - return domain.AuthSession{}, err - } expiresAt := time.Now().UTC().Add(s.cfg.AuthSessionTTL) if !claims.ExpiresAt.IsZero() && claims.ExpiresAt.Before(expiresAt) { expiresAt = claims.ExpiresAt } - session, err := s.store.CreateAuthSessionWithExternalID(authn.TokenHash(sessionToken), auth, "oidc", claims.Subject, claims.SessionID, expiresAt) - if err != nil { - return domain.AuthSession{}, err - } - http.SetCookie(w, s.authSessionCookie(sessionToken, expiresAt)) - s.audit("human", auth.User.ID, "api.auth_session_create", "auth_session", session.ID, map[string]any{ + session, err := s.createAuthSessionWithExternalID(w, r, auth, "oidc", claims.Subject, claims.SessionID, expiresAt, map[string]any{ "subject": claims.Subject, "issuer": claims.Issuer, "source": source, - "expires_at": expiresAt.Format(time.RFC3339Nano), - "session_hash_prefix": hashPrefix(session.TokenHash), "external_session_id_present": claims.SessionID != "", - "trace_id": observability.TraceID(r.Context()), }) + return session, err +} + +func (s *Server) createAuthSession(w http.ResponseWriter, r *http.Request, auth domain.AuthContext, provider string, subject string, source string) (domain.AuthSession, error) { + return s.createAuthSessionWithExternalID(w, r, auth, provider, subject, "", time.Now().UTC().Add(s.cfg.AuthSessionTTL), map[string]any{ + "subject": subject, + "source": source, + }) +} + +func (s *Server) createAuthSessionWithExternalID(w http.ResponseWriter, r *http.Request, auth domain.AuthContext, provider string, subject string, externalSessionID string, expiresAt time.Time, payload map[string]any) (domain.AuthSession, error) { + sessionToken, err := newOpaqueToken() + if err != nil { + return domain.AuthSession{}, err + } + session, err := s.store.CreateAuthSessionWithExternalID(authn.TokenHash(sessionToken), auth, provider, subject, externalSessionID, expiresAt) + if err != nil { + return domain.AuthSession{}, err + } + http.SetCookie(w, s.authSessionCookie(sessionToken, expiresAt)) + if payload == nil { + payload = map[string]any{} + } + payload["provider"] = provider + payload["expires_at"] = expiresAt.Format(time.RFC3339Nano) + payload["session_hash_prefix"] = hashPrefix(session.TokenHash) + payload["trace_id"] = observability.TraceID(r.Context()) + s.audit("human", auth.User.ID, "api.auth_session_create", "auth_session", session.ID, payload) return session, nil } @@ -2445,6 +2639,14 @@ func authEndpointDescriptor(rawURL string) map[string]any { } } +func localAdminEmail(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "local-dev@multi-codex.invalid" + } + return value +} + func readLogoutToken(r *http.Request) (string, error) { contentType := r.Header.Get("Content-Type") if strings.Contains(contentType, "application/json") { diff --git a/internal/api/run_events_stream_test.go b/internal/api/run_events_stream_test.go index 49b9f52..c48cdf6 100644 --- a/internal/api/run_events_stream_test.go +++ b/internal/api/run_events_stream_test.go @@ -43,8 +43,14 @@ func TestRunEventStreamSendsNewEventsAndAudits(t *testing.T) { server := NewServer(config.Config{QueueEnabled: false}, st, slog.New(slog.NewTextHandler(io.Discard, nil))) testServer := httptest.NewServer(server.Handler()) defer testServer.Close() + cookie := localSessionCookie(t, server.Handler()) - resp, err := testServer.Client().Get(testServer.URL + "/api/v1/runs/" + run.ID + "/events/stream") + req, err := http.NewRequest(http.MethodGet, testServer.URL+"/api/v1/runs/"+run.ID+"/events/stream", nil) + if err != nil { + t.Fatal(err) + } + req.AddCookie(cookie) + resp, err := testServer.Client().Do(req) if err != nil { t.Fatal(err) } diff --git a/internal/api/skills_test.go b/internal/api/skills_test.go index 68d1ac6..9b3cbd7 100644 --- a/internal/api/skills_test.go +++ b/internal/api/skills_test.go @@ -25,6 +25,7 @@ func TestSkillVersionHistoryAPI(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, "/api/v1/skills/"+latest.ID+"/versions", nil) + req.AddCookie(localSessionCookie(t, server.Handler())) resp := httptest.NewRecorder() server.Handler().ServeHTTP(resp, req) if resp.Code != http.StatusOK { @@ -49,6 +50,7 @@ func TestSkillVersionHistoryAPI(t *testing.T) { func createSkillVersion(t *testing.T, server *Server, body string) domain.Skill { t.Helper() req := httptest.NewRequest(http.MethodPost, "/api/v1/skills", bytes.NewBufferString(body)) + req.AddCookie(localSessionCookie(t, server.Handler())) req.Header.Set("Content-Type", "application/json") resp := httptest.NewRecorder() server.Handler().ServeHTTP(resp, req) diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..e871ff5 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,31 @@ +package auth + +import ( + "errors" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +const MinPasswordLength = 6 + +var ErrPasswordTooShort = errors.New("password must be at least 6 characters") + +func HashPassword(password string) (string, error) { + password = strings.TrimSpace(password) + if len(password) < MinPasswordLength { + return "", ErrPasswordTooShort + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func VerifyPassword(hash string, password string) bool { + if strings.TrimSpace(hash) == "" || strings.TrimSpace(password) == "" { + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/internal/config/config.go b/internal/config/config.go index e154bdc..3e10d09 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -55,6 +55,8 @@ type Config struct { AuthSessionTTL time.Duration AuthCookieSecure bool AuthLoginStateTTL time.Duration + LocalAdminEmail string + LocalAdminPassword string OIDCIssuer string OIDCAudience string OIDCJWKSURL string @@ -145,6 +147,8 @@ func FromEnv() Config { AuthSessionTTL: envDuration("MULTICODEX_AUTH_SESSION_TTL", 12*time.Hour), AuthCookieSecure: envBool("MULTICODEX_AUTH_COOKIE_SECURE", false), AuthLoginStateTTL: envDuration("MULTICODEX_AUTH_LOGIN_STATE_TTL", 10*time.Minute), + LocalAdminEmail: env("MULTICODEX_LOCAL_ADMIN_EMAIL", "local-dev@multi-codex.invalid"), + LocalAdminPassword: env("MULTICODEX_LOCAL_ADMIN_PASSWORD", "admin123"), OIDCIssuer: env("MULTICODEX_OIDC_ISSUER", ""), OIDCAudience: env("MULTICODEX_OIDC_AUDIENCE", ""), OIDCJWKSURL: env("MULTICODEX_OIDC_JWKS_URL", ""), diff --git a/internal/db/migrations/000010_project_memberships.sql b/internal/db/migrations/000010_project_memberships.sql new file mode 100644 index 0000000..30661c7 --- /dev/null +++ b/internal/db/migrations/000010_project_memberships.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS project_memberships ( + project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_memberships_user + ON project_memberships(user_id, project_id); diff --git a/internal/db/migrations/000011_user_password_credentials.sql b/internal/db/migrations/000011_user_password_credentials.sql new file mode 100644 index 0000000..1dac8c1 --- /dev/null +++ b/internal/db/migrations/000011_user_password_credentials.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS user_password_credentials ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + password_hash text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_user_password_credentials_updated + ON user_password_credentials(updated_at); diff --git a/internal/domain/types.go b/internal/domain/types.go index 5c379e6..9d838d5 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -35,10 +35,12 @@ type Repository struct { } type User struct { - ID string `json:"id"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + ExternalProvider string `json:"external_provider,omitempty"` + ExternalSubject string `json:"external_subject,omitempty"` + CreatedAt time.Time `json:"created_at"` } type Membership struct { @@ -48,10 +50,22 @@ type Membership struct { CreatedAt time.Time `json:"created_at"` } +type ProjectMembership struct { + ProjectID string `json:"project_id"` + UserID string `json:"user_id"` + Role string `json:"role"` + ProjectName string `json:"project_name,omitempty"` + ProjectSlug string `json:"project_slug,omitempty"` + UserEmail string `json:"user_email,omitempty"` + UserName string `json:"user_name,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + type AuthContext struct { - User User `json:"user"` - Membership Membership `json:"membership"` - Permissions []string `json:"permissions"` + User User `json:"user"` + Membership Membership `json:"membership"` + ProjectMemberships []ProjectMembership `json:"project_memberships"` + Permissions []string `json:"permissions"` } type Skill struct { diff --git a/internal/store/factory.go b/internal/store/factory.go index 286c474..f57ca0a 100644 --- a/internal/store/factory.go +++ b/internal/store/factory.go @@ -6,6 +6,8 @@ import ( "log/slog" "time" + "github.com/Chiiz0/multi-codex/internal/config" + _ "github.com/jackc/pgx/v5/stdlib" ) @@ -15,9 +17,14 @@ type RuntimeStore struct { } func Open(ctx context.Context, databaseURL string, log *slog.Logger) (RuntimeStore, error) { + return OpenWithConfig(ctx, config.Config{DatabaseURL: databaseURL}, log) +} + +func OpenWithConfig(ctx context.Context, cfg config.Config, log *slog.Logger) (RuntimeStore, error) { + databaseURL := cfg.DatabaseURL if databaseURL == "" { log.Info("using in-memory store") - return RuntimeStore{Store: NewMemoryStore(), Close: func() error { return nil }}, nil + return RuntimeStore{Store: NewMemoryStoreWithSeed(cfg.LocalAdminEmail, cfg.LocalAdminPassword), Close: func() error { return nil }}, nil } db, err := sql.Open("pgx", databaseURL) @@ -36,7 +43,7 @@ func Open(ctx context.Context, databaseURL string, log *slog.Logger) (RuntimeSto } pg := NewPostgresStore(db, log, databaseURL) - if err := pg.EnsureSeed(ctx); err != nil { + if err := pg.EnsureSeedWithCredentials(ctx, cfg.LocalAdminEmail, cfg.LocalAdminPassword); err != nil { _ = db.Close() return RuntimeStore{}, err } diff --git a/internal/store/memory.go b/internal/store/memory.go index a7ace25..c2bde2e 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -3,9 +3,11 @@ package store import ( "errors" "sort" + "strings" "sync" "time" + authn "github.com/Chiiz0/multi-codex/internal/auth" "github.com/Chiiz0/multi-codex/internal/domain" ) @@ -14,68 +16,86 @@ var ErrNoCapacity = errors.New("no executor capacity available") var ErrConflict = errors.New("resource already exists") type MemoryStore struct { - mu sync.RWMutex - auth domain.AuthContext - externalUsers map[string]domain.AuthContext - revokedTokens map[string]domain.AuthTokenRevocation - authSessions map[string]domain.AuthSession - sessionAuth map[string]domain.AuthContext - loginStates map[string]domain.AuthLoginState - organizations map[string]domain.Organization - projects map[string]domain.Project - repositories map[string]domain.Repository - skills map[string]domain.Skill - skillVersions map[string][]domain.SkillVersion - profiles map[string]domain.AgentProfile - nodes map[string]domain.ExecutorNode - tasks map[string]domain.Task - runs map[string]domain.Run - events map[string][]domain.RunEvent - artifacts map[string]domain.Artifact - scopeChecks map[string][]domain.ScopeCheckRecord - approvals map[string]domain.Approval - toolCalls []domain.ToolCall - mcpSessions map[string]domain.MCPSession - mcpEvents map[string][]domain.MCPSessionEvent - auditLogs []domain.AuditLog - nextEventID int64 + mu sync.RWMutex + auth domain.AuthContext + externalUsers map[string]domain.AuthContext + revokedTokens map[string]domain.AuthTokenRevocation + authSessions map[string]domain.AuthSession + sessionAuth map[string]domain.AuthContext + loginStates map[string]domain.AuthLoginState + users map[string]domain.User + passwords map[string]string + memberships map[string]domain.Membership + projectMembers map[string]domain.ProjectMembership + organizations map[string]domain.Organization + projects map[string]domain.Project + repositories map[string]domain.Repository + skills map[string]domain.Skill + skillVersions map[string][]domain.SkillVersion + profiles map[string]domain.AgentProfile + nodes map[string]domain.ExecutorNode + tasks map[string]domain.Task + runs map[string]domain.Run + events map[string][]domain.RunEvent + artifacts map[string]domain.Artifact + scopeChecks map[string][]domain.ScopeCheckRecord + approvals map[string]domain.Approval + toolCalls []domain.ToolCall + mcpSessions map[string]domain.MCPSession + mcpEvents map[string][]domain.MCPSessionEvent + auditLogs []domain.AuditLog + nextEventID int64 } func NewMemoryStore() *MemoryStore { + return NewMemoryStoreWithSeed("", "") +} + +func NewMemoryStoreWithSeed(adminEmail string, adminPassword string) *MemoryStore { s := &MemoryStore{ - projects: map[string]domain.Project{}, - externalUsers: map[string]domain.AuthContext{}, - revokedTokens: map[string]domain.AuthTokenRevocation{}, - authSessions: map[string]domain.AuthSession{}, - sessionAuth: map[string]domain.AuthContext{}, - loginStates: map[string]domain.AuthLoginState{}, - organizations: map[string]domain.Organization{}, - repositories: map[string]domain.Repository{}, - skills: map[string]domain.Skill{}, - skillVersions: map[string][]domain.SkillVersion{}, - profiles: map[string]domain.AgentProfile{}, - nodes: map[string]domain.ExecutorNode{}, - tasks: map[string]domain.Task{}, - runs: map[string]domain.Run{}, - events: map[string][]domain.RunEvent{}, - artifacts: map[string]domain.Artifact{}, - scopeChecks: map[string][]domain.ScopeCheckRecord{}, - approvals: map[string]domain.Approval{}, - toolCalls: []domain.ToolCall{}, - mcpSessions: map[string]domain.MCPSession{}, - mcpEvents: map[string][]domain.MCPSessionEvent{}, - auditLogs: []domain.AuditLog{}, - nextEventID: 1, - } - s.seed() + projects: map[string]domain.Project{}, + externalUsers: map[string]domain.AuthContext{}, + revokedTokens: map[string]domain.AuthTokenRevocation{}, + authSessions: map[string]domain.AuthSession{}, + sessionAuth: map[string]domain.AuthContext{}, + loginStates: map[string]domain.AuthLoginState{}, + users: map[string]domain.User{}, + passwords: map[string]string{}, + memberships: map[string]domain.Membership{}, + projectMembers: map[string]domain.ProjectMembership{}, + organizations: map[string]domain.Organization{}, + repositories: map[string]domain.Repository{}, + skills: map[string]domain.Skill{}, + skillVersions: map[string][]domain.SkillVersion{}, + profiles: map[string]domain.AgentProfile{}, + nodes: map[string]domain.ExecutorNode{}, + tasks: map[string]domain.Task{}, + runs: map[string]domain.Run{}, + events: map[string][]domain.RunEvent{}, + artifacts: map[string]domain.Artifact{}, + scopeChecks: map[string][]domain.ScopeCheckRecord{}, + approvals: map[string]domain.Approval{}, + toolCalls: []domain.ToolCall{}, + mcpSessions: map[string]domain.MCPSession{}, + mcpEvents: map[string][]domain.MCPSessionEvent{}, + auditLogs: []domain.AuditLog{}, + nextEventID: 1, + } + s.seed(adminEmail, adminPassword) return s } -func (s *MemoryStore) seed() { +func (s *MemoryStore) seed(adminEmail string, adminPassword string) { now := time.Now().UTC() + if adminEmail == "" { + adminEmail = "local-dev@multi-codex.invalid" + } + if adminPassword == "" { + adminPassword = "admin123" + } user := domain.User{ ID: "user_local_dev", - Email: "local-dev@multi-codex.invalid", + Email: adminEmail, DisplayName: "Local Developer", CreatedAt: now, } @@ -86,9 +106,10 @@ func (s *MemoryStore) seed() { CreatedAt: now, } s.auth = domain.AuthContext{ - User: user, - Membership: membership, - Permissions: []string{"*"}, + User: user, + Membership: membership, + ProjectMemberships: []domain.ProjectMembership{}, + Permissions: []string{"*"}, } org := domain.Organization{ ID: membership.OrgID, @@ -114,7 +135,24 @@ func (s *MemoryStore) seed() { CreatedAt: now, } s.organizations[org.ID] = org + s.users[user.ID] = user + if passwordHash, err := authn.HashPassword(adminPassword); err == nil { + s.passwords[user.ID] = passwordHash + } + s.memberships[membershipKey(membership.OrgID, membership.UserID)] = membership s.projects[project.ID] = project + projectMembership := domain.ProjectMembership{ + ProjectID: project.ID, + UserID: user.ID, + Role: "owner", + ProjectName: project.Name, + ProjectSlug: project.Slug, + UserEmail: user.Email, + UserName: user.DisplayName, + CreatedAt: now, + } + s.projectMembers[projectMembershipKey(project.ID, user.ID)] = projectMembership + s.auth.ProjectMemberships = []domain.ProjectMembership{projectMembership} s.repositories[repo.ID] = repo for _, seed := range seedSkillVersions(now) { s.skills[seed.Skill.ID] = seed.Skill @@ -139,7 +177,10 @@ func (s *MemoryStore) GetAuthContext() domain.AuthContext { s.mu.RLock() defer s.mu.RUnlock() - return s.auth + auth := s.auth + auth.ProjectMemberships = s.projectMembershipsForUserLocked(auth.User.ID) + auth.Permissions = permissionsForAuth(auth.Membership.Role, auth.ProjectMemberships) + return auth } func (s *MemoryStore) UpsertExternalUser(provider string, subject string, email string, displayName string, role string, orgID string) (domain.AuthContext, error) { @@ -175,10 +216,12 @@ func (s *MemoryStore) UpsertExternalUser(provider string, subject string, email } auth := domain.AuthContext{ User: domain.User{ - ID: userID, - Email: email, - DisplayName: displayName, - CreatedAt: createdAt, + ID: userID, + Email: email, + DisplayName: displayName, + ExternalProvider: provider, + ExternalSubject: subject, + CreatedAt: createdAt, }, Membership: domain.Membership{ OrgID: orgID, @@ -186,9 +229,12 @@ func (s *MemoryStore) UpsertExternalUser(provider string, subject string, email Role: role, CreatedAt: now, }, - Permissions: PermissionsForRole(role), } + auth.ProjectMemberships = s.projectMembershipsForUserLocked(userID) + auth.Permissions = permissionsForAuth(role, auth.ProjectMemberships) s.externalUsers[key] = auth + s.users[userID] = auth.User + s.memberships[membershipKey(orgID, userID)] = auth.Membership return auth, nil } @@ -428,6 +474,137 @@ func (s *MemoryStore) CleanupAuthLoginStates(cutoff time.Time, dryRun bool) (dom return result, nil } +func (s *MemoryStore) ListUsers(orgID string) []domain.User { + s.mu.RLock() + defer s.mu.RUnlock() + + users := []domain.User{} + for _, membership := range s.memberships { + if orgID != "" && membership.OrgID != orgID { + continue + } + user, ok := s.users[membership.UserID] + if ok { + users = append(users, user) + } + } + sort.Slice(users, func(i, j int) bool { return users[i].Email < users[j].Email }) + return users +} + +func (s *MemoryStore) GetUserByEmail(email string) (domain.AuthContext, string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, user := range s.users { + if !strings.EqualFold(user.Email, strings.TrimSpace(email)) { + continue + } + var membership domain.Membership + for _, candidate := range s.memberships { + if candidate.UserID == user.ID { + membership = candidate + break + } + } + if membership.UserID == "" { + return domain.AuthContext{}, "", ErrNotFound + } + projectMemberships := s.projectMembershipsForUserLocked(user.ID) + return domain.AuthContext{ + User: user, + Membership: membership, + ProjectMemberships: projectMemberships, + Permissions: permissionsForAuth(membership.Role, projectMemberships), + }, s.passwords[user.ID], nil + } + return domain.AuthContext{}, "", ErrNotFound +} + +func (s *MemoryStore) SetUserPassword(userID string, passwordHash string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if userID == "" || passwordHash == "" { + return errors.New("user_id and password hash are required") + } + if _, ok := s.users[userID]; !ok { + return ErrNotFound + } + s.passwords[userID] = passwordHash + return nil +} + +func (s *MemoryStore) ListMemberships(orgID string) []domain.Membership { + s.mu.RLock() + defer s.mu.RUnlock() + + memberships := []domain.Membership{} + for _, membership := range s.memberships { + if orgID == "" || membership.OrgID == orgID { + memberships = append(memberships, membership) + } + } + sort.Slice(memberships, func(i, j int) bool { + if memberships[i].Role == memberships[j].Role { + return memberships[i].UserID < memberships[j].UserID + } + return memberships[i].Role < memberships[j].Role + }) + return memberships +} + +func (s *MemoryStore) UpsertUser(user domain.User, orgID string, role string) (domain.AuthContext, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if user.Email == "" { + return domain.AuthContext{}, errors.New("email is required") + } + if user.DisplayName == "" { + user.DisplayName = user.Email + } + if orgID == "" { + orgID = s.auth.Membership.OrgID + } + if role == "" { + role = "viewer" + } + now := time.Now().UTC() + for _, existing := range s.users { + if existing.Email == user.Email { + user.ID = existing.ID + user.CreatedAt = existing.CreatedAt + if user.ExternalProvider == "" { + user.ExternalProvider = existing.ExternalProvider + } + if user.ExternalSubject == "" { + user.ExternalSubject = existing.ExternalSubject + } + break + } + } + if user.ID == "" { + user.ID = domain.NewID("user") + user.CreatedAt = now + } + if user.CreatedAt.IsZero() { + user.CreatedAt = now + } + s.users[user.ID] = user + membership := domain.Membership{OrgID: orgID, UserID: user.ID, Role: role, CreatedAt: now} + if existing, ok := s.memberships[membershipKey(orgID, user.ID)]; ok { + membership.CreatedAt = existing.CreatedAt + } + s.memberships[membershipKey(orgID, user.ID)] = membership + return domain.AuthContext{ + User: user, + Membership: membership, + ProjectMemberships: s.projectMembershipsForUserLocked(user.ID), + Permissions: permissionsForAuth(role, s.projectMembershipsForUserLocked(user.ID)), + }, nil +} + func (s *MemoryStore) ListOrganizations() []domain.Organization { s.mu.RLock() defer s.mu.RUnlock() @@ -497,6 +674,55 @@ func (s *MemoryStore) GetProject(id string) (domain.Project, error) { return project, nil } +func (s *MemoryStore) ListProjectMemberships(projectID string) []domain.ProjectMembership { + s.mu.RLock() + defer s.mu.RUnlock() + + memberships := []domain.ProjectMembership{} + for _, membership := range s.projectMembers { + if projectID == "" || membership.ProjectID == projectID { + memberships = append(memberships, s.hydrateProjectMembershipLocked(membership)) + } + } + sort.Slice(memberships, func(i, j int) bool { return memberships[i].UserEmail < memberships[j].UserEmail }) + return memberships +} + +func (s *MemoryStore) ListProjectMembershipsForUser(userID string) []domain.ProjectMembership { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.projectMembershipsForUserLocked(userID) +} + +func (s *MemoryStore) UpsertProjectMembership(membership domain.ProjectMembership) (domain.ProjectMembership, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if membership.ProjectID == "" || membership.UserID == "" { + return domain.ProjectMembership{}, errors.New("project_id and user_id are required") + } + if _, ok := s.projects[membership.ProjectID]; !ok { + return domain.ProjectMembership{}, ErrNotFound + } + if _, ok := s.users[membership.UserID]; !ok { + return domain.ProjectMembership{}, ErrNotFound + } + if membership.Role == "" { + membership.Role = "viewer" + } + key := projectMembershipKey(membership.ProjectID, membership.UserID) + if existing, ok := s.projectMembers[key]; ok { + membership.CreatedAt = existing.CreatedAt + } + if membership.CreatedAt.IsZero() { + membership.CreatedAt = time.Now().UTC() + } + membership = s.hydrateProjectMembershipLocked(membership) + s.projectMembers[key] = membership + return membership, nil +} + func (s *MemoryStore) ListRepositories(projectID string) []domain.Repository { s.mu.RLock() defer s.mu.RUnlock() @@ -1420,3 +1646,34 @@ func (s *MemoryStore) appendEventLocked(runID string, level string, eventType st s.events[runID] = append(s.events[runID], event) return event } + +func (s *MemoryStore) projectMembershipsForUserLocked(userID string) []domain.ProjectMembership { + memberships := []domain.ProjectMembership{} + for _, membership := range s.projectMembers { + if membership.UserID == userID { + memberships = append(memberships, s.hydrateProjectMembershipLocked(membership)) + } + } + sort.Slice(memberships, func(i, j int) bool { return memberships[i].ProjectName < memberships[j].ProjectName }) + return memberships +} + +func (s *MemoryStore) hydrateProjectMembershipLocked(membership domain.ProjectMembership) domain.ProjectMembership { + if project, ok := s.projects[membership.ProjectID]; ok { + membership.ProjectName = project.Name + membership.ProjectSlug = project.Slug + } + if user, ok := s.users[membership.UserID]; ok { + membership.UserEmail = user.Email + membership.UserName = user.DisplayName + } + return membership +} + +func membershipKey(orgID string, userID string) string { + return orgID + ":" + userID +} + +func projectMembershipKey(projectID string, userID string) string { + return projectID + ":" + userID +} diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 11fa5c0..e9bbb6e 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -6,8 +6,10 @@ import ( "encoding/json" "errors" "log/slog" + "strings" "time" + authn "github.com/Chiiz0/multi-codex/internal/auth" "github.com/Chiiz0/multi-codex/internal/domain" ) @@ -26,6 +28,17 @@ func NewPostgresStore(db *sql.DB, log *slog.Logger, databaseURL ...string) *Post } func (s *PostgresStore) EnsureSeed(ctx context.Context) error { + return s.EnsureSeedWithCredentials(ctx, "", "") +} + +func (s *PostgresStore) EnsureSeedWithCredentials(ctx context.Context, adminEmail string, adminPassword string) error { + adminEmail = strings.TrimSpace(adminEmail) + if adminEmail == "" { + adminEmail = "local-dev@multi-codex.invalid" + } + if adminPassword == "" { + adminPassword = "admin123" + } if _, err := s.db.ExecContext(ctx, ` INSERT INTO organizations (id, name, slug) VALUES ($1::uuid, 'Default Organization', 'default') @@ -34,8 +47,18 @@ ON CONFLICT (slug) DO NOTHING`, defaultOrgID); err != nil { } if _, err := s.db.ExecContext(ctx, ` INSERT INTO users (id, email, display_name) -VALUES ($1::uuid, 'local-dev@multi-codex.invalid', 'Local Developer') -ON CONFLICT (email) DO NOTHING`, defaultUserID); err != nil { +VALUES ($1::uuid, $2, 'Local Developer') +ON CONFLICT (email) DO NOTHING`, defaultUserID, adminEmail); err != nil { + return err + } + passwordHash, err := authn.HashPassword(adminPassword) + if err != nil { + return err + } + if _, err := s.db.ExecContext(ctx, ` +INSERT INTO user_password_credentials (user_id, password_hash) +VALUES ($1::uuid, $2) +ON CONFLICT (user_id) DO NOTHING`, defaultUserID, passwordHash); err != nil { return err } if _, err := s.db.ExecContext(ctx, ` @@ -50,11 +73,16 @@ VALUES ($2::uuid, $1::uuid, 'Demo Engineering', 'demo-engineering', 'Seed projec ON CONFLICT (org_id, slug) DO NOTHING`, defaultOrgID, defaultProjectID); err != nil { return err } - _, err := s.db.ExecContext(ctx, ` + if _, err := s.db.ExecContext(ctx, ` +INSERT INTO project_memberships (project_id, user_id, role) +VALUES ($1::uuid, $2::uuid, 'owner') +ON CONFLICT (project_id, user_id) DO UPDATE SET role = EXCLUDED.role`, defaultProjectID, defaultUserID); err != nil { + return err + } + if _, err := s.db.ExecContext(ctx, ` INSERT INTO repositories (id, project_id, name, provider, remote_url, default_branch) VALUES ($1::uuid, $2::uuid, 'demo-service', 'local', 'file:///workspace/demo-service.git', 'main') -ON CONFLICT DO NOTHING`, defaultRepoID, defaultProjectID) - if err != nil { +ON CONFLICT DO NOTHING`, defaultRepoID, defaultProjectID); err != nil { return err } diff --git a/internal/store/postgres_resources.go b/internal/store/postgres_resources.go index 9532948..443381c 100644 --- a/internal/store/postgres_resources.go +++ b/internal/store/postgres_resources.go @@ -1,6 +1,7 @@ package store import ( + "context" "database/sql" "encoding/json" "errors" @@ -29,10 +30,195 @@ LIMIT 1`, defaultUserID).Scan( auth.User = domain.User{ID: defaultUserID, Email: "local-dev@multi-codex.invalid", DisplayName: "Local Developer", CreatedAt: now} auth.Membership = domain.Membership{OrgID: defaultOrgID, UserID: defaultUserID, Role: "owner", CreatedAt: now} } - auth.Permissions = permissionsForRole(auth.Membership.Role) + auth.ProjectMemberships = s.listProjectMembershipsForUser(ctx, auth.User.ID) + auth.Permissions = permissionsForAuth(auth.Membership.Role, auth.ProjectMemberships) return auth } +func (s *PostgresStore) ListUsers(orgID string) []domain.User { + if orgID != "" && !validUUIDText(orgID) { + return []domain.User{} + } + ctx, cancel := storeContext() + defer cancel() + + rows, err := s.db.QueryContext(ctx, ` +SELECT DISTINCT u.id::text, u.email, u.display_name, COALESCE(u.external_provider, ''), COALESCE(u.external_subject, ''), u.created_at +FROM users u +JOIN memberships m ON m.user_id = u.id +WHERE ($1 = '' OR m.org_id = $1::uuid) +ORDER BY u.email ASC`, orgID) + if err != nil { + s.log.Error("list users failed", "error", err) + return nil + } + defer rows.Close() + + users := []domain.User{} + for rows.Next() { + user, err := scanUser(rows) + if err != nil { + s.log.Error("scan user failed", "error", err) + return users + } + users = append(users, user) + } + return users +} + +func (s *PostgresStore) ListMemberships(orgID string) []domain.Membership { + if orgID != "" && !validUUIDText(orgID) { + return []domain.Membership{} + } + ctx, cancel := storeContext() + defer cancel() + + rows, err := s.db.QueryContext(ctx, ` +SELECT org_id::text, user_id::text, role, created_at +FROM memberships +WHERE ($1 = '' OR org_id = $1::uuid) +ORDER BY created_at ASC`, orgID) + if err != nil { + s.log.Error("list memberships failed", "error", err) + return nil + } + defer rows.Close() + + memberships := []domain.Membership{} + for rows.Next() { + var membership domain.Membership + if err := rows.Scan(&membership.OrgID, &membership.UserID, &membership.Role, &membership.CreatedAt); err != nil { + s.log.Error("scan membership failed", "error", err) + return memberships + } + memberships = append(memberships, membership) + } + return memberships +} + +func (s *PostgresStore) GetUserByEmail(email string) (domain.AuthContext, string, error) { + ctx, cancel := storeContext() + defer cancel() + + var auth domain.AuthContext + var passwordHash string + err := s.db.QueryRowContext(ctx, ` +SELECT u.id::text, u.email, u.display_name, COALESCE(u.external_provider, ''), COALESCE(u.external_subject, ''), u.created_at, + m.org_id::text, m.user_id::text, m.role, m.created_at, + COALESCE(c.password_hash, '') +FROM users u +JOIN memberships m ON m.user_id = u.id +LEFT JOIN user_password_credentials c ON c.user_id = u.id +WHERE lower(u.email) = lower($1) +ORDER BY m.created_at ASC +LIMIT 1`, email).Scan( + &auth.User.ID, &auth.User.Email, &auth.User.DisplayName, &auth.User.ExternalProvider, &auth.User.ExternalSubject, &auth.User.CreatedAt, + &auth.Membership.OrgID, &auth.Membership.UserID, &auth.Membership.Role, &auth.Membership.CreatedAt, + &passwordHash, + ) + if errors.Is(err, sql.ErrNoRows) { + return domain.AuthContext{}, "", ErrNotFound + } + if err != nil { + return domain.AuthContext{}, "", err + } + auth.ProjectMemberships = s.listProjectMembershipsForUser(ctx, auth.User.ID) + auth.Permissions = permissionsForAuth(auth.Membership.Role, auth.ProjectMemberships) + return auth, passwordHash, nil +} + +func (s *PostgresStore) SetUserPassword(userID string, passwordHash string) error { + if !validUUIDText(userID) { + return ErrInvalidID + } + ctx, cancel := storeContext() + defer cancel() + + result, err := s.db.ExecContext(ctx, ` +INSERT INTO user_password_credentials (user_id, password_hash, updated_at) +VALUES ($1::uuid, $2, now()) +ON CONFLICT (user_id) DO UPDATE +SET password_hash = EXCLUDED.password_hash, + updated_at = now()`, userID, passwordHash) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return ErrNotFound + } + return nil +} + +func (s *PostgresStore) UpsertUser(user domain.User, orgID string, role string) (domain.AuthContext, error) { + ctx, cancel := storeContext() + defer cancel() + + if user.Email == "" { + return domain.AuthContext{}, errors.New("email is required") + } + if user.DisplayName == "" { + user.DisplayName = user.Email + } + if user.ExternalProvider == "" { + user.ExternalProvider = "local" + } + if role == "" { + role = "viewer" + } + if orgID == "" { + orgID = defaultOrgID + } + if !validUUIDText(orgID) { + return domain.AuthContext{}, ErrInvalidID + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.AuthContext{}, err + } + defer tx.Rollback() + + var auth domain.AuthContext + if user.ExternalSubject != "" { + err = tx.QueryRowContext(ctx, ` +INSERT INTO users (email, display_name, external_provider, external_subject) +VALUES ($1, $2, $3, $4) +ON CONFLICT (external_provider, external_subject) WHERE external_subject IS NOT NULL +DO UPDATE SET email = EXCLUDED.email, + display_name = EXCLUDED.display_name +RETURNING id::text, email, display_name, COALESCE(external_provider, ''), COALESCE(external_subject, ''), created_at`, + user.Email, user.DisplayName, user.ExternalProvider, user.ExternalSubject, + ).Scan(&auth.User.ID, &auth.User.Email, &auth.User.DisplayName, &auth.User.ExternalProvider, &auth.User.ExternalSubject, &auth.User.CreatedAt) + } else { + err = tx.QueryRowContext(ctx, ` +INSERT INTO users (email, display_name, external_provider) +VALUES ($1, $2, $3) +ON CONFLICT (email) DO UPDATE SET display_name = EXCLUDED.display_name +RETURNING id::text, email, display_name, COALESCE(external_provider, ''), COALESCE(external_subject, ''), created_at`, + user.Email, user.DisplayName, user.ExternalProvider, + ).Scan(&auth.User.ID, &auth.User.Email, &auth.User.DisplayName, &auth.User.ExternalProvider, &auth.User.ExternalSubject, &auth.User.CreatedAt) + } + if err != nil { + return domain.AuthContext{}, err + } + + if err := tx.QueryRowContext(ctx, ` +INSERT INTO memberships (org_id, user_id, role) +VALUES ($1::uuid, $2::uuid, $3) +ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role +RETURNING org_id::text, user_id::text, role, created_at`, + orgID, auth.User.ID, role, + ).Scan(&auth.Membership.OrgID, &auth.Membership.UserID, &auth.Membership.Role, &auth.Membership.CreatedAt); err != nil { + return domain.AuthContext{}, err + } + if err := tx.Commit(); err != nil { + return domain.AuthContext{}, err + } + auth.ProjectMemberships = s.listProjectMembershipsForUser(ctx, auth.User.ID) + auth.Permissions = permissionsForAuth(auth.Membership.Role, auth.ProjectMemberships) + return auth, nil +} + func (s *PostgresStore) ListOrganizations() []domain.Organization { ctx, cancel := storeContext() defer cancel() @@ -112,9 +298,9 @@ VALUES ($1, $2, $3, $4) ON CONFLICT (external_provider, external_subject) WHERE external_subject IS NOT NULL DO UPDATE SET email = EXCLUDED.email, display_name = EXCLUDED.display_name -RETURNING id::text, email, display_name, created_at`, +RETURNING id::text, email, display_name, COALESCE(external_provider, ''), COALESCE(external_subject, ''), created_at`, email, displayName, provider, subject, - ).Scan(&auth.User.ID, &auth.User.Email, &auth.User.DisplayName, &auth.User.CreatedAt); err != nil { + ).Scan(&auth.User.ID, &auth.User.Email, &auth.User.DisplayName, &auth.User.ExternalProvider, &auth.User.ExternalSubject, &auth.User.CreatedAt); err != nil { return domain.AuthContext{}, err } @@ -130,10 +316,112 @@ RETURNING org_id::text, user_id::text, role, created_at`, if err := tx.Commit(); err != nil { return domain.AuthContext{}, err } - auth.Permissions = PermissionsForRole(auth.Membership.Role) + auth.ProjectMemberships = s.listProjectMembershipsForUser(ctx, auth.User.ID) + auth.Permissions = permissionsForAuth(auth.Membership.Role, auth.ProjectMemberships) return auth, nil } +func (s *PostgresStore) ListProjectMemberships(projectID string) []domain.ProjectMembership { + if projectID != "" && !validUUIDText(projectID) { + return []domain.ProjectMembership{} + } + ctx, cancel := storeContext() + defer cancel() + + rows, err := s.db.QueryContext(ctx, ` +SELECT pm.project_id::text, pm.user_id::text, pm.role, p.name, p.slug, u.email, u.display_name, pm.created_at +FROM project_memberships pm +JOIN projects p ON p.id = pm.project_id +JOIN users u ON u.id = pm.user_id +WHERE ($1 = '' OR pm.project_id = $1::uuid) +ORDER BY p.name ASC, u.email ASC`, projectID) + if err != nil { + s.log.Error("list project memberships failed", "error", err) + return nil + } + defer rows.Close() + + memberships := []domain.ProjectMembership{} + for rows.Next() { + membership, err := scanProjectMembership(rows) + if err != nil { + s.log.Error("scan project membership failed", "error", err) + return memberships + } + memberships = append(memberships, membership) + } + return memberships +} + +func (s *PostgresStore) ListProjectMembershipsForUser(userID string) []domain.ProjectMembership { + if userID != "" && !validUUIDText(userID) { + return []domain.ProjectMembership{} + } + ctx, cancel := storeContext() + defer cancel() + return s.listProjectMembershipsForUser(ctx, userID) +} + +func (s *PostgresStore) UpsertProjectMembership(membership domain.ProjectMembership) (domain.ProjectMembership, error) { + if !validUUIDText(membership.ProjectID) || !validUUIDText(membership.UserID) { + return domain.ProjectMembership{}, ErrInvalidID + } + if membership.Role == "" { + membership.Role = "viewer" + } + ctx, cancel := storeContext() + defer cancel() + + row := s.db.QueryRowContext(ctx, ` +WITH upserted AS ( + INSERT INTO project_memberships (project_id, user_id, role) + VALUES ($1::uuid, $2::uuid, $3) + ON CONFLICT (project_id, user_id) DO UPDATE SET role = EXCLUDED.role + RETURNING project_id, user_id, role, created_at +) +SELECT upserted.project_id::text, upserted.user_id::text, upserted.role, + p.name, p.slug, u.email, u.display_name, upserted.created_at +FROM upserted +JOIN projects p ON p.id = upserted.project_id +JOIN users u ON u.id = upserted.user_id`, + membership.ProjectID, membership.UserID, membership.Role, + ) + membership, err := scanProjectMembership(row) + if errors.Is(err, sql.ErrNoRows) { + return domain.ProjectMembership{}, ErrNotFound + } + return membership, err +} + +func (s *PostgresStore) listProjectMembershipsForUser(ctx context.Context, userID string) []domain.ProjectMembership { + if userID == "" || !validUUIDText(userID) { + return []domain.ProjectMembership{} + } + rows, err := s.db.QueryContext(ctx, ` +SELECT pm.project_id::text, pm.user_id::text, pm.role, p.name, p.slug, u.email, u.display_name, pm.created_at +FROM project_memberships pm +JOIN projects p ON p.id = pm.project_id +JOIN users u ON u.id = pm.user_id +WHERE pm.user_id = $1::uuid +ORDER BY p.name ASC`, userID) + if err != nil { + s.log.Error("list user project memberships failed", "error", err) + return nil + } + defer rows.Close() + + memberships := []domain.ProjectMembership{} + for rows.Next() { + membership, err := scanProjectMembership(rows) + if err != nil { + s.log.Error("scan user project membership failed", "error", err) + return memberships + } + memberships = append(memberships, membership) + } + return memberships +} + func (s *PostgresStore) RevokeAuthToken(revocation domain.AuthTokenRevocation) (domain.AuthTokenRevocation, error) { ctx, cancel := storeContext() defer cancel() @@ -261,7 +549,8 @@ LIMIT 1`, tokenHash, now.UTC()).Scan( return domain.AuthContext{}, domain.AuthSession{}, err } session.LastSeenAt = now.UTC() - auth.Permissions = PermissionsForRole(auth.Membership.Role) + auth.ProjectMemberships = s.listProjectMembershipsForUser(ctx, auth.User.ID) + auth.Permissions = permissionsForAuth(auth.Membership.Role, auth.ProjectMemberships) return auth, session, nil } @@ -966,6 +1255,33 @@ func scanAgentProfile(row rowScanner) (domain.AgentProfile, error) { return profile, nil } +func scanUser(row rowScanner) (domain.User, error) { + var user domain.User + err := row.Scan(&user.ID, &user.Email, &user.DisplayName, &user.ExternalProvider, &user.ExternalSubject, &user.CreatedAt) + if err != nil { + return domain.User{}, err + } + return user, nil +} + +func scanProjectMembership(row rowScanner) (domain.ProjectMembership, error) { + var membership domain.ProjectMembership + err := row.Scan( + &membership.ProjectID, + &membership.UserID, + &membership.Role, + &membership.ProjectName, + &membership.ProjectSlug, + &membership.UserEmail, + &membership.UserName, + &membership.CreatedAt, + ) + if err != nil { + return domain.ProjectMembership{}, err + } + return membership, nil +} + func scanExecutorNode(row rowScanner) (domain.ExecutorNode, error) { var node domain.ExecutorNode var labelsBytes []byte @@ -1073,6 +1389,23 @@ func permissionsForRole(role string) []string { return PermissionsForRole(role) } +func permissionsForAuth(orgRole string, projectMemberships []domain.ProjectMembership) []string { + values := map[string]struct{}{} + for _, permission := range PermissionsForRole(orgRole) { + values[permission] = struct{}{} + } + for _, membership := range projectMemberships { + for _, permission := range PermissionsForProjectRole(membership.Role) { + values[permission] = struct{}{} + } + } + permissions := make([]string, 0, len(values)) + for permission := range values { + permissions = append(permissions, permission) + } + return permissions +} + func PermissionsForRole(role string) []string { switch role { case "owner", "admin": @@ -1089,3 +1422,20 @@ func PermissionsForRole(role string) []string { return []string{"organizations:read", "projects:read", "tasks:read", "runs:read"} } } + +func PermissionsForProjectRole(role string) []string { + switch role { + case "owner", "project_admin": + return []string{"projects:read", "projects:write", "repositories:write", "tasks:write", "runs:read", "runs:write", "approvals:write", "nodes:read", "skills:write", "audit:read"} + case "maintainer": + return []string{"projects:read", "repositories:write", "tasks:write", "runs:read", "runs:write", "approvals:write", "nodes:read", "audit:read"} + case "developer": + return []string{"projects:read", "tasks:write", "runs:read", "runs:write", "nodes:read"} + case "reviewer": + return []string{"projects:read", "tasks:read", "runs:read", "approvals:write", "nodes:read", "audit:read"} + case "auditor": + return []string{"projects:read", "tasks:read", "runs:read", "nodes:read", "audit:read"} + default: + return []string{"projects:read", "tasks:read", "runs:read"} + } +} diff --git a/internal/store/store.go b/internal/store/store.go index fad3488..a8de63e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -23,11 +23,19 @@ type Store interface { CreateAuthLoginState(state domain.AuthLoginState) (domain.AuthLoginState, error) ConsumeAuthLoginState(stateHash string, now time.Time) (domain.AuthLoginState, error) CleanupAuthLoginStates(cutoff time.Time, dryRun bool) (domain.AuthLoginStateRetentionResult, error) + GetUserByEmail(email string) (domain.AuthContext, string, error) + SetUserPassword(userID string, passwordHash string) error + ListUsers(orgID string) []domain.User + ListMemberships(orgID string) []domain.Membership + UpsertUser(user domain.User, orgID string, role string) (domain.AuthContext, error) ListOrganizations() []domain.Organization CreateOrganization(org domain.Organization) (domain.Organization, error) ListProjects() []domain.Project CreateProject(project domain.Project) domain.Project GetProject(id string) (domain.Project, error) + ListProjectMemberships(projectID string) []domain.ProjectMembership + ListProjectMembershipsForUser(userID string) []domain.ProjectMembership + UpsertProjectMembership(membership domain.ProjectMembership) (domain.ProjectMembership, error) ListRepositories(projectID string) []domain.Repository CreateRepository(repo domain.Repository) domain.Repository GetRepository(id string) (domain.Repository, error)