All checks were successful
ci/woodpecker/push/web Pipeline was successful
- Add workspace ID to ActiveProjectsWidget API calls (fixes 401/403) - Add ORCHESTRATOR_URL and ORCHESTRATOR_API_KEY to web service in swarm compose (fixes 503 on orchestrator proxy routes) - Add internal network to web service for orchestrator connectivity - Update .env.example domain examples to single-level subdomains - Fix version display on login page from v0.1 to v0.0.20 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { Suspense, useEffect, useState, useCallback } from "react";
|
|
import type { ReactElement } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { Loader2 } from "lucide-react";
|
|
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
|
|
import { AuthShell, AuthCard, AuthBrand, AuthStatusPill } from "@mosaic/ui";
|
|
import { API_BASE_URL, IS_MOCK_AUTH_MODE } from "@/lib/config";
|
|
import { signIn } from "@/lib/auth-client";
|
|
import { fetchWithRetry } from "@/lib/auth/fetch-with-retry";
|
|
import { parseAuthError } from "@/lib/auth/auth-errors";
|
|
import { useAuth } from "@/lib/auth/auth-context";
|
|
import { OAuthButton } from "@/components/auth/OAuthButton";
|
|
import { LoginForm } from "@/components/auth/LoginForm";
|
|
import { AuthDivider } from "@/components/auth/AuthDivider";
|
|
import { AuthErrorBanner } from "@/components/auth/AuthErrorBanner";
|
|
|
|
export default function LoginPage(): ReactElement {
|
|
return (
|
|
<Suspense
|
|
fallback={
|
|
<AuthShell>
|
|
<AuthCard>
|
|
<div className="flex flex-col items-center gap-6">
|
|
<AuthBrand />
|
|
<div
|
|
className="flex items-center justify-center py-8"
|
|
role="status"
|
|
aria-label="Loading authentication options"
|
|
>
|
|
<Loader2 className="h-8 w-8 animate-spin text-[#56a0ff]" aria-hidden="true" />
|
|
<span className="sr-only">Loading authentication options</span>
|
|
</div>
|
|
</div>
|
|
</AuthCard>
|
|
</AuthShell>
|
|
}
|
|
>
|
|
<LoginPageContent />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function LoginPageContent(): ReactElement {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { isAuthenticated, refreshSession } = useAuth();
|
|
const [config, setConfig] = useState<AuthConfigResponse | null | undefined>(undefined);
|
|
const [loadingConfig, setLoadingConfig] = useState(true);
|
|
const [retryCount, setRetryCount] = useState(0);
|
|
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
|
|
const [credentialsLoading, setCredentialsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [urlError, setUrlError] = useState<string | null>(null);
|
|
|
|
/* Read ?error= query param on mount and map to PDA-friendly message */
|
|
useEffect(() => {
|
|
const errorCode = searchParams.get("error");
|
|
if (errorCode) {
|
|
const parsed = parseAuthError(errorCode);
|
|
setUrlError(parsed.message);
|
|
// Clean up the URL by removing the error param without triggering navigation
|
|
const nextParams = new URLSearchParams(searchParams.toString());
|
|
nextParams.delete("error");
|
|
const query = nextParams.toString();
|
|
router.replace(query ? `/login?${query}` : "/login");
|
|
}
|
|
}, [searchParams, router]);
|
|
|
|
useEffect(() => {
|
|
if (IS_MOCK_AUTH_MODE && isAuthenticated) {
|
|
router.replace("/tasks");
|
|
}
|
|
}, [isAuthenticated, router]);
|
|
|
|
useEffect(() => {
|
|
if (IS_MOCK_AUTH_MODE) {
|
|
setConfig({ providers: [] });
|
|
setLoadingConfig(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
async function fetchConfig(): Promise<void> {
|
|
try {
|
|
const response = await fetchWithRetry(`${API_BASE_URL}/auth/config`);
|
|
if (!response.ok) {
|
|
throw new Error("Failed to fetch auth config");
|
|
}
|
|
const data = (await response.json()) as AuthConfigResponse;
|
|
if (!cancelled) {
|
|
setConfig(data);
|
|
}
|
|
} catch (err: unknown) {
|
|
if (!cancelled) {
|
|
console.error("[Auth] Failed to load auth config:", err);
|
|
setConfig(null);
|
|
setUrlError(
|
|
"Unable to load sign-in options. Please refresh the page or try again in a moment."
|
|
);
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setLoadingConfig(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void fetchConfig();
|
|
|
|
return (): void => {
|
|
cancelled = true;
|
|
};
|
|
}, [retryCount]);
|
|
|
|
const oauthProviders: AuthProviderConfig[] =
|
|
config?.providers.filter((p) => p.type === "oauth") ?? [];
|
|
const credentialProviders: AuthProviderConfig[] =
|
|
config?.providers.filter((p) => p.type === "credentials") ?? [];
|
|
|
|
const hasOAuth = oauthProviders.length > 0;
|
|
const hasCredentials = credentialProviders.length > 0;
|
|
|
|
const handleOAuthLogin = useCallback((providerId: string): void => {
|
|
setOauthLoading(providerId);
|
|
setError(null);
|
|
const callbackURL =
|
|
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
|
|
signIn
|
|
.oauth2({ providerId, callbackURL })
|
|
.then((result) => {
|
|
// BetterAuth returns Data | Error union — check for error or missing redirect URL
|
|
const hasError = "error" in result && result.error;
|
|
const hasUrl = "data" in result && result.data?.url;
|
|
if (hasError || !hasUrl) {
|
|
const errObj = hasError ? result.error : null;
|
|
const message =
|
|
errObj && typeof errObj === "object" && "message" in errObj
|
|
? String(errObj.message)
|
|
: "no redirect URL";
|
|
console.error(`[Auth] OAuth sign-in failed for ${providerId}:`, message);
|
|
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
|
setOauthLoading(null);
|
|
}
|
|
// If data.url exists, BetterAuth's client will redirect the browser automatically.
|
|
// No need to reset loading — the page is navigating away.
|
|
})
|
|
.catch((err: unknown) => {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
|
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
|
setOauthLoading(null);
|
|
});
|
|
}, []);
|
|
|
|
const handleCredentialsLogin = useCallback(
|
|
async (email: string, password: string): Promise<void> => {
|
|
setCredentialsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const result = await signIn.email({ email, password });
|
|
|
|
if (result.error) {
|
|
const parsed = parseAuthError(
|
|
result.error.message ? new Error(result.error.message) : result.error
|
|
);
|
|
setError(parsed.message);
|
|
} else {
|
|
router.push("/tasks");
|
|
}
|
|
} catch (err: unknown) {
|
|
const parsed = parseAuthError(err);
|
|
console.error("[Auth] Credentials sign-in failed:", err);
|
|
setError(parsed.message);
|
|
} finally {
|
|
setCredentialsLoading(false);
|
|
}
|
|
},
|
|
[router]
|
|
);
|
|
|
|
const handleRetry = useCallback((): void => {
|
|
setConfig(undefined);
|
|
setLoadingConfig(true);
|
|
setUrlError(null);
|
|
setError(null);
|
|
setRetryCount((c) => c + 1);
|
|
}, []);
|
|
|
|
const handleMockLogin = useCallback(async (): Promise<void> => {
|
|
setError(null);
|
|
try {
|
|
await refreshSession();
|
|
router.push("/tasks");
|
|
} catch (err: unknown) {
|
|
const parsed = parseAuthError(err);
|
|
setError(parsed.message);
|
|
}
|
|
}, [refreshSession, router]);
|
|
|
|
if (IS_MOCK_AUTH_MODE) {
|
|
return (
|
|
<AuthShell>
|
|
<AuthCard>
|
|
<div className="flex flex-col items-center gap-6">
|
|
<AuthBrand />
|
|
<div className="text-center">
|
|
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">Command Center</h1>
|
|
<p className="mt-1 text-sm text-[#5a6a87] dark:text-[#8f9db7]">
|
|
Local mock auth mode is active
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 space-y-4">
|
|
<AuthStatusPill label="Mock mode" tone="warning" className="w-full justify-center" />
|
|
{error && <AuthErrorBanner message={error} />}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void handleMockLogin();
|
|
}}
|
|
className="w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60 hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]"
|
|
data-testid="mock-auth-login"
|
|
>
|
|
Continue with Mock Session
|
|
</button>
|
|
</div>
|
|
</AuthCard>
|
|
</AuthShell>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AuthShell>
|
|
<AuthCard>
|
|
<div className="flex flex-col items-center gap-6">
|
|
<AuthBrand />
|
|
<div className="text-center">
|
|
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">Command Center</h1>
|
|
<p className="mt-1 text-sm text-[#5a6a87] dark:text-[#8f9db7]">
|
|
Sign in to your orchestration platform
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6">
|
|
{loadingConfig ? (
|
|
<div
|
|
className="flex items-center justify-center py-8"
|
|
data-testid="loading-spinner"
|
|
role="status"
|
|
aria-label="Loading authentication options"
|
|
>
|
|
<Loader2 className="h-8 w-8 animate-spin text-[#56a0ff]" aria-hidden="true" />
|
|
<span className="sr-only">Loading authentication options</span>
|
|
</div>
|
|
) : config === null ? (
|
|
<div className="space-y-4" data-testid="config-error-state">
|
|
<AuthErrorBanner message={urlError ?? "Unable to load sign-in options."} />
|
|
<div className="flex justify-center">
|
|
<button
|
|
type="button"
|
|
onClick={handleRetry}
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold text-white bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60 hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]"
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-0">
|
|
{urlError && (
|
|
<div className="mb-4">
|
|
<AuthErrorBanner
|
|
message={urlError}
|
|
onDismiss={(): void => {
|
|
setUrlError(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{error && !hasCredentials && (
|
|
<div className="mb-4">
|
|
<AuthErrorBanner
|
|
message={error}
|
|
onDismiss={(): void => {
|
|
setError(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{hasCredentials && (
|
|
<LoginForm
|
|
onSubmit={handleCredentialsLogin}
|
|
isLoading={credentialsLoading}
|
|
error={error}
|
|
/>
|
|
)}
|
|
|
|
{hasOAuth && hasCredentials && <AuthDivider />}
|
|
|
|
{hasOAuth && (
|
|
<div className="space-y-2">
|
|
{oauthProviders.map((provider) => (
|
|
<OAuthButton
|
|
key={provider.id}
|
|
providerName={provider.name}
|
|
providerId={provider.id}
|
|
onClick={(): void => {
|
|
handleOAuthLogin(provider.id);
|
|
}}
|
|
isLoading={oauthLoading === provider.id}
|
|
disabled={oauthLoading !== null && oauthLoading !== provider.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-6 flex justify-center">
|
|
<AuthStatusPill label="Mosaic v0.0.20" tone="neutral" />
|
|
</div>
|
|
</AuthCard>
|
|
</AuthShell>
|
|
);
|
|
}
|