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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user