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:
12
src/App.tsx
12
src/App.tsx
@@ -54,6 +54,7 @@ import StoreDetailView from './components/StoreDetailView';
|
|||||||
import DispatchHubView from './components/DispatchHubView';
|
import DispatchHubView from './components/DispatchHubView';
|
||||||
import LoginView from './components/LoginView';
|
import LoginView from './components/LoginView';
|
||||||
import UserStorePage from './components/UserStorePage';
|
import UserStorePage from './components/UserStorePage';
|
||||||
|
import SuperAdminPage from './components/SuperAdminPage';
|
||||||
import AwaitingApi from './components/AwaitingApi';
|
import AwaitingApi from './components/AwaitingApi';
|
||||||
import ComparisonModal from './components/ComparisonModal';
|
import ComparisonModal from './components/ComparisonModal';
|
||||||
import CatalogueBrowser from './components/CatalogueBrowser';
|
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 = (
|
const AdminConsole = (
|
||||||
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased overflow-x-hidden">
|
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased overflow-x-hidden">
|
||||||
{/* Navbar segment */}
|
{/* Navbar segment */}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
|
|||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// Form State: Tenant Onboarding
|
// Form State: Tenant Onboarding
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
const [tenantForm, setTenantForm] = useState({
|
const TENANT_FORM_INITIAL = {
|
||||||
tenantname: '',
|
tenantname: '',
|
||||||
companyname: '',
|
companyname: '',
|
||||||
primarycontact: '',
|
primarycontact: '',
|
||||||
@@ -61,7 +61,15 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
|
|||||||
city: 'Coimbatore',
|
city: 'Coimbatore',
|
||||||
state: 'Tamil Nadu',
|
state: 'Tamil Nadu',
|
||||||
postcode: '',
|
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);
|
const [tenantSuccess, setTenantSuccess] = useState<any>(null);
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
@@ -124,7 +132,7 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
|
|||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
const handleTenantSubmit = async (e: React.FormEvent) => {
|
const handleTenantSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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.');
|
alert('Kindly fill in all required fields.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,6 +141,20 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
|
|||||||
...tenantForm,
|
...tenantForm,
|
||||||
approved: 1,
|
approved: 1,
|
||||||
status: 'Active',
|
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);
|
setTenantSuccess(res);
|
||||||
alert('Tenant Onboarded successfully!');
|
alert('Tenant Onboarded successfully!');
|
||||||
@@ -244,7 +266,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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
|
Onboard Another Tenant
|
||||||
@@ -300,6 +322,33 @@ VALUES (${newUserId}, 1, 'Active', NOW());
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-bold text-slate-450 uppercase tracking-widest">HQ Street Address</label>
|
<label className="text-[10px] font-bold text-slate-450 uppercase tracking-widest">HQ Street Address</label>
|
||||||
<input
|
<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>.
|
Tenant <strong>{tenantForm.tenantname}</strong> has been provisioned. An administrator account has been dispatched with credentials tied to <strong>{tenantForm.primaryemail}</strong>.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<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"
|
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
|
Onboard Another Tenant
|
||||||
|
|||||||
45
src/components/SuperAdminPage.tsx
Normal file
45
src/components/SuperAdminPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,6 +68,11 @@ const RESPONSE_FIELDS = {
|
|||||||
applocation: 'applocation',
|
applocation: 'applocation',
|
||||||
locationid: 'locationid',
|
locationid: 'locationid',
|
||||||
locationname: 'locationname',
|
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;
|
} 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 {
|
export interface AuthUser {
|
||||||
role: LoginRole;
|
role: LoginRole;
|
||||||
@@ -87,6 +92,8 @@ export interface AuthUser {
|
|||||||
email: string;
|
email: string;
|
||||||
userid?: number;
|
userid?: number;
|
||||||
roleid?: number;
|
roleid?: number;
|
||||||
|
/** Platform operator, not scoped to any tenant — see RESPONSE_FIELDS.issuperadmin. */
|
||||||
|
issuperadmin?: boolean;
|
||||||
/** Phone number on the user record. */
|
/** Phone number on the user record. */
|
||||||
contactno?: string;
|
contactno?: string;
|
||||||
/** The merchant/tenant this user belongs to — scopes every Fiesta query. */
|
/** 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. */
|
/** Assemble the final AuthUser (role + identity) from a resolved user record. */
|
||||||
export function buildAuthUser(row: Row | null, email: string): AuthUser {
|
export function buildAuthUser(row: Row | null, email: string): AuthUser {
|
||||||
const roleid = row ? num(row[RESPONSE_FIELDS.roleid]) : 0;
|
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 applocation = row ? str(row[RESPONSE_FIELDS.applocation]).trim() : '';
|
||||||
const locationname = row ? str(row[RESPONSE_FIELDS.locationname]).trim() : '';
|
const locationname = row ? str(row[RESPONSE_FIELDS.locationname]).trim() : '';
|
||||||
const contactno = row ? str(row[RESPONSE_FIELDS.contactno]).trim() : '';
|
const contactno = row ? str(row[RESPONSE_FIELDS.contactno]).trim() : '';
|
||||||
return {
|
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),
|
name: displayName(row ?? {}, email),
|
||||||
email,
|
email,
|
||||||
userid: row && row[RESPONSE_FIELDS.userid] != null ? num(row[RESPONSE_FIELDS.userid]) : undefined,
|
userid: row && row[RESPONSE_FIELDS.userid] != null ? num(row[RESPONSE_FIELDS.userid]) : undefined,
|
||||||
roleid,
|
roleid,
|
||||||
|
issuperadmin: issuperadmin || undefined,
|
||||||
contactno: contactno || undefined,
|
contactno: contactno || undefined,
|
||||||
tenantid: row && row[RESPONSE_FIELDS.tenantid] != null ? num(row[RESPONSE_FIELDS.tenantid]) : undefined,
|
tenantid: row && row[RESPONSE_FIELDS.tenantid] != null ? num(row[RESPONSE_FIELDS.tenantid]) : undefined,
|
||||||
applocationid:
|
applocationid:
|
||||||
|
|||||||
@@ -1015,6 +1015,18 @@ export async function updateUser(input: UpdateUserInput): Promise<Row> {
|
|||||||
return fiestaSend<Row>('users/update', 'PUT', input);
|
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 {
|
export interface CreateTenantInput {
|
||||||
tenantname: string;
|
tenantname: string;
|
||||||
companyname: string;
|
companyname: string;
|
||||||
@@ -1027,11 +1039,15 @@ export interface CreateTenantInput {
|
|||||||
postcode?: string;
|
postcode?: string;
|
||||||
approved?: number;
|
approved?: number;
|
||||||
status?: string;
|
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> {
|
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',
|
method: 'POST',
|
||||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
|
|||||||
Reference in New Issue
Block a user