diff --git a/src/components/LoginView.tsx b/src/components/LoginView.tsx index 689c630..6533485 100644 --- a/src/components/LoginView.tsx +++ b/src/components/LoginView.tsx @@ -13,9 +13,9 @@ import { EyeOff, Loader2, } from 'lucide-react'; -import { checkEmailRequest } from '../services/auth'; +import { checkEmailRequest, PasswordSetupRequiredError } from '../services/auth'; import type { AuthUser } from '../services/auth'; -import { useLogin } from '../services/fiestaQueries'; +import { useLogin, useSetPassword } from '../services/fiestaQueries'; interface LoginViewProps { /** Called with the authenticated user once credentials are verified. */ @@ -29,11 +29,21 @@ export default function LoginView({ onLogin }: LoginViewProps) { const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(''); - const [step, setStep] = useState<'email' | 'password'>('email'); + const [step, setStep] = useState<'email' | 'password' | 'setup'>('email'); const [checkingEmail, setCheckingEmail] = useState(false); + // First-time login: the account exists but has never had a password set + // (a freshly onboarded tenant admin or store login). Captured off + // PasswordSetupRequiredError so the create-password step knows which + // userid to write to. + const [setupUserId, setSetupUserId] = useState(null); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showNewPassword, setShowNewPassword] = useState(false); + const login = useLogin(); - const loading = login.isPending; + const setPasswordMut = useSetPassword(); + const loading = login.isPending || setPasswordMut.isPending; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -58,7 +68,7 @@ export default function LoginView({ onLogin }: LoginViewProps) { } finally { setCheckingEmail(false); } - } else { + } else if (step === 'password') { if (!password.trim()) { setError('Please enter your password.'); return; @@ -70,8 +80,52 @@ export default function LoginView({ onLogin }: LoginViewProps) { { email, password }, { onSuccess: (user) => onLogin(user), + onError: (err) => { + if (err instanceof PasswordSetupRequiredError) { + // First login for this account — no password set yet (a fresh + // tenant admin or store login). Send them to create one instead + // of showing this as a failure. + setSetupUserId(err.userid); + setStep('setup'); + return; + } + setError(err instanceof Error ? err.message : 'Sign in failed. Please try again.'); + }, + }, + ); + } else { + // step === 'setup' + if (setupUserId == null) { + setError('Something went wrong. Please start over.'); + setStep('email'); + return; + } + if (newPassword.length < 6) { + setError('Password must be at least 6 characters.'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + setError(''); + setPasswordMut.mutate( + { userid: setupUserId, password: newPassword }, + { + onSuccess: () => { + // Password is set — sign in with it right away instead of making + // them type it again. + login.mutate( + { email, password: newPassword }, + { + onSuccess: (user) => onLogin(user), + onError: (err) => + setError(err instanceof Error ? err.message : 'Sign in failed. Please try again.'), + }, + ); + }, onError: (err) => - setError(err instanceof Error ? err.message : 'Sign in failed. Please try again.'), + setError(err instanceof Error ? err.message : 'Could not set password. Please try again.'), }, ); } @@ -125,8 +179,14 @@ export default function LoginView({ onLogin }: LoginViewProps) { {/* Heading */}
-

Welcome back

-

Sign in to your nearledaily workspace to continue.

+

+ {step === 'setup' ? 'Create your password' : 'Welcome back'} +

+

+ {step === 'setup' + ? 'This is your first sign-in — set a password to finish activating your account.' + : 'Sign in to your nearledaily workspace to continue.'} +

@@ -136,10 +196,17 @@ export default function LoginView({ onLogin }: LoginViewProps) { - {step === 'password' && ( + {(step === 'password' || step === 'setup') && ( + + +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Re-enter password" + className="w-full h-12 pl-10 pr-4 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all" + /> +
+
+ + )} + {/* Password */} {step === 'password' && (
@@ -217,11 +330,11 @@ export default function LoginView({ onLogin }: LoginViewProps) { {loading || checkingEmail ? ( <> - {checkingEmail ? 'Checking Email…' : 'Verifying…'} + {checkingEmail ? 'Checking Email…' : setPasswordMut.isPending ? 'Setting Password…' : 'Verifying…'} ) : ( <> - {step === 'email' ? 'Continue' : 'Sign in'} + {step === 'email' ? 'Continue' : step === 'setup' ? 'Set Password & Sign In' : 'Sign in'} )} diff --git a/src/services/auth.ts b/src/services/auth.ts index a0cd580..c032cd2 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -139,6 +139,22 @@ export interface LoginResult { email: string; } +/** + * Thrown by `loginRequest` when the account exists but has never had a password + * set (a freshly onboarded tenant admin or store login — see AppLogin's + * "No password set" branch, `{ code: 409, status: true, details: { setup: true } }`). + * Distinct from a plain login failure: the caller should route to a + * create-password step, not show an error. + */ +export class PasswordSetupRequiredError extends Error { + userid: number; + constructor(userid: number, message = 'Please set up a password to continue.') { + super(message); + this.name = 'PasswordSetupRequiredError'; + this.userid = userid; + } +} + /** * POST the credentials to the Fiesta web-login endpoint. Resolves with the raw * user record on success; throws an Error with a user-facing message on invalid @@ -168,6 +184,15 @@ export async function loginRequest(email: string, password: string): Promise { return fiestaSend('users/update', 'PUT', input); } +/** + * PUT /users/update — set (or reset) a user's login password. Same endpoint as + * `updateUser`, called with only userid + password so every other column is + * left untouched (the backend's Updates() skips zero-value fields). + */ +export async function setUserPassword(userid: number, password: string): Promise { + return fiestaSend('users/update', 'PUT', { userid, password }); +} + export interface CreateTenantLocationPayload { locationname: string; email?: string; diff --git a/src/services/fiestaQueries.ts b/src/services/fiestaQueries.ts index 5c8736e..4089cf8 100644 --- a/src/services/fiestaQueries.ts +++ b/src/services/fiestaQueries.ts @@ -54,6 +54,7 @@ import { getUserById, createUser, updateUser, + setUserPassword, assignRiderToOrders, CreateUserInput, createTenantUser, @@ -892,6 +893,18 @@ export function useLogin() { }); } +/** + * Set (or reset) a user's login password — used by the login flow's + * create-password step (see PasswordSetupRequiredError in ./auth) for a + * first-time tenant admin or store login. + */ +export function useSetPassword() { + return useMutation({ + mutationFn: (input: { userid: number; password: string }) => + setUserPassword(input.userid, input.password), + }); +} + export function useFiestaDeleteLocation() { const qc = useQueryClient(); return useMutation({