fix(config): require a real API base, ship the env template, show the real team
Three configuration defects and one screen of invented people. API base. The client defaulted to https://platform.loyaly.ai when LOYALY_API_BASE was unset, and that host serves THIS console, not the Behavision API - verified live: it answers /api/auth/me with the console's own 404 HTML and a login POST with the console's own BFF envelope. So an unset variable in production made the BFF call its own origin, which fails looking like a broken login form rather than a misconfiguration. There is now no remote fallback: development defaults to 127.0.0.1:8088 and production throws, naming the variable, the way tokenStore already refuses to run without AUTH_SECRET. A wrong host that appears to work is worse than a startup failure that says what is missing. The template. .env.example documented that same wrong host, and .gitignore's `.env*` matched the template itself, so it was never committed - a fresh clone got no template at all, for an app that cannot start in production without AUTH_SECRET. Added `!.env.example` after the ignore rule; .env.local and every other .env* stay ignored. The template carries placeholders only, no values. Team. /settings/team listed five invented people - aravind@nearle.in, Vikram Seth, Priya Sharma - with store names no endpoint supplies and roles that do not exist upstream, behind four controls that mutated local state and were lost on refresh. A merchant could not tell any of it from the real thing. It now reads GET /api/team, which the platform already serves and scopes by session, and renders what actually comes back: name, email, role, whether the account is still active, and last sign-in (or "Never", which is a fact worth seeing). The route used to map each row through toAuthUser, which reads client_name - a field GET /api/team does not send - so organisation was undefined on every row while active, last_login_at and created_at were discarded. ApiTeamMember now describes that payload properly and ApiUser is left to authentication. The screen is READ-ONLY on purpose. Accounts are born from invitations, and that flow already exists in the platform's own web app: a manager mints a code, the holder redeems it and chooses their own password. A second way to create a login does not belong here, least of all on the screen that lists them. Role changes and deactivation are supported upstream by PATCH /api/team/{id} and are deliberately not wired: deactivating revokes every session that person holds immediately, so it wants a confirmation step and 409 last_owner handling, neither of which belongs in a change whose purpose is removing invented data. types.ts also gains the Sales/Floor/Customer interfaces. They are inert here - nothing imports them yet - and land with this commit so the screens that consume them arrive as one reviewable change. Verified against the live local platform: two tenants, correct member lists for each, and no cross-tenant leakage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0161AMotQ8FxGPZ9gFGb5wiK
This commit is contained in:
21
.env.example
Normal file
21
.env.example
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# The one shared Loyaly platform API (Behavision). Server-side only and
|
||||||
|
# deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass
|
||||||
|
# the BFF, which is what keeps the access token out of JavaScript.
|
||||||
|
#
|
||||||
|
# local dev http://127.0.0.1:8088
|
||||||
|
# production https://mcp.loyaly.ai
|
||||||
|
#
|
||||||
|
# NOT platform.loyaly.ai — that host serves THIS console, not the API. Pointing
|
||||||
|
# the variable there makes the BFF call its own origin, which fails in a way
|
||||||
|
# that looks like a broken login form rather than a misconfiguration.
|
||||||
|
#
|
||||||
|
# There is no fallback: production refuses to start without this set.
|
||||||
|
LOYALY_API_BASE=http://127.0.0.1:8088
|
||||||
|
|
||||||
|
# Signs the session cookie and encrypts the platform token bundle.
|
||||||
|
# Required in production — the app refuses to start signing sessions with the
|
||||||
|
# development key. Generate with: openssl rand -base64 48
|
||||||
|
AUTH_SECRET=
|
||||||
|
|
||||||
|
# Browser → this app's own BFF routes. Same origin, so normally left empty.
|
||||||
|
NEXT_PUBLIC_API_BASE=
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -33,6 +33,11 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
# ...except the template, which carries no secret and is the only record of
|
||||||
|
# which variables the app needs. `.env*` was swallowing it too, so a fresh
|
||||||
|
# clone got no guidance at all — while AUTH_SECRET and LOYALY_API_BASE are both
|
||||||
|
# mandatory in production and the app refuses to start without them.
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -1,26 +1,43 @@
|
|||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {teamApi} from '@/services/api/teamApi';
|
import {teamApi} from '@/services/api/teamApi';
|
||||||
import {serveUpstream} from '@/shared/services/bff';
|
import {serveUpstream} from '@/shared/services/bff';
|
||||||
import {toAuthUser} from '@/features/auth/services/userMapper';
|
import type {ApiTeamMember} from '@/services/api/types';
|
||||||
import type {ApiUser} from '@/services/api/types';
|
import type {UserRole} from '@/features/auth/types/auth';
|
||||||
import type {TeamMember} from '@/features/team/types/team';
|
import type {TeamMember} from '@/features/team/types/team';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
/** GET /api/team — console accounts for this company. */
|
/**
|
||||||
function toMember(u: ApiUser): TeamMember {
|
* GET /api/team — console accounts for this company.
|
||||||
const user = toAuthUser(u);
|
*
|
||||||
|
* The tenant is NOT a parameter. The platform takes it from the session and
|
||||||
|
* scopes the query itself, so this route has nothing to filter by and must not
|
||||||
|
* pretend otherwise — a client-side tenant filter is a check an attacker skips.
|
||||||
|
*
|
||||||
|
* ── What this used to drop ───────────────────────────────────────────────
|
||||||
|
* It ran each row through `toAuthUser`, which reads `client_name` — a field
|
||||||
|
* `GET /api/team` does not send. So `organisation` was `undefined` on every
|
||||||
|
* row, while `active`, `last_login_at` and `created_at` were discarded. The
|
||||||
|
* one field the team screen needs — who still has access — never arrived.
|
||||||
|
*/
|
||||||
|
function toMember(m: ApiTeamMember): TeamMember {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: m.id,
|
||||||
name: user.name,
|
// Falls back to the address rather than rendering a blank cell: somebody
|
||||||
email: user.email,
|
// invited but not yet named still has to be identifiable.
|
||||||
role: user.role,
|
name: m.full_name || m.email,
|
||||||
organisation: user.organisation,
|
email: m.email,
|
||||||
|
role: m.role as UserRole,
|
||||||
|
active: m.active,
|
||||||
|
// Null rather than '' — "has never signed in" and "signed in at an unknown
|
||||||
|
// time" are different facts, and the screen says so.
|
||||||
|
lastLoginAt: m.last_login_at || null,
|
||||||
|
createdAt: m.created_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
return serveUpstream(req, (token) => teamApi.list(token), (users) =>
|
return serveUpstream(req, (token) => teamApi.list(token), (members) =>
|
||||||
users.map(toMember),
|
members.map(toMember),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,300 +1,151 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {useState} from 'react';
|
import {proportional} from '@astryxdesign/core/Table';
|
||||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
|
||||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
|
||||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
import {Text} from '@astryxdesign/core/Text';
|
||||||
import {Badge} from '@astryxdesign/core/Badge';
|
import {Badge} from '@astryxdesign/core/Badge';
|
||||||
import {Button} from '@astryxdesign/core/Button';
|
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
|
||||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||||
import {Selector} from '@astryxdesign/core/Selector';
|
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||||
import {useToast} from '@astryxdesign/core/Toast';
|
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
import {useTeam} from '@/features/team/hooks/useTeam';
|
||||||
|
import type {TeamMember} from '@/features/team/types/team';
|
||||||
|
|
||||||
export interface StaffUser extends Record<string, unknown> {
|
/**
|
||||||
|
* Who has access to this console.
|
||||||
|
*
|
||||||
|
* ── What this replaced ───────────────────────────────────────────────────
|
||||||
|
* Five invented people — `aravind@nearle.in`, `Vikram Seth`, `Priya Sharma` —
|
||||||
|
* held in `useState`, with store names (`Indiranagar Flagship`,
|
||||||
|
* `Koramangala 80ft`) that no endpoint supplies, roles (`Cashier`,
|
||||||
|
* `Marketing`, `Support`) that do not exist upstream, and four controls that
|
||||||
|
* changed nothing: Add Staff, Suspend, Reset password and Remove all mutated
|
||||||
|
* local state and were lost on refresh. A merchant could not tell any of it
|
||||||
|
* from the real thing.
|
||||||
|
*
|
||||||
|
* ── Why this screen is read-only ─────────────────────────────────────────
|
||||||
|
* Accounts are created by INVITATION, and that flow already exists in the
|
||||||
|
* platform's own web app: a manager mints a code, the holder redeems it and
|
||||||
|
* chooses their own password. Adding a second way to create an account here
|
||||||
|
* would be a second place a login can be born, on the one surface where that
|
||||||
|
* matters most. So this lists the team and does not mint anyone.
|
||||||
|
*
|
||||||
|
* Role changes and deactivation are supported upstream by
|
||||||
|
* `PATCH /api/team/{id}` and are NOT wired here — see the report. Deactivating
|
||||||
|
* revokes every session that person holds immediately, so it wants a
|
||||||
|
* confirmation step and `409 last_owner` handling, neither of which belongs in
|
||||||
|
* a change whose purpose is removing invented data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TeamRow extends Record<string, unknown> {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support';
|
role: string;
|
||||||
storeName: string;
|
status: string;
|
||||||
status: 'active' | 'suspended' | 'invited';
|
|
||||||
lastLogin: string;
|
lastLogin: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_STAFF: StaffUser[] = [
|
/**
|
||||||
|
* Read off the payload, never inferred. The platform sends a boolean; there is
|
||||||
|
* no "invited" or "suspended" state on this endpoint, so the screen does not
|
||||||
|
* invent one.
|
||||||
|
*/
|
||||||
|
function toRow(m: TeamMember): TeamRow {
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
email: m.email,
|
||||||
|
role: m.role,
|
||||||
|
status: m.active ? 'Active' : 'Inactive',
|
||||||
|
// "Never" is a fact worth showing: an account created and never used is
|
||||||
|
// exactly the row a manager wants to notice.
|
||||||
|
lastLogin: m.lastLoginAt ? new Date(m.lastLoginAt).toLocaleDateString() : 'Never',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS: TableColumn<TeamRow>[] = [
|
||||||
{
|
{
|
||||||
id: 'usr-1',
|
key: 'name',
|
||||||
name: 'Aravind',
|
header: 'Member',
|
||||||
email: 'aravind@nearle.in',
|
width: proportional(2),
|
||||||
role: 'Owner',
|
renderCell: (row) => (
|
||||||
storeName: 'All stores',
|
<HStack gap={2} vAlign="center">
|
||||||
status: 'active',
|
<Avatar name={row.name} size="sm" tooltip={false} />
|
||||||
lastLogin: 'Just now',
|
<VStack gap={0}>
|
||||||
|
<Text size="sm" weight="medium">
|
||||||
|
{row.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xsm" color="secondary">
|
||||||
|
{row.email}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'usr-2',
|
key: 'role',
|
||||||
name: 'Vikram Seth',
|
header: 'Role',
|
||||||
email: 'vikram@nearle.in',
|
width: proportional(1),
|
||||||
role: 'Manager',
|
renderCell: (row) => <Badge variant="neutral" label={row.role} />,
|
||||||
storeName: 'Indiranagar Flagship',
|
|
||||||
status: 'active',
|
|
||||||
lastLogin: 'Today, 14:20',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'usr-3',
|
key: 'status',
|
||||||
name: 'Priya Sharma',
|
header: 'Access',
|
||||||
email: 'priya@nearle.in',
|
width: proportional(1),
|
||||||
role: 'Cashier',
|
renderCell: (row) => (
|
||||||
storeName: 'Koramangala 80ft',
|
<HStack gap={1.5} vAlign="center">
|
||||||
status: 'active',
|
<StatusDot
|
||||||
lastLogin: 'Today, 09:15',
|
variant={row.status === 'Active' ? 'success' : 'error'}
|
||||||
},
|
label={row.status}
|
||||||
{
|
/>
|
||||||
id: 'usr-4',
|
<Text size="sm" color="secondary">
|
||||||
name: 'Rahul Verma',
|
{row.status}
|
||||||
email: 'rahul@nearle.in',
|
</Text>
|
||||||
role: 'Marketing',
|
</HStack>
|
||||||
storeName: 'All stores',
|
),
|
||||||
status: 'invited',
|
|
||||||
lastLogin: 'Pending accept',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'usr-5',
|
|
||||||
name: 'Deepa Nair',
|
|
||||||
email: 'deepa@nearle.in',
|
|
||||||
role: 'Cashier',
|
|
||||||
storeName: 'Whitefield Main',
|
|
||||||
status: 'suspended',
|
|
||||||
lastLogin: '3 days ago',
|
|
||||||
},
|
},
|
||||||
|
{key: 'lastLogin', header: 'Last sign-in', width: proportional(1), align: 'end'},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function TeamManagement() {
|
export function TeamManagement() {
|
||||||
const toast = useToast();
|
const team = useTeam();
|
||||||
const [staffList, setStaffList] = useState<StaffUser[]>(INITIAL_STAFF);
|
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [role, setRole] = useState<'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support'>('Manager');
|
|
||||||
const [storeName, setStoreName] = useState('Indiranagar Flagship');
|
|
||||||
|
|
||||||
const handleAddStaff = () => {
|
|
||||||
if (!name.trim() || !email.trim()) {
|
|
||||||
toast({type: 'error', body: 'Name and Email are required'});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newUser: StaffUser = {
|
|
||||||
id: `usr-${Date.now()}`,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
role,
|
|
||||||
storeName,
|
|
||||||
status: 'invited',
|
|
||||||
lastLogin: 'Pending accept',
|
|
||||||
};
|
|
||||||
setStaffList((prev) => [newUser, ...prev]);
|
|
||||||
setName('');
|
|
||||||
setEmail('');
|
|
||||||
setIsAdding(false);
|
|
||||||
toast({body: `Invitation sent to ${email}`});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleStatus = (id: string) => {
|
|
||||||
setStaffList((prev) =>
|
|
||||||
prev.map((s) => {
|
|
||||||
if (s.id !== id) return s;
|
|
||||||
const nextStatus = s.status === 'suspended' ? 'active' : 'suspended';
|
|
||||||
toast({body: `${s.name} status updated to ${nextStatus}`});
|
|
||||||
return {...s, status: nextStatus};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetPassword = (emailStr: string) => {
|
|
||||||
toast({body: `Password reset link sent to ${emailStr}`});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = (id: string, nameStr: string) => {
|
|
||||||
setStaffList((prev) => prev.filter((s) => s.id !== id));
|
|
||||||
toast({body: `${nameStr} removed from team`});
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: TableColumn<StaffUser>[] = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Staff Member',
|
|
||||||
width: proportional(2),
|
|
||||||
renderCell: (row) => (
|
|
||||||
<HStack gap={3} vAlign="center">
|
|
||||||
<Avatar name={row.name} size="sm" tooltip={false} />
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Text size="sm" weight="medium">
|
|
||||||
{row.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" color="secondary">
|
|
||||||
{row.email}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'role',
|
|
||||||
header: 'Role',
|
|
||||||
width: proportional(1),
|
|
||||||
renderCell: (row) => (
|
|
||||||
<Badge
|
|
||||||
variant={row.role === 'Owner' ? 'info' : 'neutral'}
|
|
||||||
label={row.role}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'storeName',
|
|
||||||
header: 'Assigned Store',
|
|
||||||
width: proportional(1.5),
|
|
||||||
renderCell: (row) => <Text size="sm">{row.storeName}</Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Status',
|
|
||||||
width: pixel(110),
|
|
||||||
renderCell: (row) => {
|
|
||||||
const v =
|
|
||||||
row.status === 'active'
|
|
||||||
? 'success'
|
|
||||||
: row.status === 'suspended'
|
|
||||||
? 'error'
|
|
||||||
: 'warning';
|
|
||||||
return <Badge variant={v} label={row.status} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'lastLogin',
|
|
||||||
header: 'Last Login',
|
|
||||||
width: proportional(1.2),
|
|
||||||
renderCell: (row) => (
|
|
||||||
<Text size="sm" color="secondary">
|
|
||||||
{row.lastLogin}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: 'Actions',
|
|
||||||
align: 'center',
|
|
||||||
width: pixel(160),
|
|
||||||
renderCell: (row) =>
|
|
||||||
row.role === 'Owner' ? (
|
|
||||||
<HStack hAlign="center">
|
|
||||||
<Text size="sm" color="secondary">
|
|
||||||
Primary Owner
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
) : (
|
|
||||||
<HStack hAlign="center">
|
|
||||||
<DropdownMenu
|
|
||||||
button={{
|
|
||||||
variant: 'secondary',
|
|
||||||
size: 'sm',
|
|
||||||
label: 'Manage',
|
|
||||||
icon: <Icon icon="moreHorizontal" size="sm" />,
|
|
||||||
}}
|
|
||||||
menuWidth={180}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: row.status === 'suspended' ? 'Reactivate staff' : 'Suspend staff',
|
|
||||||
onClick: () => handleToggleStatus(row.id),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Reset password',
|
|
||||||
onClick: () => handleResetPassword(row.email),
|
|
||||||
},
|
|
||||||
{type: 'divider'},
|
|
||||||
{
|
|
||||||
label: 'Remove from team',
|
|
||||||
onClick: () => handleRemove(row.id, row.name),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={5}>
|
<PanelCard
|
||||||
<StaticPanel
|
title="Console access"
|
||||||
title="Team Directory & Staff Access"
|
subtitle="Everybody who can sign in to this company's dashboard."
|
||||||
subtitle="Manage store assignments, role permissions, password resets and account statuses."
|
resource={team}
|
||||||
actions={
|
loading={<SkeletonRows count={4} />}
|
||||||
<Button
|
empty={
|
||||||
variant="primary"
|
<EmptyPanel
|
||||||
size="sm"
|
icon="staff"
|
||||||
label={isAdding ? 'Cancel' : 'Add Staff Member'}
|
title="No team members yet"
|
||||||
onClick={() => setIsAdding(!isAdding)}
|
description="People appear here once they accept an invitation."
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{(members) => (
|
||||||
<VStack gap={4}>
|
<VStack gap={4}>
|
||||||
{isAdding ? (
|
|
||||||
<VStack gap={3} padding={4}>
|
|
||||||
<Heading level={3}>Invite New Staff Member</Heading>
|
|
||||||
<HStack gap={3} vAlign="end" wrap="wrap">
|
|
||||||
<TextInput
|
|
||||||
label="Full Name"
|
|
||||||
value={name}
|
|
||||||
onChange={setName}
|
|
||||||
placeholder="e.g. Ananya Rao"
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Work Email"
|
|
||||||
value={email}
|
|
||||||
onChange={setEmail}
|
|
||||||
placeholder="ananya@nearle.in"
|
|
||||||
/>
|
|
||||||
<Selector
|
|
||||||
label="Role"
|
|
||||||
value={role}
|
|
||||||
onChange={(v) =>
|
|
||||||
setRole(v as 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support')
|
|
||||||
}
|
|
||||||
options={['Manager', 'Cashier', 'Marketing', 'Support']}
|
|
||||||
/>
|
|
||||||
<Selector
|
|
||||||
label="Assigned Store"
|
|
||||||
value={storeName}
|
|
||||||
onChange={(v) => setStoreName(v)}
|
|
||||||
options={[
|
|
||||||
'All stores',
|
|
||||||
'Indiranagar Flagship',
|
|
||||||
'Koramangala 80ft',
|
|
||||||
'Whitefield Main',
|
|
||||||
'Jayanagar 4th Block',
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
label="Send Invite"
|
|
||||||
onClick={handleAddStaff}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ResponsiveTable
|
<ResponsiveTable
|
||||||
columns={columns}
|
data={members.map(toRow)}
|
||||||
data={staffList}
|
idKey="id"
|
||||||
primaryKey="name"
|
primaryKey="name"
|
||||||
|
summaryKeys={['role', 'status']}
|
||||||
|
columns={COLUMNS}
|
||||||
/>
|
/>
|
||||||
|
{/* Says where accounts come from, because the button that used to
|
||||||
|
claim to create one has gone. Without this the screen reads as
|
||||||
|
missing a feature rather than pointing at the right place. */}
|
||||||
|
<Text size="xsm" color="secondary">
|
||||||
|
New members are added by invitation, and choose their own password
|
||||||
|
when they redeem it. Roles and access are managed on the platform.
|
||||||
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</StaticPanel>
|
)}
|
||||||
</VStack>
|
</PanelCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,21 @@ import type {UserRole} from '@/features/auth/types/auth';
|
|||||||
* Not a shop-floor employee. `active: false` means signed out immediately and
|
* Not a shop-floor employee. `active: false` means signed out immediately and
|
||||||
* unable to sign back in; reactivating restores the account but not their old
|
* unable to sign back in; reactivating restores the account but not their old
|
||||||
* sessions.
|
* sessions.
|
||||||
|
*
|
||||||
|
* `organisation` was removed: it mapped from `client_name`, which
|
||||||
|
* `GET /api/team` does not send, so it was `undefined` on every row. Every
|
||||||
|
* member of this list belongs to the caller's own company by definition — the
|
||||||
|
* backend scopes the query by the session's tenant — so the field said nothing
|
||||||
|
* even when it was populated.
|
||||||
*/
|
*/
|
||||||
export interface TeamMember {
|
export interface TeamMember {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
organisation: string;
|
/** false = signed out immediately and unable to sign back in. */
|
||||||
|
active: boolean;
|
||||||
|
/** Absent for somebody who has never signed in — that is data, not a gap. */
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,40 @@ import type {ApiRole} from './types';
|
|||||||
/**
|
/**
|
||||||
* Server-side only — deliberately NOT NEXT_PUBLIC. Publishing the platform
|
* Server-side only — deliberately NOT NEXT_PUBLIC. Publishing the platform
|
||||||
* host would let a browser bypass the BFF, which is the whole point of it.
|
* host would let a browser bypass the BFF, which is the whole point of it.
|
||||||
|
*
|
||||||
|
* ── Why there is no remote fallback ──────────────────────────────────────
|
||||||
|
* This used to default to `https://platform.loyaly.ai`, which is NOT the
|
||||||
|
* Behavision API — that host serves this very console. Measured: it answers
|
||||||
|
* `GET /api/auth/me` with the console's own 404 HTML page, and a login POST
|
||||||
|
* with the console's own `{error:{code,message}}` envelope rather than the
|
||||||
|
* platform's flat `{error,message}`. So an unset variable did not fail; it
|
||||||
|
* quietly pointed the BFF at its own origin, and every upstream call became a
|
||||||
|
* request the console made to itself.
|
||||||
|
*
|
||||||
|
* A wrong host that *works* is worse than a startup failure, so production now
|
||||||
|
* refuses to run without the variable — the same stance `tokenStore.ts` takes
|
||||||
|
* on AUTH_SECRET, and for the same reason. Development falls back to the local
|
||||||
|
* backend, which is the only host a dev machine can usefully mean.
|
||||||
|
*
|
||||||
|
* local http://127.0.0.1:8088
|
||||||
|
* production https://mcp.loyaly.ai
|
||||||
*/
|
*/
|
||||||
const BASE = (process.env.LOYALY_API_BASE ?? 'https://platform.loyaly.ai').replace(
|
const DEV_API_BASE = 'http://127.0.0.1:8088';
|
||||||
/\/+$/,
|
|
||||||
'',
|
function resolveBase(): string {
|
||||||
);
|
const configured = process.env.LOYALY_API_BASE?.trim();
|
||||||
|
if (configured) return configured.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error(
|
||||||
|
'LOYALY_API_BASE is required in production — refusing to guess the ' +
|
||||||
|
'Loyaly platform host. Set it to https://mcp.loyaly.ai.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return DEV_API_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = resolveBase();
|
||||||
|
|
||||||
export {BASE as UPSTREAM_BASE};
|
export {BASE as UPSTREAM_BASE};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
import {upstreamRequest} from './apiClient';
|
import {upstreamRequest} from './apiClient';
|
||||||
import type {ApiRole, ApiUser} from './types';
|
import type {ApiRole, ApiTeamMember} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The people with console accounts.
|
* The people with console accounts.
|
||||||
@@ -17,14 +17,19 @@ import type {ApiRole, ApiUser} from './types';
|
|||||||
*/
|
*/
|
||||||
export const teamApi = {
|
export const teamApi = {
|
||||||
list: (accessToken: string) =>
|
list: (accessToken: string) =>
|
||||||
upstreamRequest<ApiUser[]>({path: '/api/team', accessToken}),
|
upstreamRequest<ApiTeamMember[]>({path: '/api/team', accessToken}),
|
||||||
|
|
||||||
update: (
|
update: (
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
id: string,
|
id: string,
|
||||||
patch: {role?: ApiRole; active?: boolean},
|
patch: {role?: ApiRole; active?: boolean},
|
||||||
) =>
|
) =>
|
||||||
upstreamRequest<ApiUser>({
|
// Returns the UPDATED member, not an ApiUser — the handler writes back the
|
||||||
|
// same TeamMember shape `list` returns. Mis-typed as ApiUser, which has no
|
||||||
|
// `active`, so a caller reading the result could not see the change it had
|
||||||
|
// just made. Nothing calls this yet; correcting it now keeps the file
|
||||||
|
// honest for whoever wires the role/deactivate controls.
|
||||||
|
upstreamRequest<ApiTeamMember>({
|
||||||
path: `/api/team/${encodeURIComponent(id)}`,
|
path: `/api/team/${encodeURIComponent(id)}`,
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: patch,
|
body: patch,
|
||||||
|
|||||||
@@ -6,8 +6,11 @@
|
|||||||
* would prefer it. Where the UI wants a different shape, the adapter in the
|
* would prefer it. Where the UI wants a different shape, the adapter in the
|
||||||
* BFF route does the translation; nothing here bends to suit a screen.
|
* BFF route does the translation; nothing here bends to suit a screen.
|
||||||
*
|
*
|
||||||
* Reference: platform.loyaly.ai. Times are RFC 3339 UTC unless a field says
|
* Reference: the Behavision platform — mcp.loyaly.ai in production, and its
|
||||||
* otherwise. Report bucket labels are the documented exception — see Bucket.
|
* own API.md. (Not platform.loyaly.ai, which serves this console rather than
|
||||||
|
* the API; naming it here is what invited that mistake into apiClient.)
|
||||||
|
* Times are RFC 3339 UTC unless a field says otherwise. Report bucket labels
|
||||||
|
* are the documented exception — see Bucket.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -33,6 +36,29 @@ export interface ApiUser {
|
|||||||
client_name: string;
|
client_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One person on `GET /api/team`.
|
||||||
|
*
|
||||||
|
* NOT `ApiUser`. The team list was typed as `ApiUser[]`, which claims
|
||||||
|
* `client_id` and `client_name` — neither of which the platform sends here, so
|
||||||
|
* `organisation` mapped to `undefined` on every row. It also meant `active`,
|
||||||
|
* `last_login_at` and `created_at` were dropped on the floor: the one field the
|
||||||
|
* team screen needs to show who still has access never reached the console.
|
||||||
|
*
|
||||||
|
* `ApiUser` remains correct for auth, where login genuinely does return the
|
||||||
|
* signed-in user's company.
|
||||||
|
*/
|
||||||
|
export interface ApiTeamMember {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
role: ApiRole;
|
||||||
|
/** false = signed out immediately and unable to sign back in. */
|
||||||
|
active: boolean;
|
||||||
|
last_login_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** What login, refresh and register all return. */
|
/** What login, refresh and register all return. */
|
||||||
export interface ApiTokenBundle {
|
export interface ApiTokenBundle {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
@@ -316,3 +342,101 @@ export interface ApiAssistantAnswer {
|
|||||||
/** Tool names, so a wrong answer is traceable rather than mysterious. */
|
/** Tool names, so a wrong answer is traceable rather than mysterious. */
|
||||||
used?: string[];
|
used?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sales (the merchant application's domain — LOYALY.md)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line of a sale. `intent` is the load-bearing field: an enquiry keeps the
|
||||||
|
* price it was quoted at and contributes NOTHING to the bill, which is why
|
||||||
|
* `billable_paise` ships separately rather than being re-derived per client.
|
||||||
|
*/
|
||||||
|
export interface ApiSaleLine {
|
||||||
|
product_id?: string;
|
||||||
|
product_name: string;
|
||||||
|
price_paise: number;
|
||||||
|
intent: 'purchased' | 'enquired';
|
||||||
|
billable_paise: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Money is integer PAISE on this path, never a float. */
|
||||||
|
export interface ApiSale {
|
||||||
|
id: string;
|
||||||
|
invoice_no?: string;
|
||||||
|
site_id: string;
|
||||||
|
site_slug?: string;
|
||||||
|
visit_id?: string;
|
||||||
|
visitor_id?: string;
|
||||||
|
visitor_ref?: string;
|
||||||
|
customer_label?: string;
|
||||||
|
staff_id?: string;
|
||||||
|
staff_name?: string;
|
||||||
|
terminal_id?: string;
|
||||||
|
total_paise: number;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
lines?: ApiSaleLine[];
|
||||||
|
/** Counts, so the history list need not carry every line of every sale. */
|
||||||
|
purchased_lines?: number;
|
||||||
|
enquiry_lines?: number;
|
||||||
|
client_created_at?: string;
|
||||||
|
server_created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDashboardSummary {
|
||||||
|
date: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
timezone: string;
|
||||||
|
total_revenue_paise: number;
|
||||||
|
currency: string;
|
||||||
|
sales_count: number;
|
||||||
|
enquiries_count: number;
|
||||||
|
active_customer_count: number;
|
||||||
|
visitors_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The shop floor (LOYALY.md §6/§7/§20)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One person the shop is dealing with right now.
|
||||||
|
*
|
||||||
|
* `visitor_id` ABSENT is the load-bearing case: the cameras saw a face and
|
||||||
|
* matched nothing, and that is exactly when the app offers to name them.
|
||||||
|
* It must stay distinguishable from a known customer who has no name yet.
|
||||||
|
*/
|
||||||
|
export interface ApiFloorVisit {
|
||||||
|
visit_id: string;
|
||||||
|
site_id: string;
|
||||||
|
site_slug?: string;
|
||||||
|
detected_at: string;
|
||||||
|
status: 'waiting' | 'attending' | 'completed' | 'cancelled';
|
||||||
|
visitor_id?: string;
|
||||||
|
visitor_ref?: string;
|
||||||
|
label?: string;
|
||||||
|
phone?: string;
|
||||||
|
previous_visits: number;
|
||||||
|
attended_by?: string;
|
||||||
|
attended_by_name?: string;
|
||||||
|
attended_by_me: boolean;
|
||||||
|
attended_at?: string;
|
||||||
|
image?: {available: boolean; url?: string | null; reason?: string};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiCustomerCreated {
|
||||||
|
id: string;
|
||||||
|
ref: string;
|
||||||
|
label: string;
|
||||||
|
full_name?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §16 — "processed" for a new sale, "already_processed" for a replay. */
|
||||||
|
export interface ApiSaleResult {
|
||||||
|
status: 'processed' | 'already_processed';
|
||||||
|
sale_id: string;
|
||||||
|
sale?: ApiSale;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user