Compare commits
1 Commits
d3ea964116
...
6c3ede5c86
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c3ede5c86 |
@@ -3,11 +3,9 @@ import { createAuth, type Auth } from '@mosaic/auth';
|
|||||||
import type { Db } from '@mosaic/db';
|
import type { Db } from '@mosaic/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import { AUTH } from './auth.tokens.js';
|
import { AUTH } from './auth.tokens.js';
|
||||||
import { SsoController } from './sso.controller.js';
|
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [SsoController],
|
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: AUTH,
|
provide: AUTH,
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { SsoController } from './sso.controller.js';
|
|
||||||
|
|
||||||
describe('SsoController', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists configured OIDC providers', () => {
|
|
||||||
vi.stubEnv('WORKOS_CLIENT_ID', 'workos-client');
|
|
||||||
vi.stubEnv('WORKOS_CLIENT_SECRET', 'workos-secret');
|
|
||||||
vi.stubEnv('WORKOS_ISSUER', 'https://auth.workos.com/sso/client_123');
|
|
||||||
|
|
||||||
const controller = new SsoController();
|
|
||||||
const providers = controller.list();
|
|
||||||
|
|
||||||
expect(providers.find((provider) => provider.id === 'workos')).toMatchObject({
|
|
||||||
configured: true,
|
|
||||||
loginMode: 'oidc',
|
|
||||||
callbackPath: '/api/auth/oauth2/callback/workos',
|
|
||||||
teamSync: { enabled: true, claim: 'organization_id' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prefers SAML fallback for Keycloak when only the SAML login URL is configured', () => {
|
|
||||||
vi.stubEnv('KEYCLOAK_SAML_LOGIN_URL', 'https://sso.example.com/realms/mosaic/protocol/saml');
|
|
||||||
|
|
||||||
const controller = new SsoController();
|
|
||||||
const providers = controller.list();
|
|
||||||
|
|
||||||
expect(providers.find((provider) => provider.id === 'keycloak')).toMatchObject({
|
|
||||||
configured: true,
|
|
||||||
loginMode: 'saml',
|
|
||||||
samlFallback: {
|
|
||||||
configured: true,
|
|
||||||
loginUrl: 'https://sso.example.com/realms/mosaic/protocol/saml',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
|
||||||
import { buildSsoDiscovery, type SsoProviderDiscovery } from '@mosaic/auth';
|
|
||||||
|
|
||||||
@Controller('api/sso/providers')
|
|
||||||
export class SsoController {
|
|
||||||
@Get()
|
|
||||||
list(): SsoProviderDiscovery[] {
|
|
||||||
return buildSsoDiscovery();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
import helmet from '@fastify/helmet';
|
import helmet from '@fastify/helmet';
|
||||||
import { listSsoStartupWarnings } from '@mosaic/auth';
|
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
import { mountAuthHandler } from './auth/auth.controller.js';
|
import { mountAuthHandler } from './auth/auth.controller.js';
|
||||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||||
@@ -24,8 +23,13 @@ async function bootstrap(): Promise<void> {
|
|||||||
throw new Error('BETTER_AUTH_SECRET is required');
|
throw new Error('BETTER_AUTH_SECRET is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const warning of listSsoStartupWarnings()) {
|
if (
|
||||||
logger.warn(warning);
|
process.env['AUTHENTIK_CLIENT_ID'] &&
|
||||||
|
(!process.env['AUTHENTIK_CLIENT_SECRET'] || !process.env['AUTHENTIK_ISSUER'])
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
'[warn] AUTHENTIK_CLIENT_ID is set but AUTHENTIK_CLIENT_SECRET or AUTHENTIK_ISSUER is missing — Authentik SSO will not work',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
|||||||
@@ -1,27 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { api } from '@/lib/api';
|
import { useRouter } from 'next/navigation';
|
||||||
import { authClient, signIn } from '@/lib/auth-client';
|
import { useState } from 'react';
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
import { signIn } from '@/lib/auth-client';
|
||||||
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
import { getEnabledSsoProviders } from '@/lib/sso-providers';
|
||||||
|
|
||||||
export default function LoginPage(): React.ReactElement {
|
export default function LoginPage(): React.ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
const ssoProviders = getEnabledSsoProviders();
|
||||||
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
const hasSsoProviders = ssoProviders.length > 0;
|
||||||
SsoProviderDiscovery['id'] | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
|
||||||
.catch(() => [] as SsoProviderDiscovery[])
|
|
||||||
.then((providers) => setSsoProviders(providers.filter((provider) => provider.configured)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -43,27 +33,6 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
router.push('/chat');
|
router.push('/chat');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
|
||||||
setError(null);
|
|
||||||
setSsoLoadingProviderId(providerId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await authClient.signIn.oauth2({
|
|
||||||
providerId,
|
|
||||||
callbackURL: '/chat',
|
|
||||||
newUserCallbackURL: '/chat',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
|
||||||
setSsoLoadingProviderId(null);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(err instanceof Error ? err.message : `Sign in with ${providerId} failed`);
|
|
||||||
setSsoLoadingProviderId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||||
@@ -78,7 +47,26 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
{hasSsoProviders && (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{ssoProviders.map((provider) => (
|
||||||
|
<Link
|
||||||
|
key={provider.id}
|
||||||
|
href={provider.href}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card"
|
||||||
|
>
|
||||||
|
{provider.buttonLabel}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<div className="flex-1 border-t border-surface-border" />
|
||||||
|
<span className="mx-3 text-xs text-text-muted">or</span>
|
||||||
|
<div className="flex-1 border-t border-surface-border" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className={hasSsoProviders ? 'space-y-4' : 'mt-6 space-y-4'} onSubmit={handleSubmit}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||||
Email
|
Email
|
||||||
@@ -120,14 +108,6 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<SsoProviderButtons
|
|
||||||
providers={ssoProviders}
|
|
||||||
loadingProviderId={ssoLoadingProviderId}
|
|
||||||
onOidcSignIn={(providerId) => {
|
|
||||||
void handleSsoSignIn(providerId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-text-muted">
|
<p className="mt-4 text-center text-sm text-text-muted">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link href="/register" className="text-blue-400 hover:text-blue-300">
|
<Link href="/register" className="text-blue-400 hover:text-blue-300">
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { authClient, useSession } from '@/lib/auth-client';
|
import { authClient, useSession } from '@/lib/auth-client';
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
|
||||||
import { SsoProviderSection } from '@/components/settings/sso-provider-section';
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -426,9 +424,7 @@ function NotificationsTab(): React.ReactElement {
|
|||||||
|
|
||||||
function ProvidersTab(): React.ReactElement {
|
function ProvidersTab(): React.ReactElement {
|
||||||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
||||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [ssoLoading, setSsoLoading] = useState(true);
|
|
||||||
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -438,13 +434,6 @@ function ProvidersTab(): React.ReactElement {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
|
||||||
.catch(() => [] as SsoProviderDiscovery[])
|
|
||||||
.then((providers) => setSsoProviders(providers))
|
|
||||||
.finally(() => setSsoLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
||||||
setTestStatuses((prev) => ({
|
setTestStatuses((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -475,44 +464,35 @@ function ProvidersTab(): React.ReactElement {
|
|||||||
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6">
|
<section className="space-y-4">
|
||||||
<div className="space-y-4">
|
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
|
||||||
<h2 className="text-lg font-medium text-text-secondary">SSO Providers</h2>
|
{loading ? (
|
||||||
<SsoProviderSection providers={ssoProviders} loading={ssoLoading} />
|
<p className="text-sm text-text-muted">Loading providers...</p>
|
||||||
</div>
|
) : providers.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
<div className="space-y-4">
|
<p className="text-sm text-text-muted">
|
||||||
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
|
No providers configured. Set{' '}
|
||||||
{loading ? (
|
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">OLLAMA_BASE_URL</code>{' '}
|
||||||
<p className="text-sm text-text-muted">Loading providers...</p>
|
or{' '}
|
||||||
) : providers.length === 0 ? (
|
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
MOSAIC_CUSTOM_PROVIDERS
|
||||||
<p className="text-sm text-text-muted">
|
</code>{' '}
|
||||||
No providers configured. Set{' '}
|
to add providers.
|
||||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
</p>
|
||||||
OLLAMA_BASE_URL
|
</div>
|
||||||
</code>{' '}
|
) : (
|
||||||
or{' '}
|
<div className="space-y-4">
|
||||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
{providers.map((provider) => (
|
||||||
MOSAIC_CUSTOM_PROVIDERS
|
<ProviderCard
|
||||||
</code>{' '}
|
key={provider.id}
|
||||||
to add providers.
|
provider={provider}
|
||||||
</p>
|
defaultModel={defaultModel}
|
||||||
</div>
|
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
||||||
) : (
|
onTest={() => void testConnection(provider.id)}
|
||||||
<div className="space-y-4">
|
/>
|
||||||
{providers.map((provider) => (
|
))}
|
||||||
<ProviderCard
|
</div>
|
||||||
key={provider.id}
|
)}
|
||||||
provider={provider}
|
|
||||||
defaultModel={defaultModel}
|
|
||||||
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
|
||||||
onTest={() => void testConnection(provider.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,186 +1,117 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/* =============================================================================
|
/*
|
||||||
MOSAIC DESIGN SYSTEM — Reference token system from dashboard design
|
* Mosaic Stack design tokens mapped to Tailwind v4 theme.
|
||||||
============================================================================= */
|
* Source: @mosaic/design-tokens (AD-13)
|
||||||
|
* Fonts: Outfit (sans), Fira Code (mono)
|
||||||
/* -----------------------------------------------------------------------------
|
* Palette: deep blue-grays + blue/purple/teal accents
|
||||||
Primitive Tokens (Dark-first — dark is the default theme)
|
* Default: dark theme
|
||||||
----------------------------------------------------------------------------- */
|
*/
|
||||||
:root {
|
|
||||||
/* Mosaic design tokens — dark palette (default) */
|
|
||||||
--ms-bg-950: #080b12;
|
|
||||||
--ms-bg-900: #0f141d;
|
|
||||||
--ms-bg-850: #151b26;
|
|
||||||
--ms-surface-800: #1b2331;
|
|
||||||
--ms-surface-750: #232d3f;
|
|
||||||
--ms-border-700: #2f3b52;
|
|
||||||
--ms-text-100: #eef3ff;
|
|
||||||
--ms-text-300: #c5d0e6;
|
|
||||||
--ms-text-500: #8f9db7;
|
|
||||||
--ms-blue-500: #2f80ff;
|
|
||||||
--ms-blue-400: #56a0ff;
|
|
||||||
--ms-red-500: #e5484d;
|
|
||||||
--ms-red-400: #f06a6f;
|
|
||||||
--ms-purple-500: #8b5cf6;
|
|
||||||
--ms-purple-400: #a78bfa;
|
|
||||||
--ms-teal-500: #14b8a6;
|
|
||||||
--ms-teal-400: #2dd4bf;
|
|
||||||
--ms-amber-500: #f59e0b;
|
|
||||||
--ms-amber-400: #fbbf24;
|
|
||||||
--ms-pink-500: #ec4899;
|
|
||||||
--ms-emerald-500: #10b981;
|
|
||||||
--ms-orange-500: #f97316;
|
|
||||||
--ms-cyan-500: #06b6d4;
|
|
||||||
--ms-indigo-500: #6366f1;
|
|
||||||
|
|
||||||
/* Semantic aliases — dark theme is default */
|
|
||||||
--bg: var(--ms-bg-900);
|
|
||||||
--bg-deep: var(--ms-bg-950);
|
|
||||||
--bg-mid: var(--ms-bg-850);
|
|
||||||
--surface: var(--ms-surface-800);
|
|
||||||
--surface-2: var(--ms-surface-750);
|
|
||||||
--border: var(--ms-border-700);
|
|
||||||
--text: var(--ms-text-100);
|
|
||||||
--text-2: var(--ms-text-300);
|
|
||||||
--muted: var(--ms-text-500);
|
|
||||||
--primary: var(--ms-blue-500);
|
|
||||||
--primary-l: var(--ms-blue-400);
|
|
||||||
--danger: var(--ms-red-500);
|
|
||||||
--success: var(--ms-teal-500);
|
|
||||||
--warn: var(--ms-amber-500);
|
|
||||||
--purple: var(--ms-purple-500);
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
--font: var(--font-outfit, 'Outfit'), system-ui, sans-serif;
|
|
||||||
--mono: var(--font-fira-code, 'Fira Code'), 'Cascadia Code', monospace;
|
|
||||||
|
|
||||||
/* Radius scale */
|
|
||||||
--r: 8px;
|
|
||||||
--r-sm: 5px;
|
|
||||||
--r-lg: 12px;
|
|
||||||
--r-xl: 16px;
|
|
||||||
|
|
||||||
/* Layout dimensions */
|
|
||||||
--sidebar-w: 260px;
|
|
||||||
--topbar-h: 56px;
|
|
||||||
--terminal-h: 220px;
|
|
||||||
|
|
||||||
/* Easing */
|
|
||||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
|
|
||||||
/* Legacy shadow tokens (retained for component compat) */
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='light'] {
|
|
||||||
--ms-bg-950: #f8faff;
|
|
||||||
--ms-bg-900: #f0f4fc;
|
|
||||||
--ms-bg-850: #e8edf8;
|
|
||||||
--ms-surface-800: #dde4f2;
|
|
||||||
--ms-surface-750: #d0d9ec;
|
|
||||||
--ms-border-700: #b8c4de;
|
|
||||||
--ms-text-100: #0f141d;
|
|
||||||
--ms-text-300: #2f3b52;
|
|
||||||
--ms-text-500: #5a6a87;
|
|
||||||
--bg: var(--ms-bg-900);
|
|
||||||
--bg-deep: var(--ms-bg-950);
|
|
||||||
--bg-mid: var(--ms-bg-850);
|
|
||||||
--surface: var(--ms-surface-800);
|
|
||||||
--surface-2: var(--ms-surface-750);
|
|
||||||
--border: var(--ms-border-700);
|
|
||||||
--text: var(--ms-text-100);
|
|
||||||
--text-2: var(--ms-text-300);
|
|
||||||
--muted: var(--ms-text-500);
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05), 0 1px 3px 0 rgb(0 0 0 / 0.05);
|
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06);
|
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: var(--font);
|
/* ─── Fonts ─── */
|
||||||
--font-mono: var(--mono);
|
--font-sans: 'Outfit', system-ui, -apple-system, sans-serif;
|
||||||
--color-surface-bg: var(--bg);
|
--font-mono: 'Fira Code', ui-monospace, Menlo, monospace;
|
||||||
--color-surface-card: var(--surface);
|
|
||||||
--color-surface-elevated: var(--surface-2);
|
/* ─── Neutral blue-gray scale ─── */
|
||||||
--color-surface-border: var(--border);
|
--color-gray-50: #f0f2f5;
|
||||||
--color-text-primary: var(--text);
|
--color-gray-100: #dce0e8;
|
||||||
--color-text-secondary: var(--text-2);
|
--color-gray-200: #b8c0cc;
|
||||||
--color-text-muted: var(--muted);
|
--color-gray-300: #8e99a9;
|
||||||
--color-accent: var(--primary);
|
--color-gray-400: #6b7a8d;
|
||||||
--color-success: var(--success);
|
--color-gray-500: #4e5d70;
|
||||||
--color-warning: var(--warn);
|
--color-gray-600: #3b4859;
|
||||||
--color-error: var(--danger);
|
--color-gray-700: #2a3544;
|
||||||
--color-info: var(--primary-l);
|
--color-gray-800: #1c2433;
|
||||||
--spacing-sidebar: var(--sidebar-w);
|
--color-gray-900: #111827;
|
||||||
|
--color-gray-950: #0a0f1a;
|
||||||
|
|
||||||
|
/* ─── Primary — blue ─── */
|
||||||
|
--color-blue-50: #eff4ff;
|
||||||
|
--color-blue-100: #dae5ff;
|
||||||
|
--color-blue-200: #bdd1ff;
|
||||||
|
--color-blue-300: #8fb4ff;
|
||||||
|
--color-blue-400: #5b8bff;
|
||||||
|
--color-blue-500: #3b6cf7;
|
||||||
|
--color-blue-600: #2551e0;
|
||||||
|
--color-blue-700: #1d40c0;
|
||||||
|
--color-blue-800: #1e369c;
|
||||||
|
--color-blue-900: #1e317b;
|
||||||
|
--color-blue-950: #162050;
|
||||||
|
|
||||||
|
/* ─── Accent — purple ─── */
|
||||||
|
--color-purple-50: #f3f0ff;
|
||||||
|
--color-purple-100: #e7dfff;
|
||||||
|
--color-purple-200: #d2c3ff;
|
||||||
|
--color-purple-300: #b49aff;
|
||||||
|
--color-purple-400: #9466ff;
|
||||||
|
--color-purple-500: #7c3aed;
|
||||||
|
--color-purple-600: #6d28d9;
|
||||||
|
--color-purple-700: #5b21b6;
|
||||||
|
--color-purple-800: #4c1d95;
|
||||||
|
--color-purple-900: #3b1578;
|
||||||
|
--color-purple-950: #230d4d;
|
||||||
|
|
||||||
|
/* ─── Accent — teal ─── */
|
||||||
|
--color-teal-50: #effcf9;
|
||||||
|
--color-teal-100: #d0f7ef;
|
||||||
|
--color-teal-200: #a4eddf;
|
||||||
|
--color-teal-300: #6fddcb;
|
||||||
|
--color-teal-400: #3ec5b2;
|
||||||
|
--color-teal-500: #25aa99;
|
||||||
|
--color-teal-600: #1c897e;
|
||||||
|
--color-teal-700: #1b6e66;
|
||||||
|
--color-teal-800: #1a5853;
|
||||||
|
--color-teal-900: #194945;
|
||||||
|
--color-teal-950: #082d2b;
|
||||||
|
|
||||||
|
/* ─── Semantic surface tokens ─── */
|
||||||
|
--color-surface-bg: #0a0f1a;
|
||||||
|
--color-surface-card: #111827;
|
||||||
|
--color-surface-elevated: #1c2433;
|
||||||
|
--color-surface-border: #2a3544;
|
||||||
|
|
||||||
|
/* ─── Semantic text tokens ─── */
|
||||||
|
--color-text-primary: #f0f2f5;
|
||||||
|
--color-text-secondary: #8e99a9;
|
||||||
|
--color-text-muted: #6b7a8d;
|
||||||
|
|
||||||
|
/* ─── Status colors ─── */
|
||||||
|
--color-success: #22c55e;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-error: #ef4444;
|
||||||
|
--color-info: #3b82f6;
|
||||||
|
|
||||||
|
/* ─── Sidebar width ─── */
|
||||||
|
--spacing-sidebar: 16rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
:root {
|
||||||
*::before,
|
--bg: var(--color-surface-bg);
|
||||||
*::after {
|
--bg-deep: var(--color-gray-950);
|
||||||
box-sizing: border-box;
|
--surface: var(--color-surface-card);
|
||||||
|
--surface-2: var(--color-surface-elevated);
|
||||||
|
--border: var(--color-surface-border);
|
||||||
|
--text: var(--color-text-primary);
|
||||||
|
--text-2: var(--color-text-secondary);
|
||||||
|
--muted: var(--color-text-muted);
|
||||||
|
--primary: var(--color-blue-500);
|
||||||
|
--danger: var(--color-error);
|
||||||
|
--ms-blue-500: var(--color-blue-500);
|
||||||
|
--sidebar-w: 260px;
|
||||||
|
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
/* ─── Base styles ─── */
|
||||||
font-size: 15px;
|
body {
|
||||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
background-color: var(--color-surface-bg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
/* ─── Scrollbar styling ─── */
|
||||||
font-family: var(--font);
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");
|
|
||||||
opacity: 0.025;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid var(--ms-blue-400);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:focus:not(:focus-visible) {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -191,96 +122,10 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--border);
|
background: var(--color-gray-600);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--muted);
|
background: var(--color-gray-500);
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: var(--sidebar-w) 1fr;
|
|
||||||
grid-template-rows: var(--topbar-h) 1fr;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
grid-row: 1;
|
|
||||||
background: var(--bg-deep);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 20px;
|
|
||||||
gap: 12px;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 2;
|
|
||||||
background: var(--bg-deep);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main {
|
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 2;
|
|
||||||
background: var(--bg);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.app-shell {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar {
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
top: var(--topbar-h);
|
|
||||||
bottom: 0;
|
|
||||||
width: 240px;
|
|
||||||
z-index: 150;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar[data-mobile-open='true'] {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main,
|
|
||||||
.app-header {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) and (max-width: 1023px) {
|
|
||||||
.app-shell[data-sidebar-hidden='true'] {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell[data-sidebar-hidden='true'] .app-sidebar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell[data-sidebar-hidden='true'] .app-main,
|
|
||||||
.app-shell[data-sidebar-hidden='true'] .app-header {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,30 @@
|
|||||||
import type { Metadata } from 'next';
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { ThemeProvider } from '@/providers/theme-provider';
|
import { Outfit, Fira_Code } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
const outfit = Outfit({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-sans',
|
||||||
|
display: 'swap',
|
||||||
|
weight: ['300', '400', '500', '600', '700'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const firaCode = Fira_Code({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-mono',
|
||||||
|
display: 'swap',
|
||||||
|
weight: ['400', '500', '700'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
title: 'Mosaic',
|
title: 'Mosaic',
|
||||||
description: 'Mosaic Stack Dashboard',
|
description: 'Mosaic Stack Dashboard',
|
||||||
};
|
};
|
||||||
|
|
||||||
function themeScript(): string {
|
|
||||||
return `
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
|
|
||||||
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
|
||||||
} catch (error) {
|
|
||||||
document.documentElement.setAttribute('data-theme', 'dark');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement {
|
export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" className={`dark ${outfit.variable} ${firaCode.variable}`}>
|
||||||
<head>
|
<body>{children}</body>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
|
|
||||||
/>
|
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript() }} />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<ThemeProvider>{children}</ThemeProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
|
||||||
import { SsoProviderButtons } from './sso-provider-buttons.js';
|
|
||||||
|
|
||||||
describe('SsoProviderButtons', () => {
|
|
||||||
it('renders OIDC sign-in buttons and SAML fallback links', () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SsoProviderButtons
|
|
||||||
providers={[
|
|
||||||
{
|
|
||||||
id: 'workos',
|
|
||||||
name: 'WorkOS',
|
|
||||||
protocols: ['oidc'],
|
|
||||||
configured: true,
|
|
||||||
loginMode: 'oidc',
|
|
||||||
callbackPath: '/api/auth/oauth2/callback/workos',
|
|
||||||
teamSync: { enabled: true, claim: 'organization_id' },
|
|
||||||
samlFallback: { configured: false, loginUrl: null },
|
|
||||||
warnings: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'keycloak',
|
|
||||||
name: 'Keycloak',
|
|
||||||
protocols: ['oidc', 'saml'],
|
|
||||||
configured: true,
|
|
||||||
loginMode: 'saml',
|
|
||||||
callbackPath: null,
|
|
||||||
teamSync: { enabled: true, claim: 'groups' },
|
|
||||||
samlFallback: {
|
|
||||||
configured: true,
|
|
||||||
loginUrl: 'https://sso.example.com/realms/mosaic/protocol/saml',
|
|
||||||
},
|
|
||||||
warnings: [],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onOidcSignIn={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(html).toContain('Continue with WorkOS');
|
|
||||||
expect(html).toContain('Continue with Keycloak (SAML)');
|
|
||||||
expect(html).toContain('https://sso.example.com/realms/mosaic/protocol/saml');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
|
||||||
|
|
||||||
interface SsoProviderButtonsProps {
|
|
||||||
providers: SsoProviderDiscovery[];
|
|
||||||
loadingProviderId?: string | null;
|
|
||||||
onOidcSignIn: (providerId: SsoProviderDiscovery['id']) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SsoProviderButtons({
|
|
||||||
providers,
|
|
||||||
loadingProviderId = null,
|
|
||||||
onOidcSignIn,
|
|
||||||
}: SsoProviderButtonsProps): React.ReactElement | null {
|
|
||||||
const visibleProviders = providers.filter((provider) => provider.configured);
|
|
||||||
|
|
||||||
if (visibleProviders.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-6 space-y-3 border-t border-surface-border pt-6">
|
|
||||||
<p className="text-sm font-medium text-text-secondary">Single sign-on</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{visibleProviders.map((provider) => {
|
|
||||||
if (provider.loginMode === 'saml' && provider.samlFallback.loginUrl) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={provider.id}
|
|
||||||
href={provider.samlFallback.loginUrl}
|
|
||||||
className="flex w-full items-center justify-center rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:border-accent/50 hover:text-accent"
|
|
||||||
>
|
|
||||||
Continue with {provider.name} (SAML)
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={provider.id}
|
|
||||||
type="button"
|
|
||||||
disabled={loadingProviderId === provider.id}
|
|
||||||
onClick={() => onOidcSignIn(provider.id)}
|
|
||||||
className="flex w-full items-center justify-center rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:border-accent/50 hover:text-accent disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loadingProviderId === provider.id
|
|
||||||
? `Redirecting to ${provider.name}...`
|
|
||||||
: `Continue with ${provider.name}`}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { SidebarProvider, useSidebar } from './sidebar-context';
|
|
||||||
import { Sidebar } from './sidebar';
|
import { Sidebar } from './sidebar';
|
||||||
import { Topbar } from './topbar';
|
import { Topbar } from './topbar';
|
||||||
|
|
||||||
@@ -9,24 +6,14 @@ interface AppShellProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppShellFrame({ children }: AppShellProps): React.ReactElement {
|
export function AppShell({ children }: AppShellProps): React.ReactElement {
|
||||||
const { collapsed, isMobile } = useSidebar();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell" data-sidebar-hidden={!isMobile && collapsed ? 'true' : undefined}>
|
<div className="min-h-screen">
|
||||||
<Topbar />
|
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="app-main">
|
<div className="pl-sidebar">
|
||||||
<main className="h-full overflow-y-auto p-6">{children}</main>
|
<Topbar />
|
||||||
|
<main className="p-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<SidebarProvider>
|
|
||||||
<AppShellFrame>{children}</AppShellFrame>
|
|
||||||
</SidebarProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface SidebarContextValue {
|
|
||||||
collapsed: boolean;
|
|
||||||
toggleCollapsed: () => void;
|
|
||||||
mobileOpen: boolean;
|
|
||||||
setMobileOpen: (open: boolean) => void;
|
|
||||||
isMobile: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOBILE_MAX_WIDTH = 767;
|
|
||||||
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
|
||||||
|
|
||||||
export function SidebarProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mediaQuery = window.matchMedia(`(max-width: ${String(MOBILE_MAX_WIDTH)}px)`);
|
|
||||||
|
|
||||||
const syncState = (matches: boolean): void => {
|
|
||||||
setIsMobile(matches);
|
|
||||||
if (!matches) {
|
|
||||||
setMobileOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
syncState(mediaQuery.matches);
|
|
||||||
|
|
||||||
const handleChange = (event: MediaQueryListEvent): void => {
|
|
||||||
syncState(event.matches);
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaQuery.addEventListener('change', handleChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mediaQuery.removeEventListener('change', handleChange);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarContext.Provider
|
|
||||||
value={{
|
|
||||||
collapsed,
|
|
||||||
toggleCollapsed: () => setCollapsed((value) => !value),
|
|
||||||
mobileOpen,
|
|
||||||
setMobileOpen,
|
|
||||||
isMobile,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SidebarContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSidebar(): SidebarContextValue {
|
|
||||||
const context = useContext(SidebarContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useSidebar must be used within SidebarProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -3,178 +3,58 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { MosaicLogo } from '@/components/ui/mosaic-logo';
|
|
||||||
import { useSidebar } from './sidebar-context';
|
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: React.JSX.Element;
|
icon: string;
|
||||||
}
|
|
||||||
|
|
||||||
function IconChat(): React.JSX.Element {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
>
|
|
||||||
<path d="M3 4.5A2.5 2.5 0 0 1 5.5 2h5A2.5 2.5 0 0 1 13 4.5v3A2.5 2.5 0 0 1 10.5 10H8l-3.5 3v-3H5.5A2.5 2.5 0 0 1 3 7.5z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconTasks(): React.JSX.Element {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
>
|
|
||||||
<path d="M6 3h7M6 8h7M6 13h7" />
|
|
||||||
<path d="M2.5 3.5 3.5 4.5 5 2.5M2.5 8.5 3.5 9.5 5 7.5M2.5 13.5 3.5 14.5 5 12.5" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconProjects(): React.JSX.Element {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
>
|
|
||||||
<path d="M2 4.5A1.5 1.5 0 0 1 3.5 3h3l1.5 1.5h4A1.5 1.5 0 0 1 13.5 6v5.5A1.5 1.5 0 0 1 12 13H3.5A1.5 1.5 0 0 1 2 11.5z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconSettings(): React.JSX.Element {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
>
|
|
||||||
<circle cx="8" cy="8" r="2.25" />
|
|
||||||
<path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.05 3.05l1.4 1.4M11.55 11.55l1.4 1.4M3.05 12.95l1.4-1.4M11.55 4.45l1.4-1.4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconAdmin(): React.JSX.Element {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
>
|
|
||||||
<path d="M8 1.75 13 3.5v3.58c0 3.12-1.88 5.94-5 7.17-3.12-1.23-5-4.05-5-7.17V3.5z" />
|
|
||||||
<path d="M6.25 7.75 7.5 9l2.5-2.5" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ label: 'Chat', href: '/chat', icon: <IconChat /> },
|
{ label: 'Chat', href: '/chat', icon: '💬' },
|
||||||
{ label: 'Tasks', href: '/tasks', icon: <IconTasks /> },
|
{ label: 'Tasks', href: '/tasks', icon: '📋' },
|
||||||
{ label: 'Projects', href: '/projects', icon: <IconProjects /> },
|
{ label: 'Projects', href: '/projects', icon: '📁' },
|
||||||
{ label: 'Settings', href: '/settings', icon: <IconSettings /> },
|
{ label: 'Settings', href: '/settings', icon: '⚙️' },
|
||||||
{ label: 'Admin', href: '/admin', icon: <IconAdmin /> },
|
{ label: 'Admin', href: '/admin', icon: '🛡️' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar(): React.ReactElement {
|
export function Sidebar(): React.ReactElement {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { mobileOpen, setMobileOpen } = useSidebar();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<aside className="fixed left-0 top-0 z-30 flex h-screen w-sidebar flex-col border-r border-surface-border bg-surface-card">
|
||||||
<aside
|
<div className="flex h-14 items-center px-4">
|
||||||
className="app-sidebar"
|
<Link href="/" className="text-lg font-semibold text-text-primary">
|
||||||
data-mobile-open={mobileOpen ? 'true' : undefined}
|
Mosaic
|
||||||
style={{
|
</Link>
|
||||||
width: 'var(--sidebar-w)',
|
</div>
|
||||||
background: 'var(--surface)',
|
|
||||||
borderRightColor: 'var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex h-16 items-center gap-3 border-b px-5"
|
|
||||||
style={{ borderColor: 'var(--border)' }}
|
|
||||||
>
|
|
||||||
<MosaicLogo size={32} />
|
|
||||||
<div className="flex min-w-0 flex-col">
|
|
||||||
<span className="text-sm font-semibold uppercase tracking-[0.12em] text-[var(--text)]">
|
|
||||||
Mosaic
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-[var(--muted)]">Mission Control</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 px-3 py-4">
|
<nav className="flex-1 space-y-1 px-2 py-2">
|
||||||
<div className="mb-3 px-2 text-[11px] font-medium uppercase tracking-[0.18em] text-[var(--muted)]">
|
{navItems.map((item) => {
|
||||||
Workspace
|
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
</div>
|
return (
|
||||||
<div className="space-y-1.5">
|
<Link
|
||||||
{navItems.map((item) => {
|
key={item.href}
|
||||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600/20 text-blue-400'
|
||||||
|
: 'text-text-secondary hover:bg-surface-elevated hover:text-text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-base" aria-hidden="true">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
return (
|
<div className="border-t border-surface-border p-4">
|
||||||
<Link
|
<p className="text-xs text-text-muted">Mosaic Stack v0.0.4</p>
|
||||||
key={item.href}
|
</div>
|
||||||
href={item.href}
|
</aside>
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className={cn(
|
|
||||||
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all duration-150',
|
|
||||||
isActive ? 'font-medium' : 'hover:bg-white/4',
|
|
||||||
)}
|
|
||||||
style={
|
|
||||||
isActive
|
|
||||||
? {
|
|
||||||
background: 'color-mix(in srgb, var(--primary) 18%, transparent)',
|
|
||||||
color: 'var(--primary)',
|
|
||||||
}
|
|
||||||
: { color: 'var(--text-2)' }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="shrink-0" aria-hidden="true">
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="border-t px-5 py-4" style={{ borderColor: 'var(--border)' }}>
|
|
||||||
<p className="text-xs text-[var(--muted)]">Mosaic Stack v0.0.4</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{mobileOpen ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Close sidebar"
|
|
||||||
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useTheme } from '@/providers/theme-provider';
|
|
||||||
|
|
||||||
interface ThemeToggleProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeToggle({ className = '' }: ThemeToggleProps): React.JSX.Element {
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className={`btn-ghost rounded-md p-2 ${className}`}
|
|
||||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
||||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
||||||
>
|
|
||||||
{theme === 'dark' ? (
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5"
|
|
||||||
style={{ color: 'var(--warn)' }}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="4" />
|
|
||||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5"
|
|
||||||
style={{ color: 'var(--text-2)' }}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
>
|
|
||||||
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,87 +1,42 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { signOut, useSession } from '@/lib/auth-client';
|
import { useSession, signOut } from '@/lib/auth-client';
|
||||||
import { ThemeToggle } from './theme-toggle';
|
|
||||||
import { useSidebar } from './sidebar-context';
|
|
||||||
|
|
||||||
function MenuIcon(): React.JSX.Element {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
>
|
|
||||||
<path d="M2 4h12M2 8h12M2 12h12" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Topbar(): React.ReactElement {
|
export function Topbar(): React.ReactElement {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
if (pathname.startsWith('/chat')) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSignOut(): Promise<void> {
|
async function handleSignOut(): Promise<void> {
|
||||||
await signOut();
|
await signOut();
|
||||||
router.replace('/login');
|
router.replace('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSidebarToggle(): void {
|
|
||||||
if (isMobile) {
|
|
||||||
setMobileOpen(!mobileOpen);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleCollapsed();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-surface-border bg-surface-card/80 px-6 backdrop-blur-sm">
|
||||||
className="app-header justify-between border-b px-4 md:px-6"
|
<div />
|
||||||
style={{
|
|
||||||
height: 'var(--topbar-h)',
|
|
||||||
background: 'color-mix(in srgb, var(--surface) 88%, transparent)',
|
|
||||||
borderBottomColor: 'var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSidebarToggle}
|
|
||||||
className="btn-ghost rounded-lg border p-2"
|
|
||||||
style={{ borderColor: 'var(--border)', color: 'var(--text-2)' }}
|
|
||||||
aria-label="Toggle sidebar"
|
|
||||||
>
|
|
||||||
<MenuIcon />
|
|
||||||
</button>
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<div className="text-sm font-medium text-[var(--text)]">Workspace</div>
|
|
||||||
<div className="text-xs text-[var(--muted)]">Unified agent operations</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<ThemeToggle />
|
|
||||||
{session?.user ? (
|
{session?.user ? (
|
||||||
<>
|
<>
|
||||||
<span className="hidden text-sm text-[var(--text-2)] sm:block">
|
<span className="text-sm text-text-secondary">
|
||||||
{session.user.name ?? session.user.email}
|
{session.user.name ?? session.user.email}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="rounded-md px-3 py-1.5 text-sm transition-colors"
|
className="rounded-md px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
||||||
style={{ color: 'var(--muted)' }}
|
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-[var(--muted)]">Not signed in</span>
|
<span className="text-sm text-text-muted">Not signed in</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
|
||||||
import { SsoProviderSection } from './sso-provider-section.js';
|
|
||||||
|
|
||||||
describe('SsoProviderSection', () => {
|
|
||||||
it('renders configured providers with callback, sync, and fallback details', () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<SsoProviderSection
|
|
||||||
loading={false}
|
|
||||||
providers={[
|
|
||||||
{
|
|
||||||
id: 'workos',
|
|
||||||
name: 'WorkOS',
|
|
||||||
protocols: ['oidc'],
|
|
||||||
configured: true,
|
|
||||||
loginMode: 'oidc',
|
|
||||||
callbackPath: '/api/auth/oauth2/callback/workos',
|
|
||||||
teamSync: { enabled: true, claim: 'organization_id' },
|
|
||||||
samlFallback: { configured: false, loginUrl: null },
|
|
||||||
warnings: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'keycloak',
|
|
||||||
name: 'Keycloak',
|
|
||||||
protocols: ['oidc', 'saml'],
|
|
||||||
configured: true,
|
|
||||||
loginMode: 'saml',
|
|
||||||
callbackPath: null,
|
|
||||||
teamSync: { enabled: true, claim: 'groups' },
|
|
||||||
samlFallback: {
|
|
||||||
configured: true,
|
|
||||||
loginUrl: 'https://sso.example.com/realms/mosaic/protocol/saml',
|
|
||||||
},
|
|
||||||
warnings: [],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(html).toContain('WorkOS');
|
|
||||||
expect(html).toContain('/api/auth/oauth2/callback/workos');
|
|
||||||
expect(html).toContain('Team sync claim: organization_id');
|
|
||||||
expect(html).toContain('SAML fallback: https://sso.example.com/realms/mosaic/protocol/saml');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
|
||||||
|
|
||||||
interface SsoProviderSectionProps {
|
|
||||||
providers: SsoProviderDiscovery[];
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SsoProviderSection({
|
|
||||||
providers,
|
|
||||||
loading,
|
|
||||||
}: SsoProviderSectionProps): React.ReactElement {
|
|
||||||
if (loading) {
|
|
||||||
return <p className="text-sm text-text-muted">Loading SSO providers...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuredProviders = providers.filter((provider) => provider.configured);
|
|
||||||
|
|
||||||
if (providers.length === 0 || configuredProviders.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<p className="text-sm text-text-muted">
|
|
||||||
No SSO providers configured. Set WorkOS or Keycloak environment variables to enable SSO.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{configuredProviders.map((provider) => (
|
|
||||||
<div
|
|
||||||
key={provider.id}
|
|
||||||
className="rounded-lg border border-surface-border bg-surface-card p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-text-primary">{provider.name}</h3>
|
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
{provider.protocols.join(' + ').toUpperCase()}
|
|
||||||
{provider.loginMode ? ` • primary ${provider.loginMode.toUpperCase()}` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-1 text-xs font-medium text-accent">
|
|
||||||
Enabled
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2 text-xs text-text-muted">
|
|
||||||
{provider.callbackPath && <p>Callback: {provider.callbackPath}</p>}
|
|
||||||
{provider.teamSync.enabled && provider.teamSync.claim && (
|
|
||||||
<p>Team sync claim: {provider.teamSync.claim}</p>
|
|
||||||
)}
|
|
||||||
{provider.samlFallback.configured && provider.samlFallback.loginUrl && (
|
|
||||||
<p>SAML fallback: {provider.samlFallback.loginUrl}</p>
|
|
||||||
)}
|
|
||||||
{provider.warnings.map((warning) => (
|
|
||||||
<p key={warning} className="text-warning">
|
|
||||||
{warning}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { CSSProperties } from 'react';
|
|
||||||
|
|
||||||
export interface MosaicLogoProps {
|
|
||||||
size?: number;
|
|
||||||
spinning?: boolean;
|
|
||||||
spinDuration?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MosaicLogo({
|
|
||||||
size = 36,
|
|
||||||
spinning = false,
|
|
||||||
spinDuration = 20,
|
|
||||||
className = '',
|
|
||||||
}: MosaicLogoProps): React.JSX.Element {
|
|
||||||
const scale = size / 36;
|
|
||||||
const squareSize = Math.round(14 * scale);
|
|
||||||
const circleSize = Math.round(11 * scale);
|
|
||||||
const borderRadius = Math.round(3 * scale);
|
|
||||||
|
|
||||||
const animationValue = spinning
|
|
||||||
? `mosaicLogoSpin ${String(spinDuration)}s linear infinite`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const containerStyle: CSSProperties = {
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
position: 'relative',
|
|
||||||
flexShrink: 0,
|
|
||||||
animation: animationValue,
|
|
||||||
transformOrigin: 'center',
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseSquareStyle: CSSProperties = {
|
|
||||||
position: 'absolute',
|
|
||||||
width: squareSize,
|
|
||||||
height: squareSize,
|
|
||||||
borderRadius,
|
|
||||||
};
|
|
||||||
|
|
||||||
const circleStyle: CSSProperties = {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
width: circleSize,
|
|
||||||
height: circleSize,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'var(--ms-pink-500)',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{spinning ? (
|
|
||||||
<style>{`
|
|
||||||
@keyframes mosaicLogoSpin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
) : null}
|
|
||||||
<div style={containerStyle} className={className} role="img" aria-label="Mosaic logo">
|
|
||||||
<div style={{ ...baseSquareStyle, top: 0, left: 0, background: 'var(--ms-blue-500)' }} />
|
|
||||||
<div style={{ ...baseSquareStyle, top: 0, right: 0, background: 'var(--ms-purple-500)' }} />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...baseSquareStyle,
|
|
||||||
bottom: 0,
|
|
||||||
right: 0,
|
|
||||||
background: 'var(--ms-teal-500)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...baseSquareStyle,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
background: 'var(--ms-amber-500)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={circleStyle} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MosaicLogo;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export type SsoProtocol = 'oidc' | 'saml';
|
|
||||||
export type SsoLoginMode = 'oidc' | 'saml' | null;
|
|
||||||
|
|
||||||
export interface SsoProviderDiscovery {
|
|
||||||
id: 'authentik' | 'workos' | 'keycloak';
|
|
||||||
name: string;
|
|
||||||
protocols: SsoProtocol[];
|
|
||||||
configured: boolean;
|
|
||||||
loginMode: SsoLoginMode;
|
|
||||||
callbackPath: string | null;
|
|
||||||
teamSync: {
|
|
||||||
enabled: boolean;
|
|
||||||
claim: string | null;
|
|
||||||
};
|
|
||||||
samlFallback: {
|
|
||||||
configured: boolean;
|
|
||||||
loginUrl: string | null;
|
|
||||||
};
|
|
||||||
warnings: string[];
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
||||||
|
|
||||||
export type Theme = 'dark' | 'light';
|
|
||||||
|
|
||||||
interface ThemeContextValue {
|
|
||||||
theme: Theme;
|
|
||||||
toggleTheme: () => void;
|
|
||||||
setTheme: (theme: Theme) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'mosaic-theme';
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
|
||||||
|
|
||||||
function getInitialTheme(): Theme {
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
|
||||||
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const storedTheme = window.localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (storedTheme === 'light' || storedTheme === 'dark') {
|
|
||||||
setThemeState(storedTheme);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.documentElement.setAttribute('data-theme', 'dark');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = useMemo<ThemeContextValue>(
|
|
||||||
() => ({
|
|
||||||
theme,
|
|
||||||
toggleTheme: () => setThemeState((current) => (current === 'dark' ? 'light' : 'dark')),
|
|
||||||
setTheme: (nextTheme) => setThemeState(nextTheme),
|
|
||||||
}),
|
|
||||||
[theme],
|
|
||||||
);
|
|
||||||
|
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme(): ThemeContextValue {
|
|
||||||
const context = useContext(ThemeContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useTheme must be used within ThemeProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -237,23 +237,14 @@ external clients. Authentication requires a valid BetterAuth session (cookie or
|
|||||||
|
|
||||||
### SSO (Optional)
|
### SSO (Optional)
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| --------------------------- | ---------------------------------------------------------------------------- |
|
| ------------------------- | ------------------------------ |
|
||||||
| `AUTHENTIK_CLIENT_ID` | Authentik OAuth2 client ID |
|
| `AUTHENTIK_CLIENT_ID` | Authentik OAuth2 client ID |
|
||||||
| `AUTHENTIK_CLIENT_SECRET` | Authentik OAuth2 client secret |
|
| `AUTHENTIK_CLIENT_SECRET` | Authentik OAuth2 client secret |
|
||||||
| `AUTHENTIK_ISSUER` | Authentik OIDC issuer URL |
|
| `AUTHENTIK_ISSUER` | Authentik OIDC issuer URL |
|
||||||
| `AUTHENTIK_TEAM_SYNC_CLAIM` | Optional claim used to derive team sync data (defaults to `groups`) |
|
|
||||||
| `WORKOS_CLIENT_ID` | WorkOS OAuth client ID |
|
|
||||||
| `WORKOS_CLIENT_SECRET` | WorkOS OAuth client secret |
|
|
||||||
| `WORKOS_ISSUER` | WorkOS OIDC issuer URL |
|
|
||||||
| `WORKOS_TEAM_SYNC_CLAIM` | Optional claim used to derive team sync data (defaults to `organization_id`) |
|
|
||||||
| `KEYCLOAK_CLIENT_ID` | Keycloak OAuth client ID |
|
|
||||||
| `KEYCLOAK_CLIENT_SECRET` | Keycloak OAuth client secret |
|
|
||||||
| `KEYCLOAK_ISSUER` | Keycloak realm issuer URL |
|
|
||||||
| `KEYCLOAK_TEAM_SYNC_CLAIM` | Optional claim used to derive team sync data (defaults to `groups`) |
|
|
||||||
| `KEYCLOAK_SAML_LOGIN_URL` | Optional SAML login URL used when OIDC is unavailable |
|
|
||||||
|
|
||||||
Each OIDC provider requires its client ID, client secret, and issuer URL together. If only part of a provider configuration is set, gateway startup logs a warning and that provider is skipped. Keycloak can fall back to SAML when `KEYCLOAK_SAML_LOGIN_URL` is configured.
|
All three Authentik variables must be set together. If only `AUTHENTIK_CLIENT_ID`
|
||||||
|
is set, a warning is logged and SSO is disabled.
|
||||||
|
|
||||||
### Agent
|
### Agent
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { betterAuth } from 'better-auth';
|
import { betterAuth } from 'better-auth';
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||||
import { admin } from 'better-auth/plugins';
|
import { admin } from 'better-auth/plugins';
|
||||||
import { genericOAuth, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
|
import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
|
||||||
import type { Db } from '@mosaic/db';
|
import type { Db } from '@mosaic/db';
|
||||||
import { buildGenericOidcProviderConfigs } from './sso.js';
|
|
||||||
|
|
||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
db: Db;
|
db: Db;
|
||||||
@@ -11,21 +10,118 @@ export interface AuthConfig {
|
|||||||
secret?: string;
|
secret?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Builds the list of enabled OAuth providers from environment variables. Exported for testing. */
|
||||||
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
||||||
return buildGenericOidcProviderConfigs() as GenericOAuthConfig[];
|
const providers: GenericOAuthConfig[] = [];
|
||||||
|
|
||||||
|
const authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']);
|
||||||
|
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
||||||
|
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||||
|
assertOptionalProviderConfig('Authentik SSO', {
|
||||||
|
required: ['AUTHENTIK_ISSUER', 'AUTHENTIK_CLIENT_ID', 'AUTHENTIK_CLIENT_SECRET'],
|
||||||
|
values: [authentikIssuer, authentikClientId, authentikClientSecret],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authentikIssuer && authentikClientId && authentikClientSecret) {
|
||||||
|
providers.push({
|
||||||
|
providerId: 'authentik',
|
||||||
|
clientId: authentikClientId,
|
||||||
|
clientSecret: authentikClientSecret,
|
||||||
|
issuer: authentikIssuer,
|
||||||
|
discoveryUrl: buildDiscoveryUrl(authentikIssuer),
|
||||||
|
scopes: ['openid', 'email', 'profile'],
|
||||||
|
requireIssuerValidation: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workosIssuer = normalizeIssuer(process.env['WORKOS_ISSUER']);
|
||||||
|
const workosClientId = process.env['WORKOS_CLIENT_ID'];
|
||||||
|
const workosClientSecret = process.env['WORKOS_CLIENT_SECRET'];
|
||||||
|
assertOptionalProviderConfig('WorkOS SSO', {
|
||||||
|
required: ['WORKOS_ISSUER', 'WORKOS_CLIENT_ID', 'WORKOS_CLIENT_SECRET'],
|
||||||
|
values: [workosIssuer, workosClientId, workosClientSecret],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (workosIssuer && workosClientId && workosClientSecret) {
|
||||||
|
providers.push({
|
||||||
|
providerId: 'workos',
|
||||||
|
clientId: workosClientId,
|
||||||
|
clientSecret: workosClientSecret,
|
||||||
|
issuer: workosIssuer,
|
||||||
|
discoveryUrl: buildDiscoveryUrl(workosIssuer),
|
||||||
|
scopes: ['openid', 'email', 'profile'],
|
||||||
|
requireIssuerValidation: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const keycloakIssuer = resolveKeycloakIssuer();
|
||||||
|
const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID'];
|
||||||
|
const keycloakClientSecret = process.env['KEYCLOAK_CLIENT_SECRET'];
|
||||||
|
assertOptionalProviderConfig('Keycloak SSO', {
|
||||||
|
required: ['KEYCLOAK_CLIENT_ID', 'KEYCLOAK_CLIENT_SECRET', 'KEYCLOAK_ISSUER'],
|
||||||
|
values: [keycloakClientId, keycloakClientSecret, keycloakIssuer],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (keycloakIssuer && keycloakClientId && keycloakClientSecret) {
|
||||||
|
providers.push(
|
||||||
|
keycloak({
|
||||||
|
clientId: keycloakClientId,
|
||||||
|
clientSecret: keycloakClientSecret,
|
||||||
|
issuer: keycloakIssuer,
|
||||||
|
scopes: ['openid', 'email', 'profile'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKeycloakIssuer(): string | undefined {
|
||||||
|
const issuer = normalizeIssuer(process.env['KEYCLOAK_ISSUER']);
|
||||||
|
if (issuer) {
|
||||||
|
return issuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = normalizeIssuer(process.env['KEYCLOAK_URL']);
|
||||||
|
const realm = process.env['KEYCLOAK_REALM']?.trim();
|
||||||
|
|
||||||
|
if (!baseUrl || !realm) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseUrl}/realms/${realm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDiscoveryUrl(issuer: string): string {
|
||||||
|
return `${issuer}/.well-known/openid-configuration`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIssuer(value: string | undefined): string | undefined {
|
||||||
|
return value?.trim().replace(/\/+$/, '') || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertOptionalProviderConfig(
|
||||||
|
providerName: string,
|
||||||
|
config: { required: string[]; values: Array<string | undefined> },
|
||||||
|
): void {
|
||||||
|
const hasAnyValue = config.values.some((value) => Boolean(value?.trim()));
|
||||||
|
const hasAllValues = config.values.every((value) => Boolean(value?.trim()));
|
||||||
|
|
||||||
|
if (!hasAnyValue || hasAllValues) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`@mosaic/auth: ${providerName} requires ${config.required.join(', ')}. Set them in your config or via the listed environment variables.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuth(config: AuthConfig) {
|
export function createAuth(config: AuthConfig) {
|
||||||
const { db, baseURL, secret } = config;
|
const { db, baseURL, secret } = config;
|
||||||
const oidcConfigs = buildOAuthProviders();
|
|
||||||
|
const oauthProviders = buildOAuthProviders();
|
||||||
const plugins =
|
const plugins =
|
||||||
oidcConfigs.length > 0
|
oauthProviders.length > 0 ? [genericOAuth({ config: oauthProviders })] : undefined;
|
||||||
? [
|
|
||||||
genericOAuth({
|
|
||||||
config: oidcConfigs,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||||
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
||||||
|
|||||||
@@ -1,12 +1 @@
|
|||||||
export { createAuth, type Auth, type AuthConfig } from './auth.js';
|
export { createAuth, type Auth, type AuthConfig } from './auth.js';
|
||||||
export {
|
|
||||||
buildGenericOidcProviderConfigs,
|
|
||||||
buildSsoDiscovery,
|
|
||||||
listSsoStartupWarnings,
|
|
||||||
type GenericOidcProviderConfig,
|
|
||||||
type SsoLoginMode,
|
|
||||||
type SsoProtocol,
|
|
||||||
type SsoProviderDiscovery,
|
|
||||||
type SsoTeamSyncConfig,
|
|
||||||
type SupportedSsoProviderId,
|
|
||||||
} from './sso.js';
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
buildGenericOidcProviderConfigs,
|
|
||||||
buildSsoDiscovery,
|
|
||||||
listSsoStartupWarnings,
|
|
||||||
} from './sso.js';
|
|
||||||
|
|
||||||
describe('SSO provider config helpers', () => {
|
|
||||||
it('builds OIDC configs for Authentik, WorkOS, and Keycloak when fully configured', () => {
|
|
||||||
const configs = buildGenericOidcProviderConfigs({
|
|
||||||
AUTHENTIK_CLIENT_ID: 'authentik-client',
|
|
||||||
AUTHENTIK_CLIENT_SECRET: 'authentik-secret',
|
|
||||||
AUTHENTIK_ISSUER: 'https://authentik.example.com',
|
|
||||||
WORKOS_CLIENT_ID: 'workos-client',
|
|
||||||
WORKOS_CLIENT_SECRET: 'workos-secret',
|
|
||||||
WORKOS_ISSUER: 'https://auth.workos.com/sso/client_123',
|
|
||||||
KEYCLOAK_CLIENT_ID: 'keycloak-client',
|
|
||||||
KEYCLOAK_CLIENT_SECRET: 'keycloak-secret',
|
|
||||||
KEYCLOAK_ISSUER: 'https://sso.example.com/realms/mosaic',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(configs.map((config) => config.providerId)).toEqual(['authentik', 'workos', 'keycloak']);
|
|
||||||
expect(configs.find((config) => config.providerId === 'workos')).toMatchObject({
|
|
||||||
discoveryUrl: 'https://auth.workos.com/sso/client_123/.well-known/openid-configuration',
|
|
||||||
pkce: true,
|
|
||||||
requireIssuerValidation: true,
|
|
||||||
});
|
|
||||||
expect(configs.find((config) => config.providerId === 'keycloak')).toMatchObject({
|
|
||||||
discoveryUrl: 'https://sso.example.com/realms/mosaic/.well-known/openid-configuration',
|
|
||||||
pkce: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes Keycloak SAML fallback when OIDC is not configured', () => {
|
|
||||||
const providers = buildSsoDiscovery({
|
|
||||||
KEYCLOAK_SAML_LOGIN_URL: 'https://sso.example.com/realms/mosaic/protocol/saml',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(providers.find((provider) => provider.id === 'keycloak')).toMatchObject({
|
|
||||||
configured: true,
|
|
||||||
loginMode: 'saml',
|
|
||||||
samlFallback: {
|
|
||||||
configured: true,
|
|
||||||
loginUrl: 'https://sso.example.com/realms/mosaic/protocol/saml',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports partial provider configuration as startup warnings', () => {
|
|
||||||
const warnings = listSsoStartupWarnings({
|
|
||||||
WORKOS_CLIENT_ID: 'workos-client',
|
|
||||||
KEYCLOAK_CLIENT_ID: 'keycloak-client',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(warnings).toContain(
|
|
||||||
'workos OIDC is partially configured. Missing: WORKOS_CLIENT_SECRET, WORKOS_ISSUER',
|
|
||||||
);
|
|
||||||
expect(warnings).toContain(
|
|
||||||
'keycloak OIDC is partially configured. Missing: KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
export type SupportedSsoProviderId = 'authentik' | 'workos' | 'keycloak';
|
|
||||||
export type SsoProtocol = 'oidc' | 'saml';
|
|
||||||
export type SsoLoginMode = 'oidc' | 'saml' | null;
|
|
||||||
|
|
||||||
type EnvMap = Record<string, string | undefined>;
|
|
||||||
|
|
||||||
export interface GenericOidcProviderConfig {
|
|
||||||
providerId: SupportedSsoProviderId;
|
|
||||||
clientId: string;
|
|
||||||
clientSecret: string;
|
|
||||||
discoveryUrl?: string;
|
|
||||||
issuer?: string;
|
|
||||||
authorizationUrl?: string;
|
|
||||||
tokenUrl?: string;
|
|
||||||
userInfoUrl?: string;
|
|
||||||
scopes: string[];
|
|
||||||
pkce?: boolean;
|
|
||||||
requireIssuerValidation?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SsoTeamSyncConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
claim: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SsoProviderDiscovery {
|
|
||||||
id: SupportedSsoProviderId;
|
|
||||||
name: string;
|
|
||||||
protocols: SsoProtocol[];
|
|
||||||
configured: boolean;
|
|
||||||
loginMode: SsoLoginMode;
|
|
||||||
callbackPath: string | null;
|
|
||||||
teamSync: SsoTeamSyncConfig;
|
|
||||||
samlFallback: {
|
|
||||||
configured: boolean;
|
|
||||||
loginUrl: string | null;
|
|
||||||
};
|
|
||||||
warnings: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SCOPES = ['openid', 'email', 'profile'];
|
|
||||||
|
|
||||||
function readEnv(env: EnvMap, key: string): string | undefined {
|
|
||||||
const value = env[key]?.trim();
|
|
||||||
return value ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDiscoveryUrl(issuer: string): string {
|
|
||||||
return `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTeamSyncClaim(env: EnvMap, envKey: string, fallbackClaim?: string): SsoTeamSyncConfig {
|
|
||||||
const claim = readEnv(env, envKey) ?? fallbackClaim ?? null;
|
|
||||||
return {
|
|
||||||
enabled: claim !== null,
|
|
||||||
claim,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAuthentikConfig(env: EnvMap): GenericOidcProviderConfig | null {
|
|
||||||
const issuer = readEnv(env, 'AUTHENTIK_ISSUER');
|
|
||||||
const clientId = readEnv(env, 'AUTHENTIK_CLIENT_ID');
|
|
||||||
const clientSecret = readEnv(env, 'AUTHENTIK_CLIENT_SECRET');
|
|
||||||
|
|
||||||
const fields = [issuer, clientId, clientSecret];
|
|
||||||
const presentCount = fields.filter(Boolean).length;
|
|
||||||
if (presentCount > 0 && presentCount < fields.length) {
|
|
||||||
throw new Error(
|
|
||||||
'@mosaic/auth: Authentik SSO requires AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!issuer || !clientId || !clientSecret) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseIssuer = issuer.replace(/\/$/, '');
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerId: 'authentik',
|
|
||||||
issuer: baseIssuer,
|
|
||||||
clientId,
|
|
||||||
clientSecret,
|
|
||||||
discoveryUrl: toDiscoveryUrl(baseIssuer),
|
|
||||||
authorizationUrl: `${baseIssuer}/application/o/authorize/`,
|
|
||||||
tokenUrl: `${baseIssuer}/application/o/token/`,
|
|
||||||
userInfoUrl: `${baseIssuer}/application/o/userinfo/`,
|
|
||||||
scopes: DEFAULT_SCOPES,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWorkosConfig(env: EnvMap): GenericOidcProviderConfig | null {
|
|
||||||
const issuer = readEnv(env, 'WORKOS_ISSUER');
|
|
||||||
const clientId = readEnv(env, 'WORKOS_CLIENT_ID');
|
|
||||||
const clientSecret = readEnv(env, 'WORKOS_CLIENT_SECRET');
|
|
||||||
|
|
||||||
const fields = [issuer, clientId, clientSecret];
|
|
||||||
const presentCount = fields.filter(Boolean).length;
|
|
||||||
if (presentCount > 0 && presentCount < fields.length) {
|
|
||||||
throw new Error(
|
|
||||||
'@mosaic/auth: WorkOS SSO requires WORKOS_ISSUER, WORKOS_CLIENT_ID, WORKOS_CLIENT_SECRET.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!issuer || !clientId || !clientSecret) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedIssuer = issuer.replace(/\/$/, '');
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerId: 'workos',
|
|
||||||
issuer: normalizedIssuer,
|
|
||||||
clientId,
|
|
||||||
clientSecret,
|
|
||||||
discoveryUrl: toDiscoveryUrl(normalizedIssuer),
|
|
||||||
scopes: DEFAULT_SCOPES,
|
|
||||||
pkce: true,
|
|
||||||
requireIssuerValidation: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildKeycloakConfig(env: EnvMap): GenericOidcProviderConfig | null {
|
|
||||||
const explicitIssuer = readEnv(env, 'KEYCLOAK_ISSUER');
|
|
||||||
const keycloakUrl = readEnv(env, 'KEYCLOAK_URL');
|
|
||||||
const keycloakRealm = readEnv(env, 'KEYCLOAK_REALM');
|
|
||||||
const clientId = readEnv(env, 'KEYCLOAK_CLIENT_ID');
|
|
||||||
const clientSecret = readEnv(env, 'KEYCLOAK_CLIENT_SECRET');
|
|
||||||
|
|
||||||
// Derive issuer from KEYCLOAK_URL + KEYCLOAK_REALM if KEYCLOAK_ISSUER not set
|
|
||||||
const issuer =
|
|
||||||
explicitIssuer ??
|
|
||||||
(keycloakUrl && keycloakRealm
|
|
||||||
? `${keycloakUrl.replace(/\/$/, '')}/realms/${keycloakRealm}`
|
|
||||||
: undefined);
|
|
||||||
|
|
||||||
const anySet = !!(issuer || clientId || clientSecret);
|
|
||||||
if (anySet && (!issuer || !clientId || !clientSecret)) {
|
|
||||||
throw new Error(
|
|
||||||
'@mosaic/auth: Keycloak SSO requires KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!issuer || !clientId || !clientSecret) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedIssuer = issuer.replace(/\/$/, '');
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerId: 'keycloak',
|
|
||||||
issuer: normalizedIssuer,
|
|
||||||
clientId,
|
|
||||||
clientSecret,
|
|
||||||
discoveryUrl: toDiscoveryUrl(normalizedIssuer),
|
|
||||||
scopes: DEFAULT_SCOPES,
|
|
||||||
pkce: true,
|
|
||||||
requireIssuerValidation: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectWarnings(env: EnvMap, provider: SupportedSsoProviderId): string[] {
|
|
||||||
const prefix = provider.toUpperCase();
|
|
||||||
const oidcFields = [
|
|
||||||
`${prefix}_CLIENT_ID`,
|
|
||||||
`${prefix}_CLIENT_SECRET`,
|
|
||||||
`${prefix}_ISSUER`,
|
|
||||||
] as const;
|
|
||||||
const presentOidcFields = oidcFields.filter((field) => readEnv(env, field));
|
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
if (presentOidcFields.length > 0 && presentOidcFields.length < oidcFields.length) {
|
|
||||||
const missing = oidcFields.filter((field) => !readEnv(env, field));
|
|
||||||
warnings.push(`${provider} OIDC is partially configured. Missing: ${missing.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return warnings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildGenericOidcProviderConfigs(
|
|
||||||
env: EnvMap = process.env,
|
|
||||||
): GenericOidcProviderConfig[] {
|
|
||||||
return [buildAuthentikConfig(env), buildWorkosConfig(env), buildKeycloakConfig(env)].filter(
|
|
||||||
(config): config is GenericOidcProviderConfig => config !== null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listSsoStartupWarnings(env: EnvMap = process.env): string[] {
|
|
||||||
return ['authentik', 'workos', 'keycloak'].flatMap((provider) =>
|
|
||||||
collectWarnings(env, provider as SupportedSsoProviderId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSsoDiscovery(env: EnvMap = process.env): SsoProviderDiscovery[] {
|
|
||||||
const oidcConfigs = new Map(
|
|
||||||
buildGenericOidcProviderConfigs(env).map((config) => [config.providerId, config]),
|
|
||||||
);
|
|
||||||
const keycloakSamlLoginUrl = readEnv(env, 'KEYCLOAK_SAML_LOGIN_URL') ?? null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'authentik',
|
|
||||||
name: 'Authentik',
|
|
||||||
protocols: ['oidc'],
|
|
||||||
configured: oidcConfigs.has('authentik'),
|
|
||||||
loginMode: oidcConfigs.has('authentik') ? 'oidc' : null,
|
|
||||||
callbackPath: oidcConfigs.has('authentik') ? '/api/auth/oauth2/callback/authentik' : null,
|
|
||||||
teamSync: getTeamSyncClaim(env, 'AUTHENTIK_TEAM_SYNC_CLAIM', 'groups'),
|
|
||||||
samlFallback: {
|
|
||||||
configured: false,
|
|
||||||
loginUrl: null,
|
|
||||||
},
|
|
||||||
warnings: collectWarnings(env, 'authentik'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'workos',
|
|
||||||
name: 'WorkOS',
|
|
||||||
protocols: ['oidc'],
|
|
||||||
configured: oidcConfigs.has('workos'),
|
|
||||||
loginMode: oidcConfigs.has('workos') ? 'oidc' : null,
|
|
||||||
callbackPath: oidcConfigs.has('workos') ? '/api/auth/oauth2/callback/workos' : null,
|
|
||||||
teamSync: getTeamSyncClaim(env, 'WORKOS_TEAM_SYNC_CLAIM', 'organization_id'),
|
|
||||||
samlFallback: {
|
|
||||||
configured: false,
|
|
||||||
loginUrl: null,
|
|
||||||
},
|
|
||||||
warnings: collectWarnings(env, 'workos'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'keycloak',
|
|
||||||
name: 'Keycloak',
|
|
||||||
protocols: ['oidc', 'saml'],
|
|
||||||
configured: oidcConfigs.has('keycloak') || keycloakSamlLoginUrl !== null,
|
|
||||||
loginMode: oidcConfigs.has('keycloak') ? 'oidc' : keycloakSamlLoginUrl ? 'saml' : null,
|
|
||||||
callbackPath: oidcConfigs.has('keycloak') ? '/api/auth/oauth2/callback/keycloak' : null,
|
|
||||||
teamSync: getTeamSyncClaim(env, 'KEYCLOAK_TEAM_SYNC_CLAIM', 'groups'),
|
|
||||||
samlFallback: {
|
|
||||||
configured: keycloakSamlLoginUrl !== null,
|
|
||||||
loginUrl: keycloakSamlLoginUrl,
|
|
||||||
},
|
|
||||||
warnings: collectWarnings(env, 'keycloak'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user