487 lines
18 KiB
JavaScript
487 lines
18 KiB
JavaScript
import React, { useMemo, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, MapPin, Pencil, ShieldCheck, Trash2, 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 AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
|
|
import { EditClientModal, RemoveClientModal } from './ClientLoginDialogs';
|
|
import {
|
|
addressFromPlace, addressPayload, canOnboardClients, generateClientPassword, hasMapLocation,
|
|
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,
|
|
// Main address, saved as the client's primary location. Coordinates come
|
|
// only from picking a suggestion.
|
|
address: '',
|
|
city: '',
|
|
state: '',
|
|
pincode: '',
|
|
latitude: '',
|
|
longitude: '',
|
|
};
|
|
|
|
const SERVER_FIELD = [
|
|
['company name', 'companyname'],
|
|
['contact person', 'contactname'],
|
|
['email', 'email'],
|
|
['mobile', 'phone'],
|
|
['password', 'password'],
|
|
['operating city', 'applocationid'],
|
|
['pincode', 'pincode'],
|
|
['address', 'address'],
|
|
['map location', 'address'],
|
|
];
|
|
|
|
/** 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 [editing, setEditing] = useState(null);
|
|
const [removing, setRemoving] = useState(null);
|
|
// Rows without an authid come from a backend older than Edit/Delete (it has
|
|
// neither the field nor the PUT/DELETE routes). Never send /clients/undefined:
|
|
// disable the actions and say what to do instead.
|
|
const backendTooOld = data.length > 0 && data.some((r) => r.authid == null);
|
|
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: 'address',
|
|
header: 'Address',
|
|
accessor: (r) => (r.address ? [r.address, r.pincode].filter(Boolean).join(' · ') : '—'),
|
|
},
|
|
{ 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' }) : '—'),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: 'Actions',
|
|
align: 'right',
|
|
cell: (r) => (
|
|
<div className="flex justify-end gap-1.5">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={r.authid == null}
|
|
onClick={() => setEditing(r)}
|
|
aria-label={`Edit ${r.tenantname}`}
|
|
>
|
|
<Pencil className="mr-1 h-3.5 w-3.5" /> Edit
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="text-destructive hover:text-destructive"
|
|
disabled={r.authid == null}
|
|
onClick={() => setRemoving(r)}
|
|
aria-label={`Delete ${r.tenantname}`}
|
|
>
|
|
<Trash2 className="mr-1 h-3.5 w-3.5" /> Delete
|
|
</Button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
return (
|
|
<Surface radius="xl" padding="lg">
|
|
<h2 className="text-body font-semibold text-ink-1 mb-3">Clients with a console login</h2>
|
|
{backendTooOld && (
|
|
<Alert tone="warning" className="mb-3" role="alert" title="Edit and Delete are not available on this server yet">
|
|
The API server this console talks to is running an older backend build without the edit and delete
|
|
endpoints. Rebuild and redeploy doormile_backend from the current code, then reload this page.
|
|
</Alert>
|
|
)}
|
|
<DataTable
|
|
columns={columns}
|
|
rows={data}
|
|
getRowId={(row, i) => row.authid ?? `${row.tenantid}-${row.loginemail}-${i}`}
|
|
loading={isLoading}
|
|
emptyState={<EmptyState icon={Building2} size="sm" title="No client logins yet" />}
|
|
/>
|
|
<EditClientModal row={editing} onClose={() => setEditing(null)} />
|
|
<RemoveClientModal row={removing} onClose={() => setRemoving(null)} />
|
|
</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,
|
|
...addressPayload(form),
|
|
},
|
|
{
|
|
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={
|
|
// type="button": this sits inside the form, and a plain button
|
|
// would also submit it, flashing every field's error.
|
|
<Button type="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>
|
|
|
|
<div className="sm:col-span-2">
|
|
<AddressAutocomplete
|
|
id="onboard-address"
|
|
label="Client address"
|
|
required
|
|
placeholder="Search the client's building, street or area…"
|
|
value={form.address}
|
|
onChange={(text) => set('address')(text)}
|
|
onPlaceSelected={(place) => {
|
|
setForm((f) => ({ ...f, ...addressFromPlace(place) }));
|
|
setErrors((e) => ({ ...e, address: undefined, pincode: undefined }));
|
|
}}
|
|
onPlaceCleared={() => setForm((f) => ({ ...f, latitude: '', longitude: '' }))}
|
|
/>
|
|
{errors.address ? (
|
|
<p className="mt-1 text-caption text-destructive" role="alert">
|
|
{errors.address}
|
|
</p>
|
|
) : (
|
|
<p className="mt-1 flex items-center gap-1 text-caption text-ink-3">
|
|
<MapPin className="h-3.5 w-3.5" />
|
|
{hasMapLocation(form)
|
|
? "Location pinned. This becomes the client's pickup point and zone."
|
|
: 'Pick a suggestion so the address gets a map location.'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<Field label="Pincode" required error={errors.pincode}>
|
|
<Input
|
|
value={form.pincode}
|
|
onChange={(e) => set('pincode')(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
placeholder="641002"
|
|
inputMode="numeric"
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="City">
|
|
<Input value={form.city} onChange={(e) => set('city')(e.target.value)} placeholder="Coimbatore" maxLength={80} />
|
|
</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>
|
|
);
|
|
}
|