backend integration started

This commit is contained in:
2026-08-26 18:37:28 +05:30
parent 517a99d577
commit 3545819be8
50 changed files with 3461 additions and 3241 deletions

View File

@@ -1,19 +1,23 @@
import { lazy, Suspense, useState, type FormEvent, type ReactNode } from 'react';
import { useState, type FormEvent, type ReactNode } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { AlertCircle, ArrowRight, Eye, EyeOff, Loader2, Lock, Mail, Sparkles } from 'lucide-react';
import {
AlertCircle,
ArrowRight,
Eye,
EyeOff,
Loader2,
Lock,
Mail,
ShieldCheck,
Sparkles,
} from 'lucide-react';
import { useAuth } from '@/auth/AuthContext';
import { HOME_ROUTE } from '@/auth/roles';
import { PasswordSetupRequiredError } from '@/auth/session';
import type { ConsoleRole, SessionUser } from '@/auth/roles';
/**
* The demo panel is loaded lazily behind the build-time DEV constant, so in a
* production build this expression is `null`, the import is dead code, and
* neither the panel nor the accounts behind it are emitted at all.
*/
const DemoPanel = import.meta.env.DEV
? lazy(() => import('@/demo/DemoPanel').then((m) => ({ default: m.DemoPanel })))
: null;
import {
MIN_PASSWORD_LENGTH,
PasswordSetupRequiredError,
setInitialPassword,
} from '@/auth/session';
/**
* Sign-in — KROW's full-bleed auth archetype.
@@ -29,16 +33,27 @@ const DemoPanel = import.meta.env.DEV
* lands where their account says they belong.
*/
export function LoginPage() {
const { user, signIn, signInAsDemo } = useAuth();
const { user, signIn } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isRemembered, setIsRemembered] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
/**
* The userid `applogin` returned for an account with no password, or null.
*
* Non-null flips this page into its second state. It is deliberately not a
* route: the userid only exists because a sign-in attempt just produced it,
* and a `/set-password` URL that could be opened cold would be a way to set
* any account's password from nothing.
*/
const [setupUserid, setSetupUserid] = useState<number | null>(null);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
async function handleSubmit(event: FormEvent) {
@@ -49,19 +64,52 @@ export function LoginPage() {
const session = await signIn(email, password);
navigate(HOME_ROUTE[session.role], { replace: true });
} catch (cause) {
setError(
cause instanceof PasswordSetupRequiredError
? 'This account has no password set yet. An administrator needs to set one before you can sign in.'
: cause instanceof Error
? cause.message
: 'Sign-in failed',
);
// Not a failure — the first step. The account exists and has no password,
// and the backend handed back the userid to set one against.
if (cause instanceof PasswordSetupRequiredError) {
setSetupUserid(cause.userid);
setNewPassword('');
setConfirmPassword('');
} else {
setError(cause instanceof Error ? cause.message : 'Sign-in failed');
}
} finally {
setIsBusy(false);
}
}
/**
* Set the password, then sign in with it.
*
* Signing in afterwards rather than sending the person back to the form: they
* have just typed the password twice, and `applogin` is the only proof the
* write actually took.
*/
async function handleSetup(event: FormEvent) {
event.preventDefault();
if (setupUserid === null) return;
setError(null);
if (newPassword !== confirmPassword) {
setError('Those two passwords do not match.');
return;
}
setIsBusy(true);
try {
await setInitialPassword(setupUserid, newPassword);
const session = await signIn(email, newPassword);
navigate(HOME_ROUTE[session.role], { replace: true });
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Could not set the password');
} finally {
setIsBusy(false);
}
}
const canSubmit = email.trim() !== '' && password !== '' && !isBusy;
const canSetup =
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
return (
<div
@@ -81,24 +129,38 @@ export function LoginPage() {
}}
>
<BrandPanel />
<FormPanel
email={email}
password={password}
isPasswordVisible={isPasswordVisible}
isRemembered={isRemembered}
error={error}
isBusy={isBusy}
canSubmit={canSubmit}
onEmail={setEmail}
onPassword={setPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onToggleRemember={() => setIsRemembered((remembered) => !remembered)}
onSubmit={handleSubmit}
onDemo={(role, session) => {
signInAsDemo(session);
navigate(HOME_ROUTE[role], { replace: true });
}}
/>
{setupUserid === null ? (
<FormPanel
email={email}
password={password}
isPasswordVisible={isPasswordVisible}
error={error}
isBusy={isBusy}
canSubmit={canSubmit}
onEmail={setEmail}
onPassword={setPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onSubmit={handleSubmit}
/>
) : (
<SetupPanel
email={email}
newPassword={newPassword}
confirmPassword={confirmPassword}
isPasswordVisible={isPasswordVisible}
error={error}
isBusy={isBusy}
canSubmit={canSetup}
onNewPassword={setNewPassword}
onConfirmPassword={setConfirmPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onSubmit={handleSetup}
onBack={() => {
setSetupUserid(null);
setError(null);
}}
/>
)}
</div>
</div>
);
@@ -238,32 +300,26 @@ interface FormPanelProps {
email: string;
password: string;
isPasswordVisible: boolean;
isRemembered: boolean;
error: string | null;
isBusy: boolean;
canSubmit: boolean;
onEmail: (value: string) => void;
onPassword: (value: string) => void;
onToggleVisible: () => void;
onToggleRemember: () => void;
onSubmit: (event: FormEvent) => void;
onDemo: (role: ConsoleRole, session: SessionUser) => void;
}
function FormPanel({
email,
password,
isPasswordVisible,
isRemembered,
error,
isBusy,
canSubmit,
onEmail,
onPassword,
onToggleVisible,
onToggleRemember,
onSubmit,
onDemo,
}: FormPanelProps) {
return (
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
@@ -311,21 +367,7 @@ function FormPanel({
type="button"
onClick={onToggleVisible}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
width: 28,
height: 28,
display: 'grid',
placeItems: 'center',
borderRadius: 8,
border: 0,
background: 'transparent',
color: 'var(--color-ink-4)',
cursor: 'pointer',
}}
style={eyeButtonStyle}
>
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
@@ -343,104 +385,23 @@ function FormPanel({
/>
</Field>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}
>
<label
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: 'var(--color-ink-2)',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={isRemembered}
onChange={onToggleRemember}
style={{ width: 15, height: 15, accentColor: 'var(--color-brand)', cursor: 'pointer' }}
/>
Keep me signed in
</label>
{/* Neither "Keep me signed in" nor "Forgot password?" is here any more.
The checkbox was initialised true, toggled, rendered — and never
read: `handleSubmit` and `signIn(email, password)` never received
it, so ticking or clearing it changed nothing about how long the
session lasted. The link had no handler at all, and there is no
password-reset endpoint in Fiesta to give it one. Both promised
something the system does not do. */}
<button
type="button"
style={{
border: 0,
background: 'transparent',
padding: 0,
fontSize: 13,
fontWeight: 600,
color: 'var(--color-brand)',
cursor: 'pointer',
}}
>
Forgot password?
</button>
</div>
<ErrorNote message={error} />
{error ? (
<div
role="alert"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 9,
padding: '11px 13px',
borderRadius: 12,
background: 'var(--color-error-muted, #FCEEEE)',
border: '1px solid rgba(214,69,69,.22)',
color: '#B03434',
fontSize: 13,
lineHeight: 1.55,
}}
>
<AlertCircle size={16} style={{ flex: 'none', marginTop: 1 }} />
{error}
</div>
) : null}
<button
type="submit"
disabled={!canSubmit}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 9,
height: 46,
width: '100%',
borderRadius: 14,
border: 0,
background: canSubmit ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 45%, #fff)',
color: '#fff',
fontSize: 14.5,
fontWeight: 600,
cursor: canSubmit ? 'pointer' : 'default',
boxShadow: canSubmit ? '0 10px 30px -6px rgb(102 37 130 / .28)' : 'none',
transition: 'background .2s cubic-bezier(.16,1,.3,1), box-shadow .2s',
}}
>
{isBusy ? (
<>
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} />
Signing in…
</>
) : (
<>
Continue
<ArrowRight size={16} />
</>
)}
</button>
<SubmitButton
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel="Signing in…"
label="Continue"
/>
</form>
<p
@@ -456,16 +417,289 @@ function FormPanel({
opens — you do not pick one.
</p>
{DemoPanel ? (
<Suspense fallback={null}>
<DemoPanel onDemo={onDemo} />
</Suspense>
) : null}
</div>
</div>
);
}
/* ────────────────────────────────────────────────────────────────────────────
Right — first sign-in, setting the password
──────────────────────────────────────────────────────────────────────────── */
interface SetupPanelProps {
email: string;
newPassword: string;
confirmPassword: string;
isPasswordVisible: boolean;
error: string | null;
isBusy: boolean;
canSubmit: boolean;
onNewPassword: (value: string) => void;
onConfirmPassword: (value: string) => void;
onToggleVisible: () => void;
onSubmit: (event: FormEvent) => void;
onBack: () => void;
}
/**
* The second state of this page, not a second page.
*
* An account created by `createtenantuser` or `createtenantlocation` is spawned
* with an empty password, so its owner's first sign-in cannot succeed and there
* is no reset email to fall back on. Before this existed the page detected the
* condition and then told the person to go and find an administrator — for an
* account that was working as designed.
*/
function SetupPanel({
email,
newPassword,
confirmPassword,
isPasswordVisible,
error,
isBusy,
canSubmit,
onNewPassword,
onConfirmPassword,
onToggleVisible,
onSubmit,
onBack,
}: SetupPanelProps) {
const isTooShort = newPassword !== '' && newPassword.length < MIN_PASSWORD_LENGTH;
const isMismatched = confirmPassword !== '' && newPassword !== confirmPassword;
return (
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 7,
marginBottom: 12,
padding: '4px 10px',
borderRadius: 999,
background: 'var(--color-surface-subtle)',
border: '1px solid var(--color-line)',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.09em',
textTransform: 'uppercase',
color: 'var(--color-brand)',
}}
>
<ShieldCheck size={13} />
First sign-in
</div>
<h1
style={{
margin: 0,
fontFamily: 'var(--font-display)',
fontSize: 26,
lineHeight: 1.2,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--color-ink-1)',
}}
>
Choose a password
</h1>
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
{email} has no password yet. Set one now and we will sign you straight in.
</p>
</div>
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Field
label="New password"
htmlFor="new-password"
icon={<Lock size={15} />}
action={
<button
type="button"
onClick={onToggleVisible}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
style={eyeButtonStyle}
>
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
}
>
<input
id="new-password"
type={isPasswordVisible ? 'text' : 'password'}
value={newPassword}
onChange={(event) => onNewPassword(event.target.value)}
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
autoComplete="new-password"
autoFocus
required
aria-invalid={isTooShort}
style={{ ...inputStyle, paddingRight: 40 }}
/>
</Field>
<Field label="Confirm password" htmlFor="confirm-password" icon={<Lock size={15} />}>
<input
id="confirm-password"
type={isPasswordVisible ? 'text' : 'password'}
value={confirmPassword}
onChange={(event) => onConfirmPassword(event.target.value)}
placeholder="Type it again"
autoComplete="new-password"
required
aria-invalid={isMismatched}
style={{
...inputStyle,
borderColor: isMismatched ? 'rgba(214,69,69,.45)' : 'var(--color-line)',
}}
/>
</Field>
<Hint>
{isTooShort
? `A few more characters — ${MIN_PASSWORD_LENGTH} is the minimum.`
: isMismatched
? 'Those two do not match yet.'
: 'Passwords on this backend are stored as typed. Do not reuse one from elsewhere.'}
</Hint>
<ErrorNote message={error} />
<SubmitButton
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel="Setting it…"
label="Set password and sign in"
/>
</form>
<button
type="button"
onClick={onBack}
style={{
alignSelf: 'center',
border: 0,
background: 'transparent',
padding: 0,
fontSize: 12.5,
fontFamily: 'inherit',
color: 'var(--color-ink-3)',
cursor: 'pointer',
textDecoration: 'underline',
textUnderlineOffset: 3,
}}
>
Use a different account
</button>
</div>
</div>
);
}
/** A quiet line under the fields — advisory, never an error. */
function Hint({ children }: { children: ReactNode }) {
return (
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-ink-4)' }}>
{children}
</p>
);
}
/* ────────────────────────────────────────────────────────────────────────────
Shared form furniture
──────────────────────────────────────────────────────────────────────────── */
function ErrorNote({ message }: { message: string | null }) {
if (!message) return null;
return (
<div
role="alert"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 9,
padding: '11px 13px',
borderRadius: 12,
background: 'var(--color-error-muted, #FCEEEE)',
border: '1px solid rgba(214,69,69,.22)',
color: '#B03434',
fontSize: 13,
lineHeight: 1.55,
}}
>
<AlertCircle size={16} style={{ flex: 'none', marginTop: 1 }} />
{message}
</div>
);
}
function SubmitButton({
canSubmit,
isBusy,
label,
busyLabel,
}: {
canSubmit: boolean;
isBusy: boolean;
label: string;
busyLabel: string;
}) {
return (
<button
type="submit"
disabled={!canSubmit}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 9,
height: 46,
width: '100%',
borderRadius: 14,
border: 0,
background: canSubmit
? 'var(--color-brand)'
: 'color-mix(in oklab, var(--color-brand) 45%, #fff)',
color: '#fff',
fontSize: 14.5,
fontWeight: 600,
cursor: canSubmit ? 'pointer' : 'default',
boxShadow: canSubmit ? '0 10px 30px -6px rgb(102 37 130 / .28)' : 'none',
transition: 'background .2s cubic-bezier(.16,1,.3,1), box-shadow .2s',
}}
>
{isBusy ? (
<>
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} />
{busyLabel}
</>
) : (
<>
{label}
<ArrowRight size={16} />
</>
)}
</button>
);
}
const eyeButtonStyle: React.CSSProperties = {
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
width: 28,
height: 28,
display: 'grid',
placeItems: 'center',
borderRadius: 8,
border: 0,
background: 'transparent',
color: 'var(--color-ink-4)',
cursor: 'pointer',
};
/* ────────────────────────────────────────────────────────────────────────────
Field
──────────────────────────────────────────────────────────────────────────── */