Skip to content
Draft
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
48 changes: 30 additions & 18 deletions Frontend/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
*/

import axios from 'axios';
import { supabase } from '@/lib/supabase';

const AUTH_STORAGE_KEY = 'taskpulse-auth';

Expand Down Expand Up @@ -61,6 +60,27 @@ function clearAuth() {
}
}

async function refreshAccessToken(refreshToken: string) {
const { data } = await axios.post<{
message: string;
tokens: {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
};
}>(
'/api/v1/auth/refresh',
{ refresh_token: refreshToken },
{
headers: { 'Content-Type': 'application/json' },
withCredentials: true,
}
);

return data.tokens;
}

// SEC-006: Read the csrf_token cookie set by the backend
function getCsrfToken(): string | null {
const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]*)/);
Expand All @@ -78,18 +98,8 @@ const apiClient = axios.create({
// ─── Request interceptor: inject token + CSRF ────────────────────────

apiClient.interceptors.request.use(async (config) => {
// Attach JWT access token (prefer zustand store, fallback to Supabase session)
let { accessToken } = getTokens();
if (!accessToken) {
try {
const { data: { session } } = await supabase.auth.getSession();
if (session?.access_token) {
accessToken = session.access_token;
}
} catch {
// Supabase client may not be initialized yet
}
}
// Attach JWT access token from persisted auth state
const { accessToken } = getTokens();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
Expand Down Expand Up @@ -157,12 +167,14 @@ apiClient.interceptors.response.use(
isRefreshing = true;

try {
const { data: refreshData, error: refreshErr } = await supabase.auth.refreshSession();
if (refreshErr || !refreshData.session) {
throw refreshErr || new Error('No session after refresh');
const { refreshToken } = getTokens();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const newAccessToken = refreshData.session.access_token;
const newRefreshToken = refreshData.session.refresh_token;

const refreshData = await refreshAccessToken(refreshToken);
const newAccessToken = refreshData.access_token;
const newRefreshToken = refreshData.refresh_token;
setTokens(newAccessToken, newRefreshToken);
processQueue(null, newAccessToken);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
Expand Down
134 changes: 126 additions & 8 deletions Frontend/src/lib/supabase.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,130 @@
import { createClient } from '@supabase/supabase-js';
import { createClient, type SupabaseClient } from '@supabase/supabase-js';

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
type BrowserSupabaseClient = SupabaseClient;

if (!supabaseUrl || !supabaseAnonKey) {
throw new Error(
'Missing Supabase environment variables. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in your .env file.'
);
const rawSupabaseUrl = import.meta.env.VITE_SUPABASE_URL?.trim();
const rawSupabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY?.trim();

export class SupabaseConfigurationError extends Error {
constructor(message: string) {
super(message);
this.name = 'SupabaseConfigurationError';
}
}

function getValidatedSupabaseConfig() {
if (!rawSupabaseUrl || !rawSupabaseAnonKey) {
throw new SupabaseConfigurationError(
'Missing Supabase environment variables. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY for this deployment.'
);
}

let parsedUrl: URL;
try {
parsedUrl = new URL(rawSupabaseUrl);
} catch {
throw new SupabaseConfigurationError(
`VITE_SUPABASE_URL is not a valid URL: ${rawSupabaseUrl}`
);
}

if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new SupabaseConfigurationError(
`VITE_SUPABASE_URL must use http or https: ${rawSupabaseUrl}`
);
}

if (rawSupabaseUrl.includes('your-project.supabase.co')) {
throw new SupabaseConfigurationError(
'VITE_SUPABASE_URL is still set to the example placeholder value.'
);
}

return {
anonKey: rawSupabaseAnonKey,
url: parsedUrl.toString().replace(/\/$/, ''),
};
}

let supabaseConfigError: SupabaseConfigurationError | null = null;
let supabaseConfig: ReturnType<typeof getValidatedSupabaseConfig> | null = null;

try {
supabaseConfig = getValidatedSupabaseConfig();
} catch (error) {
supabaseConfigError = error instanceof SupabaseConfigurationError
? error
: new SupabaseConfigurationError('Supabase configuration is invalid for this deployment.');
}

export const supabase = createClient(supabaseUrl, supabaseAnonKey);
let supabaseClient: BrowserSupabaseClient | null = null;
let supabaseClientPromise: Promise<BrowserSupabaseClient> | null = null;
let reachabilityProbe: Promise<void> | null = null;

async function ensureSupabaseReachable() {
if (supabaseConfigError) {
throw supabaseConfigError;
}

if (!supabaseConfig) {
throw new SupabaseConfigurationError('Supabase configuration is missing for this deployment.');
}

if (!reachabilityProbe) {
const probeUrl = `${supabaseConfig.url}/auth/v1/settings`;
reachabilityProbe = fetch(probeUrl, {
headers: {
apikey: supabaseConfig.anonKey,
},
})
.then((response) => {
if (!response.ok) {
throw new SupabaseConfigurationError(
`Supabase auth endpoint responded with ${response.status} at ${probeUrl}`
);
}
})
.catch((error: unknown) => {
reachabilityProbe = null;
if (error instanceof SupabaseConfigurationError) {
throw error;
}
throw new SupabaseConfigurationError(
`Supabase auth endpoint is unreachable at ${probeUrl}. Check VITE_SUPABASE_URL for this deployment.`
);
});
}

return reachabilityProbe;
}

export function getSupabaseConfigError() {
return supabaseConfigError;
}

export async function getSupabase() {
if (supabaseClient) {
return supabaseClient;
}

if (supabaseConfigError) {
throw supabaseConfigError;
}

if (!supabaseConfig) {
throw new SupabaseConfigurationError('Supabase configuration is missing for this deployment.');
}

if (!supabaseClientPromise) {
supabaseClientPromise = (async () => {
await ensureSupabaseReachable();
supabaseClient = createClient(supabaseConfig!.url, supabaseConfig!.anonKey);
return supabaseClient;
})().catch((error) => {
supabaseClientPromise = null;
throw error;
});
}

return supabaseClientPromise;
}
49 changes: 45 additions & 4 deletions Frontend/src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { Link, useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
Expand All @@ -9,8 +9,10 @@ import {
EyeOff,
ArrowRight,
Github,
Loader2
Loader2,
AlertTriangle,
} from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
Expand All @@ -25,8 +27,9 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { useAuthStore } from '@/store/authStore';
import { authService } from '@/services/auth.service';
import { getApiErrorMessage } from '@/lib/api-client';
import { getSupabase, getSupabaseConfigError } from '@/lib/supabase';
import { authService } from '@/services/auth.service';
import { toast } from 'sonner';

function ForgotPasswordDialog() {
Expand Down Expand Up @@ -94,12 +97,35 @@ export default function LoginPage() {
const navigate = useNavigate();
const { login, oauthLogin, isLoading } = useAuthStore();
const [showPassword, setShowPassword] = useState(false);
const [authConfigError, setAuthConfigError] = useState<string | null>(
getSupabaseConfigError()?.message ?? null
);
const [formData, setFormData] = useState({
email: '',
password: '',
rememberMe: false,
});

useEffect(() => {
let active = true;

void getSupabase()
.then(() => {
if (active) {
setAuthConfigError(null);
}
})
.catch((error) => {
if (active) {
setAuthConfigError(getApiErrorMessage(error));
}
});

return () => {
active = false;
};
}, []);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

Expand Down Expand Up @@ -141,11 +167,22 @@ export default function LoginPage() {
</p>
</div>

{authConfigError && (
<Alert variant="destructive" className="mb-6">
<AlertTriangle />
<AlertTitle>Google sign-in is unavailable</AlertTitle>
<AlertDescription>
{authConfigError}
</AlertDescription>
</Alert>
)}

{/* Social Login */}
<div className="mb-6 space-y-3">
<Button
variant="outline"
className="gap-2 w-full"
disabled={Boolean(authConfigError) || isLoading}
onClick={async () => {
try {
await oauthLogin();
Expand All @@ -162,7 +199,11 @@ export default function LoginPage() {
</svg>
Google
</Button>
<Button variant="outline" className="gap-2 w-full">
<Button
variant="outline"
className="gap-2 w-full"
disabled={Boolean(authConfigError) || isLoading}
>
<Github className="w-4 h-4" />
GitHub
</Button>
Expand Down
Loading
Loading