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:
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user