Add first-login password setup flow

Accounts created by tenant/store onboarding (createtenantuser,
createtenantlocation) have no password set. Previously the login form
treated the backend's "please setup a password" response as a
successful login instead of prompting for one. Now auth.ts recognizes
it (PasswordSetupRequiredError) and LoginView routes to a create-
password step, then signs in immediately with the new password.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-07-21 16:46:07 +05:30
parent f62668e54e
commit fef75a1cc2
4 changed files with 173 additions and 13 deletions

View File

@@ -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<number | null>(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 */}
<div className="mb-6">
<h2 className="text-[1.7rem] font-bold text-slate-900 tracking-tight leading-tight">Welcome back</h2>
<p className="text-slate-500 text-sm mt-2">Sign in to your nearledaily workspace to continue.</p>
<h2 className="text-[1.7rem] font-bold text-slate-900 tracking-tight leading-tight">
{step === 'setup' ? 'Create your password' : 'Welcome back'}
</h2>
<p className="text-slate-500 text-sm mt-2">
{step === 'setup'
? 'This is your first sign-in — set a password to finish activating your account.'
: 'Sign in to your nearledaily workspace to continue.'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
@@ -136,10 +196,17 @@ export default function LoginView({ onLogin }: LoginViewProps) {
<label htmlFor="login-email" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
Email or Username
</label>
{step === 'password' && (
{(step === 'password' || step === 'setup') && (
<button
type="button"
onClick={() => { setStep('email'); setError(''); setPassword(''); }}
onClick={() => {
setStep('email');
setError('');
setPassword('');
setSetupUserId(null);
setNewPassword('');
setConfirmPassword('');
}}
className="text-xs font-semibold text-purple-600 hover:text-purple-800 bg-transparent border-none cursor-pointer p-0"
>
Change Email
@@ -152,7 +219,7 @@ export default function LoginView({ onLogin }: LoginViewProps) {
id="login-email"
type="text"
autoComplete="username"
disabled={step === 'password'}
disabled={step === 'password' || step === 'setup'}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@nearledaily.com"
@@ -161,6 +228,52 @@ export default function LoginView({ onLogin }: LoginViewProps) {
</div>
</div>
{/* First-login password setup */}
{step === 'setup' && (
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="new-password" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
New Password
</label>
<div className="relative">
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
<input
id="new-password"
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="At least 6 characters"
className="w-full h-12 pl-10 pr-11 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"
/>
<button
type="button"
onClick={() => setShowNewPassword((s) => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer p-1"
title={showNewPassword ? 'Hide password' : 'Show password'}
>
{showNewPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div className="space-y-2">
<label htmlFor="confirm-password" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
Confirm Password
</label>
<div className="relative">
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
<input
id="confirm-password"
type={showNewPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => 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"
/>
</div>
</div>
</div>
)}
{/* Password */}
{step === 'password' && (
<div className="space-y-2">
@@ -217,11 +330,11 @@ export default function LoginView({ onLogin }: LoginViewProps) {
{loading || checkingEmail ? (
<>
<Loader2 size={16} className="animate-spin" />
{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'}
<ArrowRight size={16} />
</>
)}

View File

@@ -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<Log
| { code?: number; status?: boolean; message?: string; details?: unknown }
| null;
// Account exists but has no password yet — server-side this fires before it
// even looks at the password the caller sent, so it happens on any attempt
// (including checkEmailRequest's password-less probe). `status: true` here,
// so this must be checked before the generic status===false failure branch.
const details = json?.details as { userid?: number; setup?: boolean } | undefined;
if (json?.code === 409 && details?.setup === true && typeof details.userid === 'number') {
throw new PasswordSetupRequiredError(details.userid, json.message);
}
// Failure: HTTP error, or the Fiesta `status: false` envelope (e.g. wrong
// email/password → { code: 409, message: "Invalid Email", status: false }).
if (!res.ok || (json && json.status === false)) {

View File

@@ -1015,6 +1015,15 @@ export async function updateUser(input: UpdateUserInput): Promise<Row> {
return fiestaSend<Row>('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<Row> {
return fiestaSend<Row>('users/update', 'PUT', { userid, password });
}
export interface CreateTenantLocationPayload {
locationname: string;
email?: string;

View File

@@ -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({