update on the agents and registry side
This commit is contained in:
381
src/pages/doormile/clients/ClientOnboarding.jsx
Normal file
381
src/pages/doormile/clients/ClientOnboarding.jsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, ShieldCheck, UserPlus } from 'lucide-react';
|
||||
import {
|
||||
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||
BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack,
|
||||
StatusBadge, Surface, Switch,
|
||||
} from '@/components/ds';
|
||||
import { inputVariants } from '@/components/ui/input';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks';
|
||||
import {
|
||||
canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding,
|
||||
} from '@/lib/clientOnboarding';
|
||||
|
||||
/**
|
||||
* Client onboarding — register a new client and give them a console login.
|
||||
*
|
||||
* One submit creates, in a single server transaction, the tenant (the client
|
||||
* company), its console login (doormile_auth, scoped to that tenant) and its
|
||||
* appusers row. The client then signs in at the normal console login and sees
|
||||
* only their own data.
|
||||
*
|
||||
* Restricted to the onboarding owner login (admin@doormile.com). The server
|
||||
* enforces that; this page only hides itself from everyone else.
|
||||
*/
|
||||
|
||||
const EMPTY = {
|
||||
companyname: '',
|
||||
contactname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
applocationid: '',
|
||||
password: '',
|
||||
confirm: '',
|
||||
requiredeliveryotp: false,
|
||||
};
|
||||
|
||||
const SERVER_FIELD = [
|
||||
['company name', 'companyname'],
|
||||
['contact person', 'contactname'],
|
||||
['email', 'email'],
|
||||
['mobile', 'phone'],
|
||||
['password', 'password'],
|
||||
['operating city', 'applocationid'],
|
||||
];
|
||||
|
||||
/** Put a server message under the field it is about, when it names one. */
|
||||
function serverFieldOf(message) {
|
||||
const m = String(message || '').toLowerCase();
|
||||
const hit = SERVER_FIELD.find(([needle]) => m.includes(needle));
|
||||
return hit ? hit[1] : null;
|
||||
}
|
||||
|
||||
function OnboardedList() {
|
||||
const { data = [], isLoading } = useOnboardedClients();
|
||||
const columns = [
|
||||
{ key: 'tenantname', header: 'Client', accessor: (r) => r.tenantname },
|
||||
{ key: 'loginemail', header: 'Console login', accessor: (r) => r.loginemail },
|
||||
{ key: 'primarycontact', header: 'Contact', accessor: (r) => r.primarycontact || '—' },
|
||||
{ key: 'status', header: 'Status', cell: (r) => <StatusBadge status={r.status} size="sm" /> },
|
||||
{
|
||||
key: 'logincreatedat',
|
||||
header: 'Login created',
|
||||
accessor: (r) => (r.logincreatedat ? new Date(r.logincreatedat).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Surface radius="xl" padding="lg">
|
||||
<h2 className="text-body font-semibold text-ink-1 mb-3">Clients with a console login</h2>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={data}
|
||||
getRowId={(row, i) => `${row.tenantid}-${row.loginemail}-${i}`}
|
||||
loading={isLoading}
|
||||
emptyState={<EmptyState icon={Building2} size="sm" title="No client logins yet" />}
|
||||
/>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientOnboarding() {
|
||||
const navigate = useNavigate();
|
||||
const { user, isClient } = useAuth();
|
||||
const allowed = canOnboardClients(user, isClient);
|
||||
|
||||
const onboard = useOnboardClient();
|
||||
const citiesQ = useOnboardingCities({ enabled: allowed, retry: false });
|
||||
const locations = citiesQ.data || [];
|
||||
// When the city list cannot be read, onboarding cannot work on this server
|
||||
// either (same backend, same gate) — say why instead of an empty dropdown.
|
||||
const unavailable = citiesQ.isError ? onboardingUnavailableMessage(citiesQ.error) : '';
|
||||
const cities = useMemo(() => locations.filter((l) => Number(l.applocationid) > 0), [locations]);
|
||||
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [formError, setFormError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [created, setCreated] = useState(null); // { tenant, login, password }
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const set = (key) => (value) => {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
setErrors((e) => ({ ...e, [key]: undefined }));
|
||||
};
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<EmptyState
|
||||
icon={ShieldCheck}
|
||||
title="Client onboarding is restricted"
|
||||
description="Only the designated onboarding account can register new clients."
|
||||
action={
|
||||
isClient
|
||||
? { label: 'Go to home', onClick: () => navigate('/doormile/home') }
|
||||
: { label: 'Back to clients', onClick: () => navigate('/doormile/tenants') }
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
setFormError('');
|
||||
const nextErrors = validateOnboarding(form);
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
const password = form.password;
|
||||
onboard.mutate(
|
||||
{
|
||||
companyname: form.companyname.trim(),
|
||||
contactname: form.contactname.trim(),
|
||||
email: form.email.trim().toLowerCase(),
|
||||
phone: form.phone.replace(/\D/g, ''),
|
||||
password,
|
||||
applocationid: Number(form.applocationid),
|
||||
requiredeliveryotp: form.requiredeliveryotp,
|
||||
},
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
if (res?.success === false) {
|
||||
setFormError(res.message || 'The client was not created.');
|
||||
return;
|
||||
}
|
||||
setCreated({ ...res.data, password });
|
||||
setForm(EMPTY);
|
||||
setCopied(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
const status = err?.response?.status;
|
||||
const message = err?.response?.data?.message;
|
||||
if (status === 403) {
|
||||
setFormError('Your login is not allowed to onboard clients.');
|
||||
return;
|
||||
}
|
||||
const field = serverFieldOf(message);
|
||||
if (field && message) setErrors((e) => ({ ...e, [field]: message }));
|
||||
else setFormError(message || 'The client could not be created. Nothing was saved.');
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const copyLogin = async () => {
|
||||
if (!created) return;
|
||||
const text = [
|
||||
`Doormile console: ${window.location.origin}/login`,
|
||||
`Email: ${created.login.email}`,
|
||||
`Password: ${created.password}`,
|
||||
].join('\n');
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
breadcrumb={
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/doormile/tenants">Clients</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Onboard client</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate('/doormile/tenants')}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{created && (
|
||||
<Alert tone="success" title={`${created.tenant.tenantname} is onboarded`} role="status">
|
||||
<p>
|
||||
Console login <span className="font-semibold">{created.login.email}</span> is ready. They sign in at the
|
||||
usual console login and see only their own orders.
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
This is the only time the password is shown — share it with the client securely.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={copyLogin}>
|
||||
<Copy className="mr-1.5 h-4 w-4" /> {copied ? 'Copied' : 'Copy login details'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setCreated(null)}>
|
||||
Onboard another
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={submit} noValidate>
|
||||
<Surface radius="xl" padding="lg">
|
||||
<div className="mb-5 flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-tint text-brand">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-h4 font-semibold text-ink-1">Onboard a new client</h1>
|
||||
<p className="text-body-sm text-ink-3">
|
||||
Creates the client and their console login together. If any part fails, nothing is saved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unavailable && (
|
||||
<Alert
|
||||
tone="warning"
|
||||
className="mb-4"
|
||||
role="alert"
|
||||
title="Client onboarding is not available right now"
|
||||
action={
|
||||
<Button size="sm" variant="outline" onClick={() => citiesQ.refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{unavailable}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<Alert tone="destructive" className="mb-4" role="alert">
|
||||
{formError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Company name" required error={errors.companyname} className="sm:col-span-2">
|
||||
<Input
|
||||
value={form.companyname}
|
||||
onChange={(e) => set('companyname')(e.target.value)}
|
||||
placeholder="Peelamedu Provisions Pvt Ltd"
|
||||
maxLength={120}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Contact person" required error={errors.contactname}>
|
||||
<Input
|
||||
value={form.contactname}
|
||||
onChange={(e) => set('contactname')(e.target.value)}
|
||||
placeholder="Priya Raman"
|
||||
maxLength={80}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Mobile number" required error={errors.phone}>
|
||||
<Input
|
||||
value={form.phone}
|
||||
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
placeholder="9876543210"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Login email" required error={errors.email} hint="The client signs in to the console with this">
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => set('email')(e.target.value)}
|
||||
placeholder="ops@client.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Operating city" required error={errors.applocationid}>
|
||||
<select
|
||||
value={form.applocationid}
|
||||
onChange={(e) => set('applocationid')(e.target.value)}
|
||||
className={inputVariants()}
|
||||
>
|
||||
<option value="">
|
||||
{citiesQ.isLoading ? 'Loading cities…' : unavailable ? 'Cities could not be loaded' : cities.length ? 'Choose a city' : 'No operating cities found'}
|
||||
</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c.applocationid} value={c.applocationid}>
|
||||
{c.applocationname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Password" required error={errors.password} hint="At least 8 characters" htmlFor="onboard-password">
|
||||
<Input
|
||||
id="onboard-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
onChange={(e) => set('password')(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
maxLength={72}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Confirm password" required error={errors.confirm}>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={form.confirm}
|
||||
onChange={(e) => set('confirm')(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
maxLength={72}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 sm:col-span-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const pw = generateClientPassword();
|
||||
setForm((f) => ({ ...f, password: pw, confirm: pw }));
|
||||
setErrors((e) => ({ ...e, password: undefined, confirm: undefined }));
|
||||
setShowPassword(true);
|
||||
}}
|
||||
>
|
||||
<KeyRound className="mr-1.5 h-4 w-4" /> Generate password
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setShowPassword((s) => !s)}>
|
||||
{showPassword ? <EyeOff className="mr-1.5 h-4 w-4" /> : <Eye className="mr-1.5 h-4 w-4" />}
|
||||
{showPassword ? 'Hide' : 'Show'} password
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Require delivery OTP"
|
||||
inline
|
||||
hint="The rider must collect a one-time code from the receiver"
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Switch checked={form.requiredeliveryotp} onCheckedChange={set('requiredeliveryotp')} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">
|
||||
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/tenants')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={onboard.isPending} disabled={Boolean(unavailable)}>
|
||||
Onboard client
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
</form>
|
||||
|
||||
<OnboardedList />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user