diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c5e3c34 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index c5e8b4f..7e3d960 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,11 @@ yarn-error.log* # env files (can opt-in for committing if needed) .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 diff --git a/src/app/api/team/route.ts b/src/app/api/team/route.ts index 3aee464..85bf070 100644 --- a/src/app/api/team/route.ts +++ b/src/app/api/team/route.ts @@ -1,26 +1,43 @@ import type {NextRequest} from 'next/server'; import {teamApi} from '@/services/api/teamApi'; import {serveUpstream} from '@/shared/services/bff'; -import {toAuthUser} from '@/features/auth/services/userMapper'; -import type {ApiUser} from '@/services/api/types'; +import type {ApiTeamMember} from '@/services/api/types'; +import type {UserRole} from '@/features/auth/types/auth'; import type {TeamMember} from '@/features/team/types/team'; export const dynamic = 'force-dynamic'; -/** GET /api/team — console accounts for this company. */ -function toMember(u: ApiUser): TeamMember { - const user = toAuthUser(u); +/** + * GET /api/team — console accounts for this company. + * + * 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 { - id: user.id, - name: user.name, - email: user.email, - role: user.role, - organisation: user.organisation, + id: m.id, + // Falls back to the address rather than rendering a blank cell: somebody + // invited but not yet named still has to be identifiable. + name: m.full_name || m.email, + 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) { - return serveUpstream(req, (token) => teamApi.list(token), (users) => - users.map(toMember), + return serveUpstream(req, (token) => teamApi.list(token), (members) => + members.map(toMember), ); } diff --git a/src/features/settings/components/TeamManagement.tsx b/src/features/settings/components/TeamManagement.tsx index be7079b..f282a1b 100644 --- a/src/features/settings/components/TeamManagement.tsx +++ b/src/features/settings/components/TeamManagement.tsx @@ -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 { +/** + * 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 { 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[] = [ { - 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) => ( + + + + + {row.name} + + + {row.email} + + + + ), }, { - 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) => , }, { - 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) => ( + + + + {row.status} + + + ), }, + {key: 'lastLogin', header: 'Last sign-in', width: proportional(1), align: 'end'}, ]; export function TeamManagement() { - const toast = useToast(); - const [staffList, setStaffList] = useState(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[] = [ - { - key: 'name', - header: 'Staff Member', - width: proportional(2), - renderCell: (row) => ( - - - - - {row.name} - - - {row.email} - - - - ), - }, - { - key: 'role', - header: 'Role', - width: proportional(1), - renderCell: (row) => ( - - ), - }, - { - key: 'storeName', - header: 'Assigned Store', - width: proportional(1.5), - renderCell: (row) => {row.storeName}, - }, - { - key: 'status', - header: 'Status', - width: pixel(110), - renderCell: (row) => { - const v = - row.status === 'active' - ? 'success' - : row.status === 'suspended' - ? 'error' - : 'warning'; - return ; - }, - }, - { - key: 'lastLogin', - header: 'Last Login', - width: proportional(1.2), - renderCell: (row) => ( - - {row.lastLogin} - - ), - }, - { - key: 'actions', - header: 'Actions', - align: 'center', - width: pixel(160), - renderCell: (row) => - row.role === 'Owner' ? ( - - - Primary Owner - - - ) : ( - - , - }} - 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), - }, - ]} - /> - - ), - }, - ]; + const team = useTeam(); return ( - - setIsAdding(!isAdding)} - /> - } - > + } + empty={ + + } + > + {(members) => ( - {isAdding ? ( - - Invite New Staff Member - - - - - setRole(v as 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support') - } - options={['Manager', 'Cashier', 'Marketing', 'Support']} - /> - setStoreName(v)} - options={[ - 'All stores', - 'Indiranagar Flagship', - 'Koramangala 80ft', - 'Whitefield Main', - 'Jayanagar 4th Block', - ]} - /> -