Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ VITE_XAI_API_KEY=
VITE_SUPABASE_URL=https://<project-ref>.supabase.co
VITE_SUPABASE_ANON_KEY=<anon-key-from-supabase-dashboard>
VITE_API_BASE_URL=https://api.kernelcad.com

# Show the "Continue with GitHub" button. Keep unset/false until the GitHub
# OAuth app is registered and the GitHub provider is enabled in Supabase Auth,
# otherwise the button errors. Email+password and Google work without this.
VITE_GITHUB_AUTH_ENABLED=false
2 changes: 2 additions & 0 deletions .github/workflows/deploy-cloudflare.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ jobs:
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }}
# GitHub OAuth provider is registered and enabled in Supabase Auth.
VITE_GITHUB_AUTH_ENABLED: 'true'
run: npm run build

- name: Deploy to Cloudflare Pages (kernelcad-app)
Expand Down
71 changes: 71 additions & 0 deletions src/funnel/components/EmailPasswordForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { EmailPasswordForm } from './EmailPasswordForm';

const signInWithPassword = vi.fn();
const signUp = vi.fn();
vi.mock('../lib/supabaseClient', () => ({
getSupabase: () => ({ auth: { signInWithPassword, signUp } }),
}));

afterEach(() => {
cleanup();
signInWithPassword.mockReset();
signUp.mockReset();
});

describe('EmailPasswordForm', () => {
it('renders email and password inputs and a submit button', () => {
render(<EmailPasswordForm redirectTo="https://x/y" onAuthenticated={vi.fn()} />);
expect(screen.getByLabelText(/email/i)).toBeTruthy();
expect(screen.getByLabelText(/password/i)).toBeTruthy();
expect(screen.getByRole('button', { name: /sign in/i })).toBeTruthy();
});

it('signs in with the entered credentials and calls onAuthenticated on success', async () => {
signInWithPassword.mockResolvedValue({ data: { session: { access_token: 't' } }, error: null });
const onAuthenticated = vi.fn();
render(<EmailPasswordForm redirectTo="https://x/y" onAuthenticated={onAuthenticated} />);

fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'demo@kernelcad.com' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'pw123!' } });
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));

await waitFor(() => expect(signInWithPassword).toHaveBeenCalledWith({
email: 'demo@kernelcad.com',
password: 'pw123!',
}));
await waitFor(() => expect(onAuthenticated).toHaveBeenCalledTimes(1));
});

it('shows the error message when sign-in fails and does not navigate', async () => {
signInWithPassword.mockResolvedValue({ data: { session: null }, error: { message: 'Invalid login credentials' } });
const onAuthenticated = vi.fn();
render(<EmailPasswordForm redirectTo="https://x/y" onAuthenticated={onAuthenticated} />);

fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'a@b.c' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } });
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));

await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/invalid login credentials/i));
expect(onAuthenticated).not.toHaveBeenCalled();
});

it('creates an account via signUp when toggled to register mode', async () => {
signUp.mockResolvedValue({ data: { session: null, user: { id: 'u' } }, error: null });
render(<EmailPasswordForm redirectTo="https://x/y" onAuthenticated={vi.fn()} />);

fireEvent.click(screen.getByRole('button', { name: /create an account/i }));
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@user.com' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'pw123!' } });
fireEvent.click(screen.getByRole('button', { name: /^create account$/i }));

await waitFor(() => expect(signUp).toHaveBeenCalledWith(
expect.objectContaining({ email: 'new@user.com', password: 'pw123!' }),
));
expect(signInWithPassword).not.toHaveBeenCalled();
});
});
131 changes: 131 additions & 0 deletions src/funnel/components/EmailPasswordForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
import { useState } from 'react';
import { getSupabase } from '../lib/supabaseClient';

export interface EmailPasswordFormProps {
/** Where to land after a successful sign-in. */
redirectTo: string;
/** Called after a session is established. Defaults to navigating to
* `redirectTo` so the app reloads with the persisted session (mirrors the
* OAuth redirect UX). Injectable for tests. */
onAuthenticated?: () => void;
}

type Mode = 'signin' | 'signup';

/**
* Email + password authentication, an alternative to the OAuth buttons. Sign-in
* resolves a session immediately (no email round-trip) which is what makes a
* pre-seeded demo account usable by reviewers. Sign-up creates an account; if
* the project requires email confirmation the user is told to check their inbox.
*/
export function EmailPasswordForm({ redirectTo, onAuthenticated }: EmailPasswordFormProps) {
const [mode, setMode] = useState<Mode>('signin');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);

const navigate = onAuthenticated ?? (() => window.location.assign(redirectTo));

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
setNotice(null);
const supabase = getSupabase();

if (mode === 'signin') {
const { data, error: err } = await supabase.auth.signInWithPassword({ email, password });
setLoading(false);
if (err) { setError(err.message); return; }
if (data?.session) { navigate(); return; }
setError('Sign-in did not return a session. Please try again.');
return;
}

const { data, error: err } = await supabase.auth.signUp({ email, password });
setLoading(false);
if (err) { setError(err.message); return; }
if (data?.session) { navigate(); return; }
// No session => email confirmation required.
setNotice('Account created. Check your email to confirm, then sign in.');
setMode('signin');
}

return (
<form onSubmit={handleSubmit} className="text-left space-y-3" noValidate>
<div>
<label htmlFor="kc-auth-email" className="block text-xs font-medium text-ink-soft mb-1">
Email
</label>
<input
id="kc-auth-email"
type="email"
autoComplete="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full rounded-lg border border-rule bg-white px-3 py-2 text-sm text-ink focus:border-blueprint focus:outline-none"
/>
</div>
<div>
<label htmlFor="kc-auth-password" className="block text-xs font-medium text-ink-soft mb-1">
Password
</label>
<input
id="kc-auth-password"
type="password"
autoComplete={mode === 'signin' ? 'current-password' : 'new-password'}
required
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full rounded-lg border border-rule bg-white px-3 py-2 text-sm text-ink focus:border-blueprint focus:outline-none"
/>
</div>

{error && (
<p role="alert" className="text-xs text-red-600">{error}</p>
)}
{notice && (
<p role="status" className="text-xs text-green-700">{notice}</p>
)}

<button
type="submit"
disabled={loading}
className="inline-flex w-full items-center justify-center rounded-lg bg-blueprint hover:bg-blueprint-hover text-white px-4 py-2 text-sm font-medium disabled:opacity-50 transition-colors font-sans"
>
{loading ? 'Please wait…' : mode === 'signin' ? 'Sign in' : 'Create account'}
</button>

<p className="text-xs text-ink-faint text-center">
{mode === 'signin' ? (
<>
No account?{' '}
<button
type="button"
onClick={() => { setMode('signup'); setError(null); setNotice(null); }}
className="text-blueprint hover:underline font-medium"
>
Create an account
</button>
</>
) : (
<>
Already have one?{' '}
<button
type="button"
onClick={() => { setMode('signin'); setError(null); setNotice(null); }}
className="text-blueprint hover:underline font-medium"
>
Sign in
</button>
</>
)}
</p>
</form>
);
}
36 changes: 36 additions & 0 deletions src/funnel/components/SignInButton.github.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { SignInButton } from './SignInButton';

const signInWithOAuth = vi.fn();
vi.mock('../lib/supabaseClient', () => ({
getSupabase: () => ({ auth: { signInWithOAuth } }),
}));

afterEach(() => {
cleanup();
signInWithOAuth.mockReset();
});

describe('SignInButton provider', () => {
it('defaults to Google', async () => {
signInWithOAuth.mockResolvedValue({ error: null });
render(<SignInButton redirectTo="https://x/y">Continue with Google</SignInButton>);
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(signInWithOAuth).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'google', options: { redirectTo: 'https://x/y' } }),
));
});

it('uses GitHub when provider="github"', async () => {
signInWithOAuth.mockResolvedValue({ error: null });
render(<SignInButton provider="github" redirectTo="https://x/y">Continue with GitHub</SignInButton>);
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(signInWithOAuth).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'github', options: { redirectTo: 'https://x/y' } }),
));
});
});
38 changes: 27 additions & 11 deletions src/funnel/components/SignInButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,38 @@
import { useState } from 'react';
import { getSupabase } from '../lib/supabaseClient';

export type OAuthProvider = 'google' | 'github';

export interface SignInButtonProps {
/** OAuth provider to authenticate with. Defaults to Google. */
provider?: OAuthProvider;
/** Where to redirect after successful auth. Defaults to current location. */
redirectTo?: string;
className?: string;
children?: React.ReactNode;
}

export function SignInButton({ redirectTo, className, children }: SignInButtonProps) {
const DEFAULT_LABEL: Record<OAuthProvider, string> = {
google: 'Continue with Google',
github: 'Continue with GitHub',
};

export function SignInButton({ provider = 'google', redirectTo, className, children }: SignInButtonProps) {
const [loading, setLoading] = useState(false);

async function handleClick() {
setLoading(true);
const supabase = getSupabase();
const target = redirectTo ?? window.location.href;
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
provider,
options: { redirectTo: target },
});
if (error) {
setLoading(false);
alert(`Sign-in failed: ${error.message}`);
}
// Successful OAuth redirects to Google; component unmounts.
// Successful OAuth redirects to the provider; component unmounts.
}

return (
Expand All @@ -38,14 +47,21 @@ export function SignInButton({ redirectTo, className, children }: SignInButtonPr
'inline-flex items-center gap-2 rounded-lg bg-blueprint hover:bg-blueprint-hover text-white px-4 py-2 text-sm font-medium disabled:opacity-50 transition-colors font-sans'
}
>
{/* Google G mark — colours retained per Google brand guidelines */}
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.79 2.71v2.26h2.9c1.7-1.56 2.69-3.87 2.69-6.61z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.47-.81 5.96-2.18l-2.9-2.26c-.8.54-1.83.86-3.06.86-2.35 0-4.35-1.59-5.06-3.72H.96v2.33A9 9 0 0 0 9 18z"/>
<path fill="#FBBC05" d="M3.94 10.7a5.4 5.4 0 0 1 0-3.4V4.96H.96a9 9 0 0 0 0 8.08l2.98-2.34z"/>
<path fill="#EA4335" d="M9 3.58c1.32 0 2.51.45 3.44 1.34l2.58-2.58A9 9 0 0 0 .96 4.96l2.98 2.34C4.65 5.17 6.65 3.58 9 3.58z"/>
</svg>
{loading ? 'Signing in…' : (children ?? 'Continue with Google')}
{provider === 'google' ? (
/* Google G mark — colours retained per Google brand guidelines */
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.79 2.71v2.26h2.9c1.7-1.56 2.69-3.87 2.69-6.61z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.47-.81 5.96-2.18l-2.9-2.26c-.8.54-1.83.86-3.06.86-2.35 0-4.35-1.59-5.06-3.72H.96v2.33A9 9 0 0 0 9 18z"/>
<path fill="#FBBC05" d="M3.94 10.7a5.4 5.4 0 0 1 0-3.4V4.96H.96a9 9 0 0 0 0 8.08l2.98-2.34z"/>
<path fill="#EA4335" d="M9 3.58c1.32 0 2.51.45 3.44 1.34l2.58-2.58A9 9 0 0 0 .96 4.96l2.98 2.34C4.65 5.17 6.65 3.58 9 3.58z"/>
</svg>
) : (
/* GitHub Octocat mark */
<svg width="18" height="18" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
</svg>
)}
{loading ? 'Signing in…' : (children ?? DEFAULT_LABEL[provider])}
</button>
);
}
28 changes: 24 additions & 4 deletions src/funnel/components/SignInModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
import { useEffect, useRef } from 'react';
import { SignInButton } from './SignInButton';
import { EmailPasswordForm } from './EmailPasswordForm';

export interface SignInModalProps {
open: boolean;
Expand Down Expand Up @@ -86,10 +87,29 @@ export function SignInModal({
</h2>
<p className="text-ink-soft text-sm mt-2 leading-relaxed">{description}</p>

<div className="mt-6 flex justify-center">
<SignInButton redirectTo={redirectTo ?? window.location.href}>
Continue with Google
</SignInButton>
<div className="mt-6">
<EmailPasswordForm redirectTo={redirectTo ?? window.location.href} />
</div>

<div className="my-5 flex items-center gap-3">
<span className="h-px flex-1 bg-rule" />
<span className="text-xs text-ink-faint">or</span>
<span className="h-px flex-1 bg-rule" />
</div>

<div className="flex flex-col gap-2">
<SignInButton
provider="google"
redirectTo={redirectTo ?? window.location.href}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-rule bg-white hover:bg-paper text-ink px-4 py-2 text-sm font-medium disabled:opacity-50 transition-colors font-sans"
/>
{import.meta.env.VITE_GITHUB_AUTH_ENABLED === 'true' && (
<SignInButton
provider="github"
redirectTo={redirectTo ?? window.location.href}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-rule bg-white hover:bg-paper text-ink px-4 py-2 text-sm font-medium disabled:opacity-50 transition-colors font-sans"
/>
)}
</div>

<p className="mt-5 text-xs text-ink-faint font-mono tracking-wide">
Expand Down
Loading
Loading