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:
2026-09-17 12:05:41 +05:30
parent 697b0d9ef2
commit 759f3b79fd
8 changed files with 353 additions and 291 deletions

View File

@@ -1,300 +1,151 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {proportional} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
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 {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Avatar} from '@astryxdesign/core/Avatar';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Selector} from '@astryxdesign/core/Selector';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
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;
name: string;
email: string;
role: 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support';
storeName: string;
status: 'active' | 'suspended' | 'invited';
role: string;
status: 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',
name: 'Aravind',
email: 'aravind@nearle.in',
role: 'Owner',
storeName: 'All stores',
status: 'active',
lastLogin: 'Just now',
key: 'name',
header: 'Member',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Avatar name={row.name} size="sm" tooltip={false} />
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="xsm" color="secondary">
{row.email}
</Text>
</VStack>
</HStack>
),
},
{
id: 'usr-2',
name: 'Vikram Seth',
email: 'vikram@nearle.in',
role: 'Manager',
storeName: 'Indiranagar Flagship',
status: 'active',
lastLogin: 'Today, 14:20',
key: 'role',
header: 'Role',
width: proportional(1),
renderCell: (row) => <Badge variant="neutral" label={row.role} />,
},
{
id: 'usr-3',
name: 'Priya Sharma',
email: 'priya@nearle.in',
role: 'Cashier',
storeName: 'Koramangala 80ft',
status: 'active',
lastLogin: 'Today, 09:15',
},
{
id: 'usr-4',
name: 'Rahul Verma',
email: 'rahul@nearle.in',
role: 'Marketing',
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: 'status',
header: 'Access',
width: proportional(1),
renderCell: (row) => (
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={row.status === 'Active' ? 'success' : 'error'}
label={row.status}
/>
<Text size="sm" color="secondary">
{row.status}
</Text>
</HStack>
),
},
{key: 'lastLogin', header: 'Last sign-in', width: proportional(1), align: 'end'},
];
export function TeamManagement() {
const toast = useToast();
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>
),
},
];
const team = useTeam();
return (
<VStack gap={5}>
<StaticPanel
title="Team Directory & Staff Access"
subtitle="Manage store assignments, role permissions, password resets and account statuses."
actions={
<Button
variant="primary"
size="sm"
label={isAdding ? 'Cancel' : 'Add Staff Member'}
onClick={() => setIsAdding(!isAdding)}
/>
}
>
<PanelCard
title="Console access"
subtitle="Everybody who can sign in to this company's dashboard."
resource={team}
loading={<SkeletonRows count={4} />}
empty={
<EmptyPanel
icon="staff"
title="No team members yet"
description="People appear here once they accept an invitation."
/>
}
>
{(members) => (
<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
columns={columns}
data={staffList}
data={members.map(toRow)}
idKey="id"
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>
</StaticPanel>
</VStack>
)}
</PanelCard>
);
}

View File

@@ -6,11 +6,21 @@ import type {UserRole} from '@/features/auth/types/auth';
* 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
* 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 {
id: string;
name: string;
email: string;
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;
}