CognxSafeTrack Claude Sonnet 4.6 commited on
Commit ·
4e2a593
1
Parent(s): 0f2f80a
fix: resolve login 400 — auto-resolve organizationId from email when omitted
Browse filesBackend now falls back to findUserByEmailOnly() when the frontend
doesn't send organizationId (deployed admin still uses old build).
Password verification still required — auth security unchanged.
Also adds organizationId field to login form for first-time users,
and removes last default-org-id references from tenant.tsx.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- apps/admin/src/lib/tenant.tsx +3 -7
- apps/admin/src/pages/LoginPage.tsx +43 -17
- apps/api/src/routes/auth.ts +12 -8
- apps/api/src/services/auth.ts +7 -0
apps/admin/src/lib/tenant.tsx
CHANGED
|
@@ -33,10 +33,8 @@ export function TenantProvider({ children }: { children: React.ReactNode }) {
|
|
| 33 |
const slug = isSubdomain ? parts[0] : null;
|
| 34 |
|
| 35 |
useEffect(() => {
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
sessionStorage.setItem(TENANT_KEY, selectedOrgId!);
|
| 39 |
-
// Fetch org details for branding
|
| 40 |
api.get(`/v1/organizations/${selectedOrgId}`, token)
|
| 41 |
.then(setCurrentOrg)
|
| 42 |
.catch(err => {
|
|
@@ -44,9 +42,7 @@ export function TenantProvider({ children }: { children: React.ReactNode }) {
|
|
| 44 |
setCurrentOrg(null);
|
| 45 |
});
|
| 46 |
} else {
|
| 47 |
-
if (!selectedOrgId
|
| 48 |
-
sessionStorage.removeItem(TENANT_KEY);
|
| 49 |
-
}
|
| 50 |
setCurrentOrg(null);
|
| 51 |
}
|
| 52 |
}, [selectedOrgId, token]);
|
|
|
|
| 33 |
const slug = isSubdomain ? parts[0] : null;
|
| 34 |
|
| 35 |
useEffect(() => {
|
| 36 |
+
if (selectedOrgId && token) {
|
| 37 |
+
sessionStorage.setItem(TENANT_KEY, selectedOrgId);
|
|
|
|
|
|
|
| 38 |
api.get(`/v1/organizations/${selectedOrgId}`, token)
|
| 39 |
.then(setCurrentOrg)
|
| 40 |
.catch(err => {
|
|
|
|
| 42 |
setCurrentOrg(null);
|
| 43 |
});
|
| 44 |
} else {
|
| 45 |
+
if (!selectedOrgId) sessionStorage.removeItem(TENANT_KEY);
|
|
|
|
|
|
|
| 46 |
setCurrentOrg(null);
|
| 47 |
}
|
| 48 |
}, [selectedOrgId, token]);
|
apps/admin/src/pages/LoginPage.tsx
CHANGED
|
@@ -6,41 +6,53 @@ import { API_URL } from '../lib/api';
|
|
| 6 |
|
| 7 |
export default function LoginPage() {
|
| 8 |
const { login, token } = useAuth();
|
| 9 |
-
const { currentOrg, isSubdomain, slug } = useTenant();
|
| 10 |
const navigate = useNavigate();
|
| 11 |
-
|
| 12 |
const [email, setEmail] = useState('');
|
| 13 |
const [password, setPassword] = useState('');
|
|
|
|
| 14 |
const [error, setError] = useState('');
|
| 15 |
const [loading, setLoading] = useState(false);
|
| 16 |
|
| 17 |
-
useEffect(() => {
|
| 18 |
-
if (token) navigate('/', { replace: true });
|
| 19 |
}, [token, navigate]);
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
const handleSubmit = async (e: React.FormEvent) => {
|
| 22 |
-
e.preventDefault();
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
setLoading(true);
|
| 25 |
try {
|
| 26 |
-
const res = await fetch(`${API_URL}/v1/auth/login`, {
|
| 27 |
method: 'POST',
|
| 28 |
headers: { 'Content-Type': 'application/json' },
|
| 29 |
-
body: JSON.stringify({ email, password })
|
| 30 |
});
|
| 31 |
-
|
| 32 |
const data = await res.json();
|
| 33 |
-
|
| 34 |
-
if (res.ok) {
|
| 35 |
-
|
| 36 |
-
|
|
|
|
| 37 |
} else {
|
| 38 |
setError(data.message || 'Identifiants invalides.');
|
| 39 |
}
|
| 40 |
-
} catch {
|
| 41 |
-
setError('Impossible de joindre le serveur.');
|
| 42 |
-
} finally {
|
| 43 |
-
setLoading(false);
|
| 44 |
}
|
| 45 |
};
|
| 46 |
|
|
@@ -65,6 +77,20 @@ export default function LoginPage() {
|
|
| 65 |
</div>
|
| 66 |
|
| 67 |
<form onSubmit={handleSubmit} className="space-y-5">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
<div>
|
| 69 |
<label className="block text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Email</label>
|
| 70 |
<input
|
|
|
|
| 6 |
|
| 7 |
export default function LoginPage() {
|
| 8 |
const { login, token } = useAuth();
|
| 9 |
+
const { selectedOrgId, setSelectedOrgId, currentOrg, isSubdomain, slug } = useTenant();
|
| 10 |
const navigate = useNavigate();
|
| 11 |
+
|
| 12 |
const [email, setEmail] = useState('');
|
| 13 |
const [password, setPassword] = useState('');
|
| 14 |
+
const [orgId, setOrgId] = useState(selectedOrgId || '');
|
| 15 |
const [error, setError] = useState('');
|
| 16 |
const [loading, setLoading] = useState(false);
|
| 17 |
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
if (token) navigate('/', { replace: true });
|
| 20 |
}, [token, navigate]);
|
| 21 |
|
| 22 |
+
// Keep local orgId in sync if tenant context resolves it (e.g. after page load)
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
if (selectedOrgId && !orgId) setOrgId(selectedOrgId);
|
| 25 |
+
}, [selectedOrgId]);
|
| 26 |
+
|
| 27 |
const handleSubmit = async (e: React.FormEvent) => {
|
| 28 |
+
e.preventDefault();
|
| 29 |
+
const resolvedOrgId = orgId.trim();
|
| 30 |
+
if (!resolvedOrgId) {
|
| 31 |
+
setError("L'identifiant d'organisation est requis.");
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
setError('');
|
| 35 |
setLoading(true);
|
| 36 |
try {
|
| 37 |
+
const res = await fetch(`${API_URL}/v1/auth/login`, {
|
| 38 |
method: 'POST',
|
| 39 |
headers: { 'Content-Type': 'application/json' },
|
| 40 |
+
body: JSON.stringify({ email, password, organizationId: resolvedOrgId })
|
| 41 |
});
|
| 42 |
+
|
| 43 |
const data = await res.json();
|
| 44 |
+
|
| 45 |
+
if (res.ok) {
|
| 46 |
+
setSelectedOrgId(resolvedOrgId);
|
| 47 |
+
login(data.token, data.user);
|
| 48 |
+
navigate('/', { replace: true });
|
| 49 |
} else {
|
| 50 |
setError(data.message || 'Identifiants invalides.');
|
| 51 |
}
|
| 52 |
+
} catch {
|
| 53 |
+
setError('Impossible de joindre le serveur.');
|
| 54 |
+
} finally {
|
| 55 |
+
setLoading(false);
|
| 56 |
}
|
| 57 |
};
|
| 58 |
|
|
|
|
| 77 |
</div>
|
| 78 |
|
| 79 |
<form onSubmit={handleSubmit} className="space-y-5">
|
| 80 |
+
{!selectedOrgId && (
|
| 81 |
+
<div>
|
| 82 |
+
<label className="block text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">ID Organisation</label>
|
| 83 |
+
<input
|
| 84 |
+
type="text"
|
| 85 |
+
required
|
| 86 |
+
placeholder="ex: cldxxxxxxxxxxxx"
|
| 87 |
+
value={orgId}
|
| 88 |
+
onChange={e => setOrgId(e.target.value)}
|
| 89 |
+
className="w-full bg-slate-50 border-none rounded-2xl px-5 py-4 text-sm outline-none focus:ring-2 focus:ring-indigo-500 transition font-mono"
|
| 90 |
+
/>
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
+
|
| 94 |
<div>
|
| 95 |
<label className="block text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Email</label>
|
| 96 |
<input
|
apps/api/src/routes/auth.ts
CHANGED
|
@@ -20,17 +20,21 @@ export async function authRoutes(fastify: FastifyInstance) {
|
|
| 20 |
}
|
| 21 |
}
|
| 22 |
}, async (request, reply) => {
|
| 23 |
-
const { email, password, organizationId } = request.body as { email: string; password: string; organizationId: string };
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
-
|
| 30 |
-
const user = await AuthService.findUserByEmail(email, organizationId);
|
| 31 |
|
| 32 |
if (!user || !user.passwordHash) {
|
| 33 |
-
logger.warn(`[AUTH] User not found: ${email} in Org: ${
|
| 34 |
return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid email or password' });
|
| 35 |
}
|
| 36 |
|
|
|
|
| 20 |
}
|
| 21 |
}
|
| 22 |
}, async (request, reply) => {
|
| 23 |
+
const { email, password, organizationId: bodyOrgId } = request.body as { email: string; password: string; organizationId?: string };
|
| 24 |
+
|
| 25 |
+
// If organizationId is omitted (older frontend), resolve it from the user record.
|
| 26 |
+
// Security: password is still verified — this doesn't bypass auth.
|
| 27 |
+
let user;
|
| 28 |
+
if (bodyOrgId) {
|
| 29 |
+
logger.info(`[AUTH] Login attempt for ${email} (Org: ${bodyOrgId})`);
|
| 30 |
+
user = await AuthService.findUserByEmail(email, bodyOrgId);
|
| 31 |
+
} else {
|
| 32 |
+
logger.info(`[AUTH] Login attempt for ${email} (no org — resolving from email)`);
|
| 33 |
+
user = await AuthService.findUserByEmailOnly(email);
|
| 34 |
}
|
|
|
|
|
|
|
| 35 |
|
| 36 |
if (!user || !user.passwordHash) {
|
| 37 |
+
logger.warn(`[AUTH] User not found: ${email} in Org: ${bodyOrgId ?? 'unknown'}`);
|
| 38 |
return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid email or password' });
|
| 39 |
}
|
| 40 |
|
apps/api/src/services/auth.ts
CHANGED
|
@@ -28,6 +28,13 @@ export class AuthService {
|
|
| 28 |
});
|
| 29 |
}
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
/**
|
| 32 |
* Checks if a user is allowed to access an organization.
|
| 33 |
*/
|
|
|
|
| 28 |
});
|
| 29 |
}
|
| 30 |
|
| 31 |
+
static async findUserByEmailOnly(email: string) {
|
| 32 |
+
return prisma.user.findFirst({
|
| 33 |
+
where: { email },
|
| 34 |
+
include: { organization: true }
|
| 35 |
+
});
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
/**
|
| 39 |
* Checks if a user is allowed to access an organization.
|
| 40 |
*/
|