Skip to content

Commit 167fdc1

Browse files
committed
feat: enhance authentication flow and session management, add RLS fixes release:patch
1 parent f7a548b commit 167fdc1

File tree

9 files changed

+216
-37
lines changed

9 files changed

+216
-37
lines changed

.claude/settings.local.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,10 @@
199199
"Bash(gh api:*)",
200200
"Bash(set -a)",
201201
"Bash(source .env)",
202-
"Bash(set +a)"
202+
"Bash(set +a)",
203+
"Bash(gh workflow run:*)",
204+
"Bash(gh workflow:*)",
205+
"Bash(pnpm -F @pairux/web typecheck:*)"
203206
],
204207
"deny": [
205208
"Bash(npm *)",

apps/desktop/electron.vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export default defineConfig({
2727
},
2828
renderer: {
2929
root: 'src/renderer',
30+
publicDir: resolve(__dirname, 'public'),
3031
build: {
3132
outDir: 'dist/renderer',
3233
rollupOptions: {

apps/desktop/src/main/ipc/auth.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,23 +58,26 @@ export function registerAuthHandlers(): void {
5858
args: { email: string; password: string }
5959
): Promise<{ success: true; user: AuthUser } | { success: false; error: string }> => {
6060
try {
61-
const result = await apiRequest<{ user: AuthUser }>('/api/auth/login', {
61+
const result = await apiRequest<{
62+
user: AuthUser;
63+
session: { accessToken: string; refreshToken: string; expiresAt: number };
64+
}>('/api/auth/login', {
6265
method: 'POST',
6366
body: JSON.stringify({ email: args.email, password: args.password }),
6467
});
6568

66-
if (!result.success || !result.data?.user) {
69+
if (!result.success || !result.data) {
6770
console.log('[Auth] Login failed:', result.error);
6871
return { success: false, error: result.error ?? 'Invalid credentials' };
6972
}
7073

71-
const user = result.data.user;
74+
const { user, session } = result.data;
7275

73-
// Store user info (no tokens needed since we use API)
76+
// Store real access token for API authentication
7477
storeAuth({
75-
accessToken: 'api-session', // Placeholder - auth is cookie-based on API
76-
refreshToken: '',
77-
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days
78+
accessToken: session.accessToken,
79+
refreshToken: session.refreshToken,
80+
expiresAt: session.expiresAt * 1000, // Convert to milliseconds
7881
user: { id: user.id, email: user.email },
7982
});
8083

apps/desktop/src/main/window.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,34 @@
1-
import { BrowserWindow, shell, session, desktopCapturer } from 'electron';
1+
import { BrowserWindow, shell, session, desktopCapturer, nativeImage } from 'electron';
22
import { join } from 'path';
33

44
const isDev = process.env.NODE_ENV === 'development';
55

6+
// Get the icon path based on environment
7+
function getIconPath(): string {
8+
if (isDev) {
9+
// Development: use resources folder relative to dist/main
10+
return join(__dirname, '../../resources/icon.png');
11+
}
12+
// Production: icon is in resources folder of the packaged app
13+
return join(__dirname, '../../resources/icon.png');
14+
}
15+
616
export async function createMainWindow(): Promise<BrowserWindow> {
17+
// Load the window icon
18+
const iconPath = getIconPath();
19+
const icon = nativeImage.createFromPath(iconPath);
20+
721
const mainWindow = new BrowserWindow({
822
width: 1200,
923
height: 800,
1024
minWidth: 800,
1125
minHeight: 600,
1226
show: false,
13-
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
27+
icon: icon.isEmpty() ? undefined : icon,
28+
// macOS: hidden inset for native look with traffic lights
29+
// Windows: custom title bar overlay
30+
// Linux: default titlebar to show window controls and menu
31+
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
1432
titleBarOverlay:
1533
process.platform === 'win32'
1634
? {

apps/desktop/src/renderer/components/capture/CapturePreview.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,11 @@ export function CapturePreview({
250250
quality: recordingQuality,
251251
format: 'webm',
252252
includeAudio: false,
253+
// Pass the existing stream so we don't need to create a new one
254+
// This is especially important for Wayland where the source ID isn't a valid chromeMediaSourceId
255+
existingStream: stream,
253256
});
254-
}, [source, recordingQuality, startRecording]);
257+
}, [source, recordingQuality, startRecording, stream]);
255258

256259
const handleStopRecording = useCallback(async () => {
257260
await stopRecording();

apps/desktop/src/renderer/hooks/useRecording.ts

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ interface RecordingOptions {
1313
format?: RecordingFormat;
1414
includeAudio?: boolean;
1515
customPath?: string;
16+
/** Existing stream to record from (for Wayland/getDisplayMedia captures) */
17+
existingStream?: MediaStream;
1618
}
1719

1820
interface RecordingState {
@@ -116,35 +118,46 @@ export function useRecording(options: UseRecordingOptions = {}) {
116118
format = 'webm',
117119
includeAudio = false,
118120
customPath,
121+
existingStream,
119122
} = recordingOptions;
120123

121124
try {
122125
// Get video constraints based on quality
123126
const preset = QUALITY_PRESETS[quality];
124127

125-
// Get media stream from the source
126-
// Electron's desktopCapturer requires non-standard mandatory constraints
127-
const constraints = {
128-
audio: includeAudio
129-
? {
130-
mandatory: {
131-
chromeMediaSource: 'desktop',
132-
},
133-
}
134-
: false,
135-
video: {
136-
mandatory: {
137-
chromeMediaSource: 'desktop',
138-
chromeMediaSourceId: sourceId,
139-
maxWidth: preset.width,
140-
maxHeight: preset.height,
141-
maxFrameRate: 30,
128+
let stream: MediaStream;
129+
let ownsStream = false;
130+
131+
if (existingStream) {
132+
// Use existing stream (e.g., from Wayland getDisplayMedia)
133+
stream = existingStream;
134+
ownsStream = false;
135+
} else {
136+
// Get media stream from the source
137+
// Electron's desktopCapturer requires non-standard mandatory constraints
138+
const constraints = {
139+
audio: includeAudio
140+
? {
141+
mandatory: {
142+
chromeMediaSource: 'desktop',
143+
},
144+
}
145+
: false,
146+
video: {
147+
mandatory: {
148+
chromeMediaSource: 'desktop',
149+
chromeMediaSourceId: sourceId,
150+
maxWidth: preset.width,
151+
maxHeight: preset.height,
152+
maxFrameRate: 30,
153+
},
142154
},
143-
},
144-
} as MediaStreamConstraints;
155+
} as MediaStreamConstraints;
145156

146-
const stream = await navigator.mediaDevices.getUserMedia(constraints);
147-
streamRef.current = stream;
157+
stream = await navigator.mediaDevices.getUserMedia(constraints);
158+
ownsStream = true;
159+
}
160+
streamRef.current = ownsStream ? stream : null; // Only track if we own it
148161

149162
// Start recording on main process (creates file)
150163
const startResult = (await electronAPI.invoke('recording:start', {
@@ -153,9 +166,12 @@ export function useRecording(options: UseRecordingOptions = {}) {
153166
})) as { success: boolean; path?: string; error?: string };
154167

155168
if (!startResult.success) {
156-
stream.getTracks().forEach((track) => {
157-
track.stop();
158-
});
169+
// Only stop tracks if we created the stream
170+
if (ownsStream) {
171+
stream.getTracks().forEach((track) => {
172+
track.stop();
173+
});
174+
}
159175
return { success: false, error: startResult.error };
160176
}
161177

apps/web/src/app/api/auth/login/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,21 @@ export async function POST(request: Request) {
1919
return errorResponse(error.message, 401);
2020
}
2121

22-
if (!data.user) {
22+
if (!data.user || !data.session) {
2323
return errorResponse('Invalid credentials', 401);
2424
}
2525

26+
// Return tokens for desktop app authentication
2627
return successResponse({
2728
user: {
2829
id: data.user.id,
2930
email: data.user.email,
3031
},
32+
session: {
33+
accessToken: data.session.access_token,
34+
refreshToken: data.session.refresh_token,
35+
expiresAt: data.session.expires_at,
36+
},
3137
});
3238
} catch (error) {
3339
return handleApiError(error);

apps/web/src/lib/supabase/server.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
/* eslint-disable @typescript-eslint/no-deprecated */
22
import { createServerClient, type CookieOptions } from '@supabase/ssr';
3-
import { cookies } from 'next/headers';
3+
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
4+
import { cookies, headers } from 'next/headers';
45
import type { Database } from '@pairux/shared-types';
56

7+
/**
8+
* Create a Supabase client for server-side use.
9+
* Supports both cookie-based auth (web browser) and Bearer token auth (desktop app).
10+
*/
611
export async function createClient() {
712
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
813
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
@@ -11,6 +16,27 @@ export async function createClient() {
1116
throw new Error('Missing Supabase environment variables');
1217
}
1318

19+
// Check for Bearer token in Authorization header (desktop app)
20+
const headerStore = await headers();
21+
const authHeader = headerStore.get('authorization');
22+
23+
if (authHeader?.startsWith('Bearer ')) {
24+
const token = authHeader.slice(7);
25+
// Desktop app: use token-based auth
26+
return createSupabaseClient<Database>(url, key, {
27+
global: {
28+
headers: {
29+
Authorization: `Bearer ${token}`,
30+
},
31+
},
32+
auth: {
33+
persistSession: false,
34+
autoRefreshToken: false,
35+
},
36+
});
37+
}
38+
39+
// Web browser: use cookie-based auth
1440
const cookieStore = await cookies();
1541

1642
return createServerClient<Database>(url, key, {
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
-- Fix remaining infinite recursion in RLS policies
2+
-- The recursion happens when:
3+
-- 1. Query sessions → "Participants can view their sessions" → EXISTS on session_participants
4+
-- 2. session_participants RLS → "Host can view session participants" → EXISTS on sessions
5+
-- This creates a circular dependency.
6+
7+
-- The is_session_participant and is_session_host functions already exist from
8+
-- migration 20250123000002, but they haven't been applied to all problematic policies.
9+
10+
-- =============================================================================
11+
-- STEP 1: Fix sessions table policies that query session_participants
12+
-- =============================================================================
13+
14+
-- Drop the problematic policy on sessions table
15+
DROP POLICY IF EXISTS "Participants can view their sessions" ON public.sessions;
16+
17+
-- Recreate using SECURITY DEFINER helper function
18+
CREATE POLICY "Participants can view their sessions"
19+
ON public.sessions FOR SELECT
20+
USING (
21+
public.is_session_participant(id, auth.uid())
22+
);
23+
24+
-- =============================================================================
25+
-- STEP 2: Fix session_participants policies that query sessions
26+
-- =============================================================================
27+
28+
-- Drop and recreate "Host can view session participants"
29+
DROP POLICY IF EXISTS "Host can view session participants" ON public.session_participants;
30+
31+
CREATE POLICY "Host can view session participants"
32+
ON public.session_participants FOR SELECT
33+
USING (
34+
public.is_session_host(session_id, auth.uid())
35+
);
36+
37+
-- Drop and recreate "Host can update participants"
38+
DROP POLICY IF EXISTS "Host can update participants" ON public.session_participants;
39+
40+
CREATE POLICY "Host can update participants"
41+
ON public.session_participants FOR UPDATE
42+
USING (
43+
public.is_session_host(session_id, auth.uid())
44+
);
45+
46+
-- Drop and recreate "Host can delete participants"
47+
DROP POLICY IF EXISTS "Host can delete participants" ON public.session_participants;
48+
49+
CREATE POLICY "Host can delete participants"
50+
ON public.session_participants FOR DELETE
51+
USING (
52+
public.is_session_host(session_id, auth.uid())
53+
);
54+
55+
-- =============================================================================
56+
-- STEP 3: Fix media_sessions policies that query session_participants or sessions
57+
-- =============================================================================
58+
59+
-- Drop and recreate "Room participants can view media sessions"
60+
DROP POLICY IF EXISTS "Room participants can view media sessions" ON public.media_sessions;
61+
62+
CREATE POLICY "Room participants can view media sessions"
63+
ON public.media_sessions
64+
FOR SELECT
65+
TO authenticated
66+
USING (
67+
public.is_session_participant(room_id, auth.uid())
68+
OR public.is_session_host(room_id, auth.uid())
69+
);
70+
71+
-- =============================================================================
72+
-- STEP 4: Add a helper function to check creator status
73+
-- =============================================================================
74+
75+
CREATE OR REPLACE FUNCTION public.is_session_creator(p_session_id UUID, p_user_id UUID)
76+
RETURNS BOOLEAN
77+
LANGUAGE sql
78+
SECURITY DEFINER
79+
SET search_path = public
80+
STABLE
81+
AS $$
82+
SELECT EXISTS (
83+
SELECT 1 FROM public.sessions
84+
WHERE id = p_session_id
85+
AND creator_id = p_user_id
86+
);
87+
$$;
88+
89+
GRANT EXECUTE ON FUNCTION public.is_session_creator TO anon, authenticated;
90+
91+
-- Update "Room creators can manage room media sessions" to use helper
92+
DROP POLICY IF EXISTS "Room creators can manage room media sessions" ON public.media_sessions;
93+
94+
CREATE POLICY "Room creators can manage room media sessions"
95+
ON public.media_sessions
96+
FOR ALL
97+
TO authenticated
98+
USING (
99+
public.is_session_creator(room_id, auth.uid())
100+
)
101+
WITH CHECK (
102+
public.is_session_creator(room_id, auth.uid())
103+
);

0 commit comments

Comments
 (0)