Add Super Admin login routing and wire tenant onboarding to auto-create a location

super_admin is now a real LoginRole, derived from the server's
issuperadmin flag (not a client-guessable roleid) — routes exclusively
to a new minimal SuperAdminPage, never the merchant console.

That page reuses the existing tenant/store/rider onboarding wizard
(AdminConsole) rather than duplicating it — its Tenant tab was already
calling the right composite endpoint but had nowhere to send location
data, and was never actually reachable from anywhere in the app. Now
it collects a primary outlet name + business category and sends a
nested tenantlocations object, so one submit provisions tenant +
active location + admin user instead of leaving the tenant
locationless. createTenantUser also moves off the mob API base onto
the web one, matching where this is actually called from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-07-16 20:46:07 +05:30
parent 186546f1fe
commit ecbae9d8fc
5 changed files with 143 additions and 9 deletions

View File

@@ -54,6 +54,7 @@ import StoreDetailView from './components/StoreDetailView';
import DispatchHubView from './components/DispatchHubView';
import LoginView from './components/LoginView';
import UserStorePage from './components/UserStorePage';
import SuperAdminPage from './components/SuperAdminPage';
import AwaitingApi from './components/AwaitingApi';
import ComparisonModal from './components/ComparisonModal';
import CatalogueBrowser from './components/CatalogueBrowser';
@@ -568,6 +569,17 @@ export default function App() {
);
}
// Platform operator, not scoped to any tenant — never sees the merchant
// console (dashboard/inventory/catalogue/etc), only tenant onboarding.
if (authRole === 'super_admin' && authUser) {
return (
<Routes>
<Route path="/superadmin/*" element={<SuperAdminPage onLogout={handleLogout} user={authUser} />} />
<Route path="*" element={<Navigate to="/superadmin/onboarding" replace />} />
</Routes>
);
}
const AdminConsole = (
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased overflow-x-hidden">
{/* Navbar segment */}

View File

@@ -51,7 +51,7 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
// ----------------------------------------------------
// Form State: Tenant Onboarding
// ----------------------------------------------------
const [tenantForm, setTenantForm] = useState({
const TENANT_FORM_INITIAL = {
tenantname: '',
companyname: '',
primarycontact: '',
@@ -61,7 +61,15 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
city: 'Coimbatore',
state: 'Tamil Nadu',
postcode: '',
});
// Primary outlet — created in the same call as the tenant, so a fresh
// tenant is never left without a location to actually operate from.
locationname: '',
// 1 Food & Dining, 2 Grocery & Daily, 3 Pharmacy, 4 Retail — the same
// categories categoryName() in fiestaMappers.ts already displays.
categoryid: 2,
applocationid: 1,
};
const [tenantForm, setTenantForm] = useState(TENANT_FORM_INITIAL);
const [tenantSuccess, setTenantSuccess] = useState<any>(null);
// ----------------------------------------------------
@@ -124,7 +132,7 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
// ----------------------------------------------------
const handleTenantSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!tenantForm.tenantname || !tenantForm.companyname || !tenantForm.primarycontact || !tenantForm.primaryemail) {
if (!tenantForm.tenantname || !tenantForm.companyname || !tenantForm.primarycontact || !tenantForm.primaryemail || !tenantForm.locationname) {
alert('Kindly fill in all required fields.');
return;
}
@@ -133,6 +141,20 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
...tenantForm,
approved: 1,
status: 'Active',
// Reuses the tenant's own address for its primary location — a
// freshly onboarded single-location merchant's outlet address is
// the same as their registered one.
tenantlocations: {
locationname: tenantForm.locationname,
email: tenantForm.primaryemail,
contactno: tenantForm.primarycontact,
address: tenantForm.address,
suburb: tenantForm.suburb,
city: tenantForm.city,
state: tenantForm.state,
postcode: tenantForm.postcode,
applocationid: tenantForm.applocationid,
},
});
setTenantSuccess(res);
alert('Tenant Onboarded successfully!');
@@ -244,7 +266,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
</p>
<button
type="button"
onClick={() => { setTenantSuccess(null); setTenantForm({ tenantname: '', companyname: '', primarycontact: '', primaryemail: '', address: '', suburb: '', city: 'Coimbatore', state: 'Tamil Nadu', postcode: '' }); }}
onClick={() => { setTenantSuccess(null); setTenantForm(TENANT_FORM_INITIAL); }}
className="bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider px-5 py-2.5 rounded-lg border-none cursor-pointer"
>
Onboard Another Tenant
@@ -300,6 +322,33 @@ VALUES (${newUserId}, 1, 'Active', NOW());
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] font-bold text-slate-450 uppercase tracking-widest">Primary Outlet Name (*)</label>
<input
type="text"
placeholder="e.g. Kaveri Groceries - Main Branch"
value={tenantForm.locationname}
onChange={(e) => setTenantForm({ ...tenantForm, locationname: e.target.value })}
className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800"
required
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold text-slate-450 uppercase tracking-widest">Business Category</label>
<select
value={tenantForm.categoryid}
onChange={(e) => setTenantForm({ ...tenantForm, categoryid: Number(e.target.value) })}
className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800 cursor-pointer"
>
<option value={1}>Food & Dining</option>
<option value={2}>Grocery & Daily</option>
<option value={3}>Pharmacy</option>
<option value={4}>Retail</option>
</select>
</div>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold text-slate-450 uppercase tracking-widest">HQ Street Address</label>
<input
@@ -972,7 +1021,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
Tenant <strong>{tenantForm.tenantname}</strong> has been provisioned. An administrator account has been dispatched with credentials tied to <strong>{tenantForm.primaryemail}</strong>.
</p>
<button
onClick={() => { setTenantSuccess(null); setTenantForm({ tenantname: '', companyname: '', primarycontact: '', primaryemail: '', address: '', suburb: '', city: 'Coimbatore', state: 'Tamil Nadu', postcode: '' }); }}
onClick={() => { setTenantSuccess(null); setTenantForm(TENANT_FORM_INITIAL); }}
className="bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider px-5 py-2.5 rounded-lg border-none cursor-pointer"
>
Onboard Another Tenant

View File

@@ -0,0 +1,45 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Exclusive landing page for issuperadmin logins — a platform operator, not
* scoped to any tenant. Deliberately minimal: no sidebar, no merchant nav,
* just the shared Header (for logout) and the existing tenant/store/rider
* onboarding wizard (AdminConsole), which until now was only reachable
* scoped to a single tenant's own Settings page.
*/
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import Header from './Header';
import AdminConsole from './AdminConsole';
import type { AuthUser } from '../services/auth';
interface SuperAdminPageProps {
user: AuthUser;
onLogout: () => void;
}
export default function SuperAdminPage({ user, onLogout }: SuperAdminPageProps) {
const profile = { name: user.name, role: 'Super Admin', email: user.email };
return (
<div className="min-h-screen bg-slate-50 flex flex-col">
<Header
isSidebarOpen={false}
onToggleSidebar={() => {}}
onHelpClick={() => {}}
onLogoutClick={onLogout}
profile={profile}
/>
<main className="flex-1 p-4 md:p-6">
<Routes>
<Route path="onboarding" element={<AdminConsole showHeader />} />
<Route path="*" element={<Navigate to="onboarding" replace />} />
</Routes>
</main>
</div>
);
}

View File

@@ -68,6 +68,11 @@ const RESPONSE_FIELDS = {
applocation: 'applocation',
locationid: 'locationid',
locationname: 'locationname',
// Server-derived flag, checked before roleid: a platform operator who
// isn't scoped to any tenant. Never inferred client-side (a roleid the
// client could send/expect would be spoofable) — this comes back on the
// login row itself, gated server-side in AppLogin.
issuperadmin: 'issuperadmin',
} as const;
/**
@@ -79,7 +84,7 @@ const ADMIN_ROLE_IDS = new Set<number>([1, 3]);
// ──────────────────────────────────────────────────────────────────────────────
export type LoginRole = 'admin' | 'user';
export type LoginRole = 'admin' | 'user' | 'super_admin';
export interface AuthUser {
role: LoginRole;
@@ -87,6 +92,8 @@ export interface AuthUser {
email: string;
userid?: number;
roleid?: number;
/** Platform operator, not scoped to any tenant — see RESPONSE_FIELDS.issuperadmin. */
issuperadmin?: boolean;
/** Phone number on the user record. */
contactno?: string;
/** The merchant/tenant this user belongs to — scopes every Fiesta query. */
@@ -229,15 +236,20 @@ export function matchTenantUser(users: Row[], email: string): Row | null {
/** Assemble the final AuthUser (role + identity) from a resolved user record. */
export function buildAuthUser(row: Row | null, email: string): AuthUser {
const roleid = row ? num(row[RESPONSE_FIELDS.roleid]) : 0;
const issuperadmin = Boolean(row && row[RESPONSE_FIELDS.issuperadmin]);
const applocation = row ? str(row[RESPONSE_FIELDS.applocation]).trim() : '';
const locationname = row ? str(row[RESPONSE_FIELDS.locationname]).trim() : '';
const contactno = row ? str(row[RESPONSE_FIELDS.contactno]).trim() : '';
return {
role: roleFromRoleId(roleid),
// issuperadmin is checked first: it's a server-derived flag from the
// login row, not something the client asserts, so it takes priority
// over the roleid-based admin/user split.
role: issuperadmin ? 'super_admin' : roleFromRoleId(roleid),
name: displayName(row ?? {}, email),
email,
userid: row && row[RESPONSE_FIELDS.userid] != null ? num(row[RESPONSE_FIELDS.userid]) : undefined,
roleid,
issuperadmin: issuperadmin || undefined,
contactno: contactno || undefined,
tenantid: row && row[RESPONSE_FIELDS.tenantid] != null ? num(row[RESPONSE_FIELDS.tenantid]) : undefined,
applocationid:

View File

@@ -1015,6 +1015,18 @@ export async function updateUser(input: UpdateUserInput): Promise<Row> {
return fiestaSend<Row>('users/update', 'PUT', input);
}
export interface CreateTenantLocationPayload {
locationname: string;
email?: string;
contactno?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: string;
applocationid?: number;
}
export interface CreateTenantInput {
tenantname: string;
companyname: string;
@@ -1027,11 +1039,15 @@ export interface CreateTenantInput {
postcode?: string;
approved?: number;
status?: string;
applocationid?: number;
categoryid?: number;
/** Auto-provisions the tenant's primary (Active) location in the same call. */
tenantlocations?: CreateTenantLocationPayload;
}
/** POST /tenants/createtenantuser — Onboard a new tenant and create their admin user. */
/** POST /tenants/createtenantuser — Onboard a new tenant, primary location, and admin user. */
export async function createTenantUser(input: CreateTenantInput): Promise<Row> {
const res = await fetch(`${FIESTA_MOB_BASE}/tenants/createtenantuser`, {
const res = await fetch(`${FIESTA_BASE}/tenants/createtenantuser`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(input),