Skip to content
Open
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
273 changes: 273 additions & 0 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions src/components/common/PasswordStrengthMeter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from 'react';
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { getPasswordStrength } from '../../utils/passwordStrength';

interface PasswordStrengthMeterProps {
password?: string;
}

export const PasswordStrengthMeter: React.FC<PasswordStrengthMeterProps> = ({ password = '' }) => {
const strength = getPasswordStrength(password);

if (!password) {
return null;
}

const criteria = [
{ label: 'At least 8 characters', met: strength.hasMinLength },
{ label: 'Uppercase letter (A-Z)', met: strength.hasUppercase },
{ label: 'Number (0-9)', met: strength.hasNumber },
{ label: 'Special character (!@#$%^&*)', met: strength.hasSpecial },
];

return (
<div className="mt-3 space-y-2" id="password-strength-container">
{/* Label and Strength Status */}
<div className="flex items-center justify-between text-xs font-semibold">
<span className="text-gray-600 dark:text-gray-400">Password Strength:</span>
<span
id="password-strength-label"
className={`${strength.textColorClass} font-bold transition-colors duration-200`}
>
{strength.label}
</span>
</div>

{/* ARIA Accessible Progress Bar */}
<div
className="w-full h-2 bg-gray-200 dark:bg-neutral-700 rounded-full overflow-hidden"
role="progressbar"
aria-valuenow={strength.percentage}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Password strength: ${strength.label}`}
aria-describedby="password-strength-label"
>
<div
className={`h-full ${strength.barColorClass} transition-all duration-300 ease-out`}
style={{ width: `${strength.percentage}%` }}
/>
</div>

{/* Live Region for Screen Readers */}
<div className="sr-only" aria-live="polite">
Password strength is currently {strength.label}.
</div>

{/* Criteria Checklist */}
<div className="grid grid-cols-2 gap-1.5 pt-1 text-xs">
{criteria.map((item, index) => (
<div
key={index}
className={`flex items-center gap-1.5 transition-colors duration-200 ${
item.met
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-400 dark:text-gray-500'
}`}
>
{item.met ? (
<CheckIcon className="w-3.5 h-3.5 text-emerald-500 flex-shrink-0" />
) : (
<XMarkIcon className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 flex-shrink-0" />
)}
<span className="truncate">{item.label}</span>
</div>
))}
</div>
</div>
);
};

export default PasswordStrengthMeter;
2 changes: 2 additions & 0 deletions src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from "react";
import { Link, useLocation } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import {
Menu,
X,
Expand All @@ -15,6 +16,7 @@ import {
Sun,
Moon,
Eye,
Globe,
} from "lucide-react";
import Button from "../common/Button";
import { useAuth } from "../../context/AuthContext";
Expand Down
92 changes: 47 additions & 45 deletions src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,36 +12,31 @@ interface AuthContextType {
const AuthContext = createContext<AuthContextType | undefined>(undefined);

// Builds the app-level User from a Supabase session and resolves the user's
// role from the profiles table, defaulting to 'patient'. This is the single
// place role is read, so every consumer of useAuth() gets it for free.
//
// Notes:
// - Defaulting to 'patient' mirrors the schema default (profiles.role default
// 'patient') and the existing fallback in Profile.tsx (role || "patient"),
// so behavior stays consistent.
// - .single() resolves to { data: null, error } (it does not throw) when a
// brand-new user has no profile row yet; the !error check handles that and
// falls through to the patient default.
// role from the profiles table, defaulting to 'patient'.
const buildUserFromSession = async (session: Session): Promise<User> => {
const { user } = session;

let role: UserRole = 'patient';
const { data, error } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single();

if (!error && data?.role === 'doctor') {
role = 'doctor';
try {
const { data, error } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single();

if (!error && data?.role === 'doctor') {
role = 'doctor';
}
} catch (e) {
console.warn('Could not fetch user profile role, defaulting to patient', e);
}

return {
id: user.id,
name: user.user_metadata.full_name || user.email,
name: user.user_metadata?.full_name || user.email || 'User',
email: user.email || '',
role,
profilePicture: user.user_metadata.avatar_url,
profilePicture: user.user_metadata?.avatar_url,
preferences: {
theme: 'light',
notifications: true,
Expand All @@ -58,44 +53,51 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
useEffect(() => {
const getSession = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (session) {
setCurrentUser(await buildUserFromSession(session));
const res = await supabase.auth.getSession();
if (res?.data?.session) {
setCurrentUser(await buildUserFromSession(res.data.session));
}
} catch (err) {
console.warn('AuthContext: Session resolution handled safely', err);
} finally {
// Always clear the loading flag so guarded routes can never get stuck
// on the loader, even if the role lookup fails.
setIsLoading(false);
}
};

getSession();

const { data: authListener } = supabase.auth.onAuthStateChange(async (event, session) => {
if (!session) {
setCurrentUser(null);
return;
}

// Supabase fires onAuthStateChange on TOKEN_REFRESHED (roughly hourly).
// Rebuilding the user on those events would re-run the profiles role
// lookup every time for no reason, since the identity hasn't changed.
// Only rebuild (and re-query the role) on events that can actually
// change who the user is or their profile data.
if (event === 'TOKEN_REFRESHED') {
return;
}
try {
const res = supabase.auth.onAuthStateChange(async (event, session) => {
if (!session) {
setCurrentUser(null);
return;
}

setCurrentUser(await buildUserFromSession(session));
});
if (event === 'TOKEN_REFRESHED') {
return;
}

return () => {
authListener.subscription.unsubscribe();
};
try {
setCurrentUser(await buildUserFromSession(session));
} catch (err) {
console.warn('AuthContext: Listener build user error', err);
}
});

return () => {
res?.data?.subscription?.unsubscribe();
};
} catch (err) {
console.warn('AuthContext: Listener subscription error', err);
}
}, []);

const logout = async () => {
await supabase.auth.signOut();
try {
await supabase.auth.signOut();
} catch (err) {
console.warn('Logout warning', err);
}
setCurrentUser(null);
};

Expand Down
9 changes: 3 additions & 6 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ThemeProvider } from './context/ThemeContext.tsx';
import './i18n.ts';
import App from './App.tsx';
import './i18n';
import App from './App';
import './index.css';

createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
<App />
</StrictMode>
);
12 changes: 9 additions & 3 deletions src/pages/Signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import toast from 'react-hot-toast';
import { UserPlusIcon, EnvelopeIcon, UserIcon, LockClosedIcon, EyeIcon, EyeSlashIcon, CheckCircleIcon } from '@heroicons/react/24/outline';

import { supabase } from '../services/supabaseClient';
import PasswordStrengthMeter from '../components/common/PasswordStrengthMeter';
import { getPasswordStrength } from '../utils/passwordStrength';

interface SignupFormInputs {
name: string;
Expand Down Expand Up @@ -173,9 +175,12 @@ export default function Signup() {
{...register('password', {
required: 'Password is required',
minLength: { value: 8, message: 'Password must be at least 8 characters' },
pattern: {
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
message: 'Password must contain uppercase, lowercase, number and special character'
validate: (val) => {
const strength = getPasswordStrength(val);
if (strength.label === 'Weak') {
return 'Password is too weak. Please include uppercase letters, numbers, or special characters.';
}
return true;
}
})}
className="w-full pl-10 pr-10 py-3 rounded-lg border border-gray-200 dark:border-neutral-700/80 bg-gray-50 dark:bg-neutral-900/50 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200"
Expand All @@ -196,6 +201,7 @@ export default function Signup() {
</div>
</div>
{errors.password && <span className="text-red-500 text-sm mt-1 block">{errors.password.message}</span>}
<PasswordStrengthMeter password={password} />
</div>

<div>
Expand Down
24 changes: 20 additions & 4 deletions src/services/supabaseClient.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
const rawUrl = import.meta.env.VITE_SUPABASE_URL;
const rawKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

if (!supabaseUrl || !supabaseKey) {
throw new Error("Missing VITE_SUPABASE_URL or VITE_SUPABASE_ANON_KEY - check your .env file against SETUP.md");
const isValidUrl = (url?: string) => {
try {
return Boolean(url && url.startsWith('http') && !url.includes('YOUR_SUPABASE_URL'));
} catch {
return false;
}
};

const supabaseUrl = isValidUrl(rawUrl)
? rawUrl!
: 'https://placeholder.supabase.co';

const supabaseKey = (rawKey && !rawKey.includes('YOUR_SUPABASE_ANON_KEY'))
? rawKey
: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImR1bW15Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2MDAwMDAwMDAsImV4cCI6MjAwMDAwMDAwMH0.dummykey';

if (!isValidUrl(rawUrl)) {
console.warn("ClinIQ Notice: VITE_SUPABASE_URL is missing or using placeholder in .env. Running in demo mode.");
}

export const supabase = createClient(supabaseUrl, supabaseKey);
45 changes: 45 additions & 0 deletions src/utils/passwordStrength.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { getPasswordStrength } from './passwordStrength';

describe('getPasswordStrength', () => {
it('returns score 0 and empty stats when password is empty', () => {
const result = getPasswordStrength('');
expect(result.score).toBe(0);
expect(result.label).toBe('Weak');
expect(result.percentage).toBe(0);
});

it('rates simple short password as Weak', () => {
const result = getPasswordStrength('pass');
expect(result.label).toBe('Weak');
expect(result.percentage).toBe(25);
expect(result.hasMinLength).toBe(false);
});

it('rates password with length and uppercase as Fair', () => {
const result = getPasswordStrength('Password');
expect(result.hasMinLength).toBe(true);
expect(result.hasUppercase).toBe(true);
expect(result.label).toBe('Fair');
expect(result.percentage).toBe(50);
});

it('rates password with length, uppercase, and number as Strong', () => {
const result = getPasswordStrength('Password123');
expect(result.hasMinLength).toBe(true);
expect(result.hasUppercase).toBe(true);
expect(result.hasNumber).toBe(true);
expect(result.label).toBe('Strong');
expect(result.percentage).toBe(75);
});

it('rates password satisfying all criteria as Very Strong', () => {
const result = getPasswordStrength('Password123!');
expect(result.hasMinLength).toBe(true);
expect(result.hasUppercase).toBe(true);
expect(result.hasNumber).toBe(true);
expect(result.hasSpecial).toBe(true);
expect(result.label).toBe('Very Strong');
expect(result.percentage).toBe(100);
});
});
Loading