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
4 changes: 4 additions & 0 deletions apps/dashboard-api/src/__tests__/auth.controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ jest.mock('@urbackend/common', () => {
email: z.string().email(),
password: z.string().min(1),
}),
registerSchema: z.object({
email: z.string().email(),
password: z.string().min(1),
}),
changePasswordSchema: z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(6),
Expand Down
3 changes: 2 additions & 1 deletion apps/dashboard-api/src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { sendOtp } = require("@urbackend/common");
const crypto = require("crypto");
const {
loginSchema,
registerSchema,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

CRITICAL: registerSchema is undefined at runtime.

The pipeline failure indicates that registerSchema.parse throws "Cannot read properties of undefined (reading 'parse')", meaning registerSchema itself is undefined when this code executes. While the import statement on line 11 looks syntactically correct, the export chain from @urbackend/common appears broken at runtime.

Run the following script to verify the export chain:

#!/bin/bash
# Verify registerSchema is properly exported from `@urbackend/common`

echo "=== Checking input.validation.js export ==="
rg -n "module\.exports\.registerSchema" packages/common/src/utils/input.validation.js

echo -e "\n=== Checking index.js import from input.validation ==="
rg -n "registerSchema" packages/common/src/index.js

echo -e "\n=== Checking if common package needs rebuild ==="
# Check if there's a build script
cat packages/common/package.json | jq -r '.scripts.build // "No build script found"'

echo -e "\n=== Verifying the export is reachable ==="
# Try to require and inspect the export in a Node context
node -e "
try {
  const common = require('./packages/common/src/index.js');
  if (common.registerSchema === undefined) {
    console.error('ERROR: registerSchema is undefined in common exports');
    process.exit(1);
  }
  console.log('✓ registerSchema is exported and defined');
  console.log('✓ Type:', typeof common.registerSchema);
} catch (err) {
  console.error('ERROR loading common:', err.message);
  process.exit(1);
}
"

If the script confirms the export exists, the dashboard-api package may need to reinstall dependencies or rebuild to pick up the changes:

#!/bin/bash
cd apps/dashboard-api
npm install
# or if using a monorepo tool:
# npm run build --workspace=`@urbackend/common`

Also applies to: 278-278

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard-api/src/controllers/auth.controller.js` at line 11, The
runtime error means registerSchema is not actually exported from the common
package; inspect packages/common/src/utils/input.validation.js to ensure
registerSchema is defined, confirm packages/common/src/index.js re-exports
registerSchema (search for "registerSchema" in that index), and if missing add
the explicit export so module.exports.registerSchema (or export { registerSchema
}) is present; after fixing exports, rebuild the common package and
reinstall/rebuild apps/dashboard-api so it picks up the updated bundle (ensure
the controller import in auth.controller.js still points to the correct package
entry that now exports registerSchema).

changePasswordSchema,
deleteAccountSchema,
onlyEmailSchema,
Expand Down Expand Up @@ -274,7 +275,7 @@ async function checkOtpCooldown(userId) {

module.exports.register = async (req, res) => {
try {
const { email, password } = loginSchema.parse(req.body);
const { email, password } = registerSchema.parse(req.body);

const existingUser = await Developer.findOne({ email });
if (existingUser) return res.status(400).json({ error: "Email already exists" });
Expand Down
3 changes: 2 additions & 1 deletion apps/web-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"react-router-dom": "^7.9.6",
"recharts": "^3.5.1",
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.18"
"tailwindcss": "^4.1.18",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
Expand Down
101 changes: 101 additions & 0 deletions apps/web-dashboard/src/components/PasswordStrengthMeter.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { useEffect, useMemo } from 'react';
import zxcvbn from 'zxcvbn';

const MIN_SCORE = 2;

const SCORE_CONFIG = [
{ label: 'Very weak', color: 'var(--color-danger)' },
{ label: 'Weak', color: '#f97316' },
{ label: 'Fair', color: '#facc15' },
{ label: 'Strong', color: 'var(--color-primary)' },
{ label: 'Very strong', color: 'var(--color-primary)' },
];

function buildUserInputs(userInputs) {
const values = (userInputs || []).filter(Boolean).map((v) => String(v));
const tokens = values.flatMap((v) => v.split(/[\s@.+_-]+/));
return [...new Set([...values, ...tokens])].filter((v) => v.length > 2);
}

function PasswordStrengthMeter({ password, userInputs, onStrengthChange }) {
const result = useMemo(() => {
if (!password) return null;
return zxcvbn(password, buildUserInputs(userInputs));
}, [password, userInputs]);

useEffect(() => {
if (!onStrengthChange) return;
if (!result) {
onStrengthChange({ score: 0, isStrongEnough: false });
return;
}
onStrengthChange({
score: result.score,
isStrongEnough: result.score >= MIN_SCORE,
});
}, [result, onStrengthChange]);

if (!password) return null;

const { score, feedback } = result;
const config = SCORE_CONFIG[score];
const barWidth = ((score + 1) / 5) * 100;

return (
<div style={{ marginTop: '0.5rem' }}>
{/* Strength bar */}
<div style={{
height: '4px',
borderRadius: '2px',
background: 'var(--color-border)',
overflow: 'hidden',
marginBottom: '0.4rem',
}}>
<div style={{
height: '100%',
width: `${barWidth}%`,
background: config.color,
borderRadius: '2px',
transition: 'width 0.3s ease, background 0.3s ease',
}} />
</div>

{/* Score label */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: feedback.warning || feedback.suggestions?.length ? '0.4rem' : 0,
}}>
<span style={{ fontSize: '0.78rem', color: config.color, fontWeight: 600 }}>
{config.label}
</span>
{score < MIN_SCORE && (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
Too weak to submit
</span>
)}
</div>

{/* Warning */}
{feedback.warning && (
<p style={{ fontSize: '0.78rem', color: 'var(--color-text-muted)', margin: '0 0 0.25rem' }}>
{feedback.warning}
</p>
)}

{/* Suggestions */}
{feedback.suggestions?.length > 0 && (
<ul style={{ margin: 0, padding: '0 0 0 1rem' }}>
{feedback.suggestions.map((s) => (
<li key={s} style={{ fontSize: '0.78rem', color: 'var(--color-text-muted)', marginBottom: '0.15rem' }}>
{s}
</li>
))}
</ul>
)}
Comment on lines +45 to +96
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add ARIA semantics for live password-strength feedback.

The meter and “Too weak to submit” feedback are currently visual-only. Add progress semantics and a polite live region so assistive tech announces strength changes.

♿ Suggested patch
-  return (
-    <div style={{ marginTop: '0.5rem' }}>
+  return (
+    <div style={{ marginTop: '0.5rem' }} aria-live="polite">
       {/* Strength bar */}
-      <div style={{
+      <div
+        role="progressbar"
+        aria-label="Password strength"
+        aria-valuemin={0}
+        aria-valuemax={4}
+        aria-valuenow={score}
+        aria-valuetext={config.label}
+        style={{
         height: '4px',
         borderRadius: '2px',
         background: 'var(--color-border)',
         overflow: 'hidden',
         marginBottom: '0.4rem',
-      }}>
+      }}>
         <div style={{
           height: '100%',
           width: `${barWidth}%`,
           background: config.color,
           borderRadius: '2px',
           transition: 'width 0.3s ease, background 0.3s ease',
         }} />
       </div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-dashboard/src/components/PasswordStrengthMeter.jsx` around lines 45
- 96, Wrap the visual strength bar and textual status in accessible semantics:
add progress semantics to the inner strength bar div (role="progressbar",
aria-valuemin="0", aria-valuemax="100", aria-valuenow={Math.round(barWidth)} and
aria-valuetext={`${config.label} (${Math.round(barWidth)}%)`}) and add a polite
live region for updates (e.g., aria-live="polite" aria-atomic="true") around the
score label/feedback block so changes to config.label, the "Too weak to submit"
message (when score < MIN_SCORE) and feedback.warning/suggestions are announced;
ensure unique content uses existing symbols barWidth, config.label, MIN_SCORE,
feedback to populate ARIA attributes.

</div>
);
}

export default PasswordStrengthMeter;
25 changes: 23 additions & 2 deletions apps/web-dashboard/src/pages/ForgotPassword.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { Eye, EyeOff, KeyRound, Lock, Mail } from 'lucide-react';
import { Link, useLocation, useNavigate } from 'react-router-dom';

import AuthShell from '../components/AuthShell';
import PasswordStrengthMeter from '../components/PasswordStrengthMeter';
import { useAuth } from '../context/AuthContext';
import api from '../utils/api';

Expand All @@ -16,13 +17,19 @@ function ForgotPassword() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [passwordStrength, setPasswordStrength] = useState({ score: 0, isStrongEnough: false });
const [formData, setFormData] = useState({
email: location.state?.email || '',
otp: '',
newPassword: '',
confirmPassword: '',
});

const userInputs = useMemo(
() => [formData.email],
[formData.email]
);

useEffect(() => {
if (!authLoading && isAuthenticated) {
navigate('/dashboard', { replace: true });
Expand Down Expand Up @@ -92,6 +99,11 @@ function ForgotPassword() {
return;
}

if (!passwordStrength.isStrongEnough) {
toast.error('Please choose a stronger password.');
return;
}
Comment on lines +102 to +105
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In React 19, does state updated from a child component's useEffect run after render/paint such that parent submit guards can observe the previous value for one render?

💡 Result:

Short answer: No—React doesn’t let a child component’s passive effect (useEffect) “race” with the parent’s render/paint such that a parent event handler (like an onSubmit) observes the previous state for one render. However, state updates scheduled from effects are not guaranteed to be applied before paint in all circumstances, so you also shouldn’t rely on useEffect timing for “next render” ordering unless you structure code around explicit user events or use layout effects when you must block painting. What the React docs guarantee / imply: - useEffect callbacks are deferred relative to the browser paint in the general case, and are run after layout and paint via a deferred event [1]. - React guarantees that even when effects are deferred until after the browser has painted, React will flush a previous render’s effects before starting a new update (so updates from the effect can’t be missed or applied “after” a newer render has begun) [2]. What that means for your scenario (child useEffect updates state used by a parent submit guard): - If the submit guard logic runs in response to a user interaction (e.g., the onSubmit handler), React will process updates such that the “next” UI/event system view isn’t allowed to “skip” the update that was scheduled earlier; useEffect timing is about when the effect runs, not about producing a one-render-old observable state to an unrelated later user event [1][2]. - Also, React’s own docs note that timing can differ for certain cases (e.g., effects caused by discrete user input or flushSync may be processed differently, and it’s also not guaranteed that useEffect always runs after paint) [1][3]. So while you typically won’t see a parent submit guard observe a previous value “for one render” due to passive-effect updates, you should not design the app assuming a strict paint-phase ordering across all scheduling situations. If you need the parent guard to observe the child-driven state change immediately before a user event: - Don’t depend on child useEffect for that; instead, update the parent state in the same interaction path (e.g., lift state up and call the parent’s state update directly, or call setState in an event handler). This avoids the asynchronous/deferred nature of passive effects [1][2]. - If you truly must block paint and ensure ordering with layout, useLayoutEffect is the correct tool (but note it has different performance/ordering semantics and can effectively make passive effects flush earlier) [4][2][5]. Conclusion: - In React 19, a passive child useEffect-driven state update is not expected to be applied “after render/paint” in a way that causes the parent’s next submit guard to see the previous value for exactly one render. React flushes previous effects before starting a new update [2]. - But you should avoid assuming a rigid “after paint” timing relationship for correctness, because React documentation and discussions note that effect timing can vary in special cases [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="apps/web-dashboard/src/pages/ForgotPassword.jsx"

# Show the relevant sections around the mentioned line ranges
nl -ba "$FILE" | sed -n '70,140p'
nl -ba "$FILE" | sed -n '190,260p'

Repository: geturbackend/urBackend

Length of output: 109


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="apps/web-dashboard/src/pages/ForgotPassword.jsx"

# Print with line numbers (avoid nl since it's missing)
echo "== $FILE : lines ~70-140 =="
cat -n "$FILE" | sed -n '70,140p'

echo
echo "== $FILE : lines ~190-260 =="
cat -n "$FILE" | sed -n '190,260p'

Repository: geturbackend/urBackend

Length of output: 5865


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find where PasswordStrengthMeter is defined/implemented
rg -n "function PasswordStrengthMeter|const PasswordStrengthMeter|PasswordStrengthMeter" apps/web-dashboard/src -S

# 2) Find the prop name `onStrengthChange` usage
rg -n "onStrengthChange" apps/web-dashboard/src -S

# 3) Inspect how `handleResetPassword` is connected (form onSubmit / button type submit)
rg -n "handleResetPassword|onSubmit=\{handleResetPassword\}|type=\"submit\"" apps/web-dashboard/src/pages/ForgotPassword.jsx -n

Repository: geturbackend/urBackend

Length of output: 2041


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE1="apps/web-dashboard/src/components/PasswordStrengthMeter.jsx"
FILE2="apps/web-dashboard/src/pages/ForgotPassword.jsx"

echo "== $FILE1 : lines 1-140 =="
cat -n "$FILE1" | sed -n '1,160p'

echo
echo "== $FILE2 : lines 1-120 =="
cat -n "$FILE2" | sed -n '1,140p'

echo
echo "== $FILE2 : around form onSubmit + submit button area =="
cat -n "$FILE2" | sed -n '140,320p'

Repository: geturbackend/urBackend

Length of output: 15117


Avoid effect-lagged password strength in reset submit gating

handleResetPassword (102-105) and the reset submit button disabled logic (252-256) depend on passwordStrength.isStrongEnough, but passwordStrength is updated via PasswordStrengthMeter using a useEffect (wiring at 218-222). If the user submits right after changing the password, this state can be temporarily out of date. Derive isStrongEnough directly from the current formData.newPassword in the parent (or otherwise ensure synchronous/ordered gating) for both the submit guard and button disabled state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-dashboard/src/pages/ForgotPassword.jsx` around lines 102 - 105, The
submit gating currently reads stale passwordStrength.isStrongEnough (used in
handleResetPassword and the submit button disabled logic) which is updated
asynchronously by PasswordStrengthMeter; instead derive the strength
synchronously from the current formData.newPassword in the parent: add a local
synchronous check (e.g., computeIsStrongEnoughFrom(formData.newPassword) using
the same validation logic as PasswordStrengthMeter) and use that boolean for
both the early-return in handleResetPassword and the disabled prop on the submit
button so both places use the up-to-date value from formData.newPassword rather
than the effect-updated passwordStrength.


setIsSubmitting(true);
const loadingToast = toast.loading('Resetting password...');

Expand Down Expand Up @@ -203,6 +215,11 @@ function ForgotPassword() {
{showNewPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<PasswordStrengthMeter
password={formData.newPassword}
userInputs={userInputs}
onStrengthChange={setPasswordStrength}
/>
</div>

<div className="auth-field">
Expand Down Expand Up @@ -232,7 +249,11 @@ function ForgotPassword() {
</div>
</div>

<button type="submit" className="btn btn-primary auth-submit" disabled={isSubmitting}>
<button
type="submit"
className="btn btn-primary auth-submit"
disabled={isSubmitting || (formData.newPassword.length > 0 && !passwordStrength.isStrongEnough)}
>
{isSubmitting ? 'Resetting...' : 'Reset password'}
</button>

Expand Down
25 changes: 21 additions & 4 deletions apps/web-dashboard/src/pages/Settings.jsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import api from '../utils/api';
import { useAuth } from '../context/AuthContext';
import toast from 'react-hot-toast';
import { Lock, Trash2, AlertTriangle, Save, CheckCircle } from 'lucide-react';
import { API_URL } from '../config';
import ConfirmationModal from './ConfirmationModal';
import PasswordStrengthMeter from '../components/PasswordStrengthMeter';

export default function Settings() {
const { logout, user, isLoading } = useAuth();

// Password State
const [passData, setPassData] = useState({ currentPassword: '', newPassword: '' });
const [loadingPass, setLoadingPass] = useState(false);
const [passwordStrength, setPasswordStrength] = useState({ score: 0, isStrongEnough: false });

const userInputs = useMemo(() => [user?.email], [user?.email]);

// Delete Account State
const [deletePass, setDeletePass] = useState('');
Expand All @@ -22,13 +26,21 @@ export default function Settings() {
// Handle Password Change
const handlePasswordChange = async (e) => {
e.preventDefault();
if (!passwordStrength.isStrongEnough) {
toast.error('Please choose a stronger password.');
return;
}
setLoadingPass(true);
try {
await api.put(`/api/auth/change-password`, passData);
toast.success("Password updated!");
toast.success('Password updated!');
setPassData({ currentPassword: '', newPassword: '' });
} catch (err) {
toast.error(err.response?.data || "Failed to update password");
const data = err.response?.data;
let message = 'Failed to update password';
if (typeof data?.error === 'string') message = data.error;
else if (Array.isArray(data?.error)) message = data.error[0]?.message || message;
toast.error(message);
} finally {
setLoadingPass(false);
}
Expand Down Expand Up @@ -146,10 +158,15 @@ if (pageLoading) return <SettingsSkeleton />;
minLength={6}
style={{ width: '100%', padding: '12px', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '8px', color: '#fff' }}
/>
<PasswordStrengthMeter
password={passData.newPassword}
userInputs={userInputs}
onStrengthChange={setPasswordStrength}
/>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button type="submit" className="btn btn-primary" disabled={loadingPass} style={{ padding: '10px 20px' }}>
<button type="submit" className="btn btn-primary" disabled={loadingPass || (passData.newPassword.length > 0 && !passwordStrength.isStrongEnough)} style={{ padding: '10px 20px' }}>
{loadingPass ? 'Updating...' : <><Save size={18} /> Update Password</>}
</button>
</div>
Expand Down
55 changes: 22 additions & 33 deletions apps/web-dashboard/src/pages/Signup.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Eye, EyeOff, Github, Lock, Mail, UserRound } from 'lucide-react';
import { useNavigate } from 'react-router-dom';

import AuthShell from '../components/AuthShell';
import PasswordStrengthMeter from '../components/PasswordStrengthMeter';
import { useAuth } from '../context/AuthContext';
import api from '../utils/api';
import { API_URL } from '../config';
Expand All @@ -19,40 +20,26 @@ function Signup() {
});
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [passwordStrength, setPasswordStrength] = useState({ score: 0, isStrongEnough: false });

useEffect(() => {
if (!authLoading && isAuthenticated) {
navigate('/dashboard', { replace: true });
}
}, [authLoading, isAuthenticated, navigate]);

const passwordChecks = useMemo(
() => [
{
label: 'At least 6 characters',
passed: formData.password.length >= 6,
},
{
label: 'Contains a letter',
passed: /[A-Za-z]/.test(formData.password),
},
{
label: 'Contains a number',
passed: /\d/.test(formData.password),
},
],
[formData.password]
const userInputs = useMemo(
() => [formData.name, formData.email],
[formData.name, formData.email]
);

if (authLoading) {
return null;
}

const handleChange = (event) => {
const { name, value } = event.target;
setFormData((current) => ({ ...current, [name]: value }));
};

if (authLoading) return null;

const handleSubmit = async (event) => {
event.preventDefault();

Expand All @@ -61,6 +48,11 @@ function Signup() {
return;
}

if (!passwordStrength.isStrongEnough) {
toast.error('Please choose a stronger password.');
return;
}

setIsSubmitting(true);
const loadingToast = toast.loading('Creating your account...');

Expand Down Expand Up @@ -179,21 +171,18 @@ function Signup() {
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<PasswordStrengthMeter
password={formData.password}
userInputs={userInputs}
onStrengthChange={setPasswordStrength}
/>
</div>

<div className="auth-password-checks">
{passwordChecks.map((check) => (
<div
key={check.label}
className={`auth-password-check ${check.passed ? 'is-passed' : ''}`}
>
<span className="auth-password-check__dot" />
<span>{check.label}</span>
</div>
))}
</div>

<button type="submit" className="btn btn-secondary auth-submit" disabled={isSubmitting}>
<button
type="submit"
className="btn btn-secondary auth-submit"
disabled={isSubmitting || (formData.password.length > 0 && !passwordStrength.isStrongEnough)}
>
{isSubmitting ? 'Creating account...' : 'Create account'}
</button>
</form>
Expand Down
Loading
Loading