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 files

Backend 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 CHANGED
@@ -33,10 +33,8 @@ export function TenantProvider({ children }: { children: React.ReactNode }) {
33
  const slug = isSubdomain ? parts[0] : null;
34
 
35
  useEffect(() => {
36
- const isValidId = selectedOrgId && selectedOrgId !== 'default-org-id';
37
- if (isValidId && token) {
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 || selectedOrgId === 'default-org-id') {
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
- setError('');
 
 
 
 
 
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
- login(data.token, data.user);
36
- navigate('/', { replace: true });
 
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
- logger.info(`[AUTH] Login attempt for ${email} (Org: ${organizationId || 'default'})`);
25
-
26
- if (!organizationId) {
27
- return reply.code(400).send({ error: 'organizationId required' });
 
 
 
 
 
 
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: ${organizationId}`);
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
  */