mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
* feat(vcs): gate self-hosted Git providers to self-host deployments only (MUL-3772) The Forgejo/Gitea/GitLab integration is intended for self-hosted Multica, where Multica can reach a Git instance on the operator's own network. On the managed multi-tenant cloud it adds an SSRF surface (connect validates a user-supplied instance URL from the server) and would store third-party Git tokens for all tenants under one key, while only serving the small subset of users whose instance is publicly reachable. Product decision: offer it on self-host only. - Add an explicit deployment switch MULTICA_VCS_INTEGRATION_ENABLED (default off). Connect, rotate, and webhook now require BOTH the switch on AND a valid MULTICA_VCS_SECRET_KEY — the switch is the product boundary, not key presence alone. When off, connect/rotate return 404 and the webhook returns a bare 404 (no config leak), independent of the frontend. - /api/config exposes vcs_integration_available (mirrors the switch, omitted when false) so the Settings UI hides the whole "Git providers" section on cloud instead of surfacing an operator-only "missing key" hint. - docker-compose.selfhost.yml defaults the switch on; .env.example documents it. - Docs (en/zh) lead with a callout: available on self-hosted Multica only, not Multica Cloud, and clarify "self-hosted" means Multica itself, not just Git. #5006 / #5883 stay in place — the schema and backend capability are retained; this only gates availability. No cloud VCS connection can exist (connect always required the key, which the cloud never set), so nothing needs migrating. Verified: go build/vet + VCS/config handler tests on a fresh migrated DB (incl. a new disabled-deployment 404 test); pnpm typecheck (core + views) and the integrations-tab + core schema/config vitest suites pass. Co-authored-by: multica-agent <github@multica.ai> * fix(vcs): complete self-host integration gating (MUL-5138) Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
148 lines
4.8 KiB
TypeScript
148 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, type ReactNode } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { getApi } from "../api";
|
|
import { useAuthStore } from "../auth";
|
|
import {
|
|
captureSignupSource,
|
|
identify as identifyAnalytics,
|
|
initAnalytics,
|
|
resetAnalytics,
|
|
} from "../analytics";
|
|
import { configStore } from "../config";
|
|
import { workspaceKeys } from "../workspace/queries";
|
|
import { createLogger } from "../logger";
|
|
import { defaultStorage } from "./storage";
|
|
import { setCurrentWorkspace } from "./workspace-storage";
|
|
import type { ClientIdentity } from "./types";
|
|
import type { StorageAdapter } from "../types/storage";
|
|
import type { User } from "../types";
|
|
|
|
const logger = createLogger("auth");
|
|
|
|
export function AuthInitializer({
|
|
children,
|
|
onLogin,
|
|
onLogout,
|
|
storage = defaultStorage,
|
|
cookieAuth,
|
|
identity,
|
|
}: {
|
|
children: ReactNode;
|
|
onLogin?: () => void;
|
|
onLogout?: () => void;
|
|
storage?: StorageAdapter;
|
|
cookieAuth?: boolean;
|
|
identity?: ClientIdentity;
|
|
}) {
|
|
const qc = useQueryClient();
|
|
|
|
useEffect(() => {
|
|
const api = getApi();
|
|
|
|
// Stamp attribution before anything else — the signup event (server-side)
|
|
// reads this cookie, so it has to be present before the user hits submit.
|
|
captureSignupSource();
|
|
|
|
// Fetch app config (CDN domain, PostHog key, …) in the background — non-blocking.
|
|
api
|
|
.getConfig()
|
|
.then((cfg) => {
|
|
if (cfg.cdn_domain) {
|
|
configStore.getState().setCdnConfig({
|
|
cdnDomain: cfg.cdn_domain,
|
|
// Old servers omit this — false keeps the previous behavior.
|
|
cdnSigned: cfg.cdn_signed === true,
|
|
});
|
|
}
|
|
configStore.getState().setAuthConfig({
|
|
allowSignup: cfg.allow_signup,
|
|
googleClientId: cfg.google_client_id,
|
|
// Old servers omit this field — treat that as "creation allowed"
|
|
// (the managed-cloud default) rather than blocking the UI.
|
|
workspaceCreationDisabled: cfg.workspace_creation_disabled === true,
|
|
// Absent/false on the managed cloud and older servers → section hidden.
|
|
vcsIntegrationAvailable: cfg.vcs_integration_available === true,
|
|
});
|
|
configStore.getState().setDaemonConfig({
|
|
daemonServerUrl: cfg.daemon_server_url,
|
|
daemonAppUrl: cfg.daemon_app_url,
|
|
});
|
|
configStore.getState().setFeatureFlags(cfg.feature_flags);
|
|
configStore.getState().setServerVersion(cfg.server_version);
|
|
if (cfg.posthog_key) {
|
|
initAnalytics({
|
|
key: cfg.posthog_key,
|
|
host: cfg.posthog_host || "",
|
|
appVersion: identity?.version,
|
|
environment: cfg.analytics_environment,
|
|
});
|
|
}
|
|
})
|
|
.catch(() => {
|
|
/* config is optional — legacy file card matching degrades gracefully */
|
|
});
|
|
|
|
const onAuthSuccess = (user: User) => {
|
|
onLogin?.();
|
|
useAuthStore.setState({ user, isLoading: false });
|
|
identifyAnalytics(user.id, { email: user.email, name: user.name });
|
|
};
|
|
|
|
const onAuthFailure = () => {
|
|
onLogout?.();
|
|
resetAnalytics();
|
|
useAuthStore.setState({ user: null, isLoading: false });
|
|
};
|
|
|
|
if (cookieAuth) {
|
|
// Cookie mode: the HttpOnly cookie is sent automatically by the browser.
|
|
// Call the API to check if the session is still valid.
|
|
//
|
|
// Seed the workspace list into React Query so the URL-driven layout can
|
|
// resolve the slug without a second fetch. The active workspace itself
|
|
// is derived from the URL by [workspaceSlug]/layout.tsx — no imperative
|
|
// selection here.
|
|
Promise.all([api.getMe(), api.listWorkspaces()])
|
|
.then(([user, wsList]) => {
|
|
onAuthSuccess(user);
|
|
qc.setQueryData(workspaceKeys.list(), wsList);
|
|
})
|
|
.catch((err) => {
|
|
logger.error("cookie auth init failed", err);
|
|
onAuthFailure();
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Token mode: read from localStorage (Electron / legacy).
|
|
const token = storage.getItem("multica_token");
|
|
if (!token) {
|
|
onLogout?.();
|
|
useAuthStore.setState({ isLoading: false });
|
|
return;
|
|
}
|
|
|
|
api.setToken(token);
|
|
|
|
Promise.all([api.getMe(), api.listWorkspaces()])
|
|
.then(([user, wsList]) => {
|
|
onAuthSuccess(user);
|
|
// Seed React Query cache so the URL-driven layout can resolve the
|
|
// slug without a second fetch.
|
|
qc.setQueryData(workspaceKeys.list(), wsList);
|
|
})
|
|
.catch((err) => {
|
|
logger.error("auth init failed", err);
|
|
api.setToken(null);
|
|
setCurrentWorkspace(null, null);
|
|
storage.removeItem("multica_token");
|
|
onAuthFailure();
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
return <>{children}</>;
|
|
}
|