mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-31 19:15:17 +02:00
feat: integrate Supabase OAuth with OIDC discovery for authentication
Add rowboat auth flow using Supabase as the OIDC provider. User info is fetched via the standard OIDC userinfo endpoint (discovered from issuer metadata) instead of a hard-coded Supabase URL. Includes login screen, auth state hook, IPC handlers, logout button, and id_token_sub persistence for userinfo fetches across app restarts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
84c101fa21
commit
bbe82c124d
10 changed files with 368 additions and 8 deletions
|
|
@ -50,6 +50,8 @@ import { Separator } from "@/components/ui/separator"
|
|||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { OnboardingModal } from '@/components/onboarding-modal'
|
||||
import { useRowboatAuth } from '@/hooks/useRowboatAuth'
|
||||
import { LoginScreen } from '@/components/login-screen'
|
||||
|
||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||
type RunEventType = z.infer<typeof RunEvent>
|
||||
|
|
@ -441,6 +443,24 @@ function ChatInputWithMentions({
|
|||
}
|
||||
|
||||
function App() {
|
||||
const auth = useRowboatAuth()
|
||||
|
||||
if (auth.isLoading) {
|
||||
return (
|
||||
<div className="flex h-svh w-full items-center justify-center bg-background">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return <LoginScreen isLoggingIn={auth.isLoggingIn} error={auth.error} login={auth.login} />
|
||||
}
|
||||
|
||||
return <AppContent auth={auth} />
|
||||
}
|
||||
|
||||
function AppContent({ auth }: { auth: ReturnType<typeof useRowboatAuth> }) {
|
||||
// File browser state (for Knowledge section)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [fileHistoryBack, setFileHistoryBack] = useState<string[]>([])
|
||||
|
|
@ -1669,7 +1689,7 @@ function App() {
|
|||
<SidebarSectionProvider defaultSection="tasks" onSectionChange={handleSectionChange}>
|
||||
<div className="flex h-svh w-full">
|
||||
{/* Icon sidebar - always visible, fixed position */}
|
||||
<SidebarIcon />
|
||||
<SidebarIcon user={auth.user} onLogout={auth.logout} />
|
||||
|
||||
{/* Spacer for the fixed icon sidebar */}
|
||||
<div className="w-14 shrink-0" />
|
||||
|
|
|
|||
51
apps/x/apps/renderer/src/components/login-screen.tsx
Normal file
51
apps/x/apps/renderer/src/components/login-screen.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Button } from './ui/button';
|
||||
import { LoaderIcon } from 'lucide-react';
|
||||
|
||||
interface LoginScreenProps {
|
||||
isLoggingIn: boolean;
|
||||
error: string | null;
|
||||
login: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function LoginScreen({ isLoggingIn, error, login }: LoginScreenProps) {
|
||||
return (
|
||||
<div className="flex h-svh w-full items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-6 max-w-sm text-center px-4">
|
||||
<div className="text-4xl font-semibold tracking-tight text-foreground/80">
|
||||
Rowboat
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sign in to your Rowboat account to continue.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="w-full rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={login}
|
||||
disabled={isLoggingIn}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
|
||||
Waiting for browser...
|
||||
</>
|
||||
) : (
|
||||
'Sign in to Rowboat'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isLoggingIn && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Complete sign-in in your browser, then return here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import * as React from "react"
|
|||
import {
|
||||
Brain,
|
||||
HelpCircle,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Plug,
|
||||
Settings,
|
||||
|
|
@ -31,7 +32,12 @@ const navItems: NavItem[] = [
|
|||
{ id: "knowledge", title: "Knowledge", icon: Brain },
|
||||
]
|
||||
|
||||
export function SidebarIcon() {
|
||||
interface SidebarIconProps {
|
||||
user?: { email: string; name?: string } | null;
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
export function SidebarIcon({ user, onLogout }: SidebarIconProps = {}) {
|
||||
const { activeSection, setActiveSection } = useSidebarSection()
|
||||
|
||||
return (
|
||||
|
|
@ -88,6 +94,23 @@ export function SidebarIcon() {
|
|||
<HelpCircle className="size-5" />
|
||||
</button>
|
||||
</HelpPopover>
|
||||
|
||||
{/* Sign out */}
|
||||
{onLogout && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<LogOut className="size-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{user?.email ? `Sign out (${user.email})` : 'Sign out'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
97
apps/x/apps/renderer/src/hooks/useRowboatAuth.ts
Normal file
97
apps/x/apps/renderer/src/hooks/useRowboatAuth.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface RowboatAuthState {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isLoggingIn: boolean;
|
||||
user: { email: string; name?: string } | null;
|
||||
error: string | null;
|
||||
login: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useRowboatAuth(): RowboatAuthState {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [user, setUser] = useState<{ email: string; name?: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Check auth status on mount
|
||||
useEffect(() => {
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('auth:getStatus', null);
|
||||
setIsAuthenticated(result.isAuthenticated);
|
||||
setUser(result.user);
|
||||
} catch (err) {
|
||||
console.error('Failed to check auth status:', err);
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
checkStatus();
|
||||
}, []);
|
||||
|
||||
// Listen for auth events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('auth:didAuthenticate', (event) => {
|
||||
setIsAuthenticated(event.isAuthenticated);
|
||||
setUser(event.user);
|
||||
setIsLoggingIn(false);
|
||||
setError(null);
|
||||
});
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
// Also listen for oauth:didConnect for the rowboat provider (handles errors)
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
||||
if (event.provider !== 'rowboat') return;
|
||||
if (!event.success) {
|
||||
setIsLoggingIn(false);
|
||||
setError(event.error || 'Login failed');
|
||||
}
|
||||
});
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async () => {
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
setError(null);
|
||||
const result = await window.ipc.invoke('auth:login', null);
|
||||
if (!result.success) {
|
||||
setIsLoggingIn(false);
|
||||
setError(result.error || 'Failed to start login');
|
||||
}
|
||||
// If success, the OAuth flow has started - wait for auth:didAuthenticate event
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err);
|
||||
setIsLoggingIn(false);
|
||||
setError('Failed to start login');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await window.ipc.invoke('auth:logout', null);
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
} catch (err) {
|
||||
console.error('Logout failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
isLoggingIn,
|
||||
user,
|
||||
error,
|
||||
login,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue