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,
|
EyeOff,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { checkEmailRequest } from '../services/auth';
|
import { checkEmailRequest, PasswordSetupRequiredError } from '../services/auth';
|
||||||
import type { AuthUser } from '../services/auth';
|
import type { AuthUser } from '../services/auth';
|
||||||
import { useLogin } from '../services/fiestaQueries';
|
import { useLogin, useSetPassword } from '../services/fiestaQueries';
|
||||||
|
|
||||||
interface LoginViewProps {
|
interface LoginViewProps {
|
||||||
/** Called with the authenticated user once credentials are verified. */
|
/** Called with the authenticated user once credentials are verified. */
|
||||||
@@ -29,11 +29,21 @@ export default function LoginView({ onLogin }: LoginViewProps) {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [step, setStep] = useState<'email' | 'password'>('email');
|
const [step, setStep] = useState<'email' | 'password' | 'setup'>('email');
|
||||||
const [checkingEmail, setCheckingEmail] = useState(false);
|
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 login = useLogin();
|
||||||
const loading = login.isPending;
|
const setPasswordMut = useSetPassword();
|
||||||
|
const loading = login.isPending || setPasswordMut.isPending;
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -58,7 +68,7 @@ export default function LoginView({ onLogin }: LoginViewProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setCheckingEmail(false);
|
setCheckingEmail(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (step === 'password') {
|
||||||
if (!password.trim()) {
|
if (!password.trim()) {
|
||||||
setError('Please enter your password.');
|
setError('Please enter your password.');
|
||||||
return;
|
return;
|
||||||
@@ -70,8 +80,52 @@ export default function LoginView({ onLogin }: LoginViewProps) {
|
|||||||
{ email, password },
|
{ email, password },
|
||||||
{
|
{
|
||||||
onSuccess: (user) => onLogin(user),
|
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) =>
|
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 */}
|
{/* Heading */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-[1.7rem] font-bold text-slate-900 tracking-tight leading-tight">Welcome back</h2>
|
<h2 className="text-[1.7rem] font-bold text-slate-900 tracking-tight leading-tight">
|
||||||
<p className="text-slate-500 text-sm mt-2">Sign in to your nearledaily workspace to continue.</p>
|
{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>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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">
|
<label htmlFor="login-email" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
|
||||||
Email or Username
|
Email or Username
|
||||||
</label>
|
</label>
|
||||||
{step === 'password' && (
|
{(step === 'password' || step === 'setup') && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="text-xs font-semibold text-purple-600 hover:text-purple-800 bg-transparent border-none cursor-pointer p-0"
|
||||||
>
|
>
|
||||||
Change Email
|
Change Email
|
||||||
@@ -152,7 +219,7 @@ export default function LoginView({ onLogin }: LoginViewProps) {
|
|||||||
id="login-email"
|
id="login-email"
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
disabled={step === 'password'}
|
disabled={step === 'password' || step === 'setup'}
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="you@nearledaily.com"
|
placeholder="you@nearledaily.com"
|
||||||
@@ -161,6 +228,52 @@ export default function LoginView({ onLogin }: LoginViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Password */}
|
||||||
{step === 'password' && (
|
{step === 'password' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -217,11 +330,11 @@ export default function LoginView({ onLogin }: LoginViewProps) {
|
|||||||
{loading || checkingEmail ? (
|
{loading || checkingEmail ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<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} />
|
<ArrowRight size={16} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -139,6 +139,22 @@ export interface LoginResult {
|
|||||||
email: string;
|
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
|
* 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
|
* 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 }
|
| { code?: number; status?: boolean; message?: string; details?: unknown }
|
||||||
| null;
|
| 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
|
// Failure: HTTP error, or the Fiesta `status: false` envelope (e.g. wrong
|
||||||
// email/password → { code: 409, message: "Invalid Email", status: false }).
|
// email/password → { code: 409, message: "Invalid Email", status: false }).
|
||||||
if (!res.ok || (json && json.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);
|
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 {
|
export interface CreateTenantLocationPayload {
|
||||||
locationname: string;
|
locationname: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
getUserById,
|
getUserById,
|
||||||
createUser,
|
createUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
|
setUserPassword,
|
||||||
assignRiderToOrders,
|
assignRiderToOrders,
|
||||||
CreateUserInput,
|
CreateUserInput,
|
||||||
createTenantUser,
|
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() {
|
export function useFiestaDeleteLocation() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
Reference in New Issue
Block a user