admin login issue
This commit is contained in:
82
src/features/admin/components/AdminLayout.tsx
Normal file
82
src/features/admin/components/AdminLayout.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
|
||||
import {BrandMark} from '@/shared/components/brand/BrandLogo';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
|
||||
/**
|
||||
* The platform console's frame.
|
||||
*
|
||||
* ── Same guard, different shell ──────────────────────────────────────────
|
||||
* `AuthGuard` is the one the workspace uses, reading the session minted by the
|
||||
* same login route. What is deliberately absent is `WorkspaceShell`: no site
|
||||
* switcher, no tenant navigation, no Loyaly AI rail. Every one of those is
|
||||
* scoped to a company, and a platform operator has none — a store picker with
|
||||
* nothing in it is worse than no store picker.
|
||||
*
|
||||
* ── Why it says "Platform" so loudly ─────────────────────────────────────
|
||||
* This console can suspend a company and delete one irreversibly. Somebody who
|
||||
* has both consoles open needs to know which tab they are typing into before
|
||||
* they click, not after. The badge is the cheapest possible version of that.
|
||||
*/
|
||||
export function AdminLayout({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<VStack gap={0} width="100%" minHeight="100vh">
|
||||
<AdminTopBar />
|
||||
<VStack gap={0} width="100%" paddingInline={8} paddingBlock={8}>
|
||||
{children}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminTopBar() {
|
||||
const {user, logout} = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
router.replace('/login');
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack
|
||||
gap={4}
|
||||
width="100%"
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
paddingInline={8}
|
||||
paddingBlock={4}
|
||||
className="border-b border-border bg-surface"
|
||||
>
|
||||
<HStack gap={3} vAlign="center">
|
||||
<BrandMark size={24} />
|
||||
<Text size="sm" weight="medium">
|
||||
Platform
|
||||
</Text>
|
||||
<Badge variant="neutral" label="Admin" />
|
||||
</HStack>
|
||||
|
||||
<HStack gap={3} vAlign="center">
|
||||
{/* The address, not the display name: on a console that can delete a
|
||||
company, which ACCOUNT is signed in matters more than whose it is. */}
|
||||
<Text size="xsm" color="secondary">
|
||||
{user?.email}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void signOut()}
|
||||
label="Sign out"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
278
src/features/admin/components/CompaniesPanel.tsx
Normal file
278
src/features/admin/components/CompaniesPanel.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
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 {
|
||||
useCompanies,
|
||||
useCompanyFilters,
|
||||
} from '@/features/admin/hooks/useCompanies';
|
||||
import {CreateCompanyDialog} from './CreateCompanyDialog';
|
||||
import {SuspendCompanyDialog} from './SuspendCompanyDialog';
|
||||
import {ResetOwnerPasswordDialog} from './ResetOwnerPasswordDialog';
|
||||
import {DeleteCompanyDialog} from './DeleteCompanyDialog';
|
||||
import type {Company, CompanyFilter} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Every company on the platform, and the four things an operator can do to one.
|
||||
*
|
||||
* ── The columns are exactly what the API returns ─────────────────────────
|
||||
* Name, slug, shops, accounts, status, created. `GET /api/admin/clients`
|
||||
* returns those seven fields and nothing else, so there is no owner column, no
|
||||
* plan, no revenue and no health. A column this list cannot fill would have to
|
||||
* be faked, and a faked column on the console that can delete a company is the
|
||||
* worst place in the product to put one.
|
||||
*
|
||||
* ── Why suspended rows are not hidden ────────────────────────────────────
|
||||
* A suspended company is the one an operator is most likely to be looking for
|
||||
* — it is the one somebody is on the phone about. It stays in the default list
|
||||
* with its status shown, and the filter is there to narrow deliberately.
|
||||
*/
|
||||
|
||||
interface CompanyRow extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
sites: number;
|
||||
users: number;
|
||||
status: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
function toRow(c: Company): CompanyRow {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
sites: c.sites,
|
||||
users: c.users,
|
||||
status: c.isActive ? 'Active' : 'Suspended',
|
||||
// Date only. A platform operator cares which week a tenant was onboarded,
|
||||
// never which minute.
|
||||
created: new Date(c.createdAt).toLocaleDateString(),
|
||||
};
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumn<CompanyRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Company',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
{/* The slug is shown on every row because it is what the delete
|
||||
confirmation asks for, and hunting for it afterwards is how
|
||||
somebody ends up copying the wrong one. */}
|
||||
<Text size="xsm" color="secondary">
|
||||
{row.slug}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{key: 'sites', header: 'Shops', width: proportional(1), align: 'end'},
|
||||
{key: 'users', header: 'Accounts', width: proportional(1), align: 'end'},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
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: 'created', header: 'Created', width: proportional(1), align: 'end'},
|
||||
];
|
||||
|
||||
type Action =
|
||||
| {kind: 'create'}
|
||||
| {kind: 'suspend'; company: Company}
|
||||
| {kind: 'reset'; company: Company}
|
||||
| {kind: 'delete'; company: Company}
|
||||
| null;
|
||||
|
||||
export function CompaniesPanel() {
|
||||
const companies = useCompanies();
|
||||
const {query, setQuery, filter, setFilter, visible} = useCompanyFilters(
|
||||
companies.data,
|
||||
);
|
||||
const [action, setAction] = useState<Action>(null);
|
||||
|
||||
/**
|
||||
* Nothing optimistic. Every mutation refetches and the table redraws from
|
||||
* what the server says — a row that claimed to be suspended because a PATCH
|
||||
* was sent, while the platform refused it, is exactly the lie this console
|
||||
* cannot afford.
|
||||
*/
|
||||
function refresh() {
|
||||
companies.refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<PanelCard
|
||||
title="Companies"
|
||||
subtitle="Every merchant company on this platform."
|
||||
resource={companies}
|
||||
loading={<SkeletonRows count={5} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No companies yet"
|
||||
description="Create your first merchant company to get started."
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAction({kind: 'create'})}
|
||||
label="Create company"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAction({kind: 'create'})}
|
||||
label="Create company"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(all) => (
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack gap={3} vAlign="center" hAlign="between">
|
||||
<TextInput
|
||||
label="Search companies"
|
||||
isLabelHidden
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search by name or slug…"
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={(v) => setFilter(v as CompanyFilter)}
|
||||
label="Filter by status"
|
||||
>
|
||||
<SegmentedControlItem value="all" label="All" />
|
||||
<SegmentedControlItem value="active" label="Active" />
|
||||
<SegmentedControlItem value="suspended" label="Suspended" />
|
||||
</SegmentedControl>
|
||||
</HStack>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
/* A search that matches nothing is NOT the same as a platform
|
||||
with no companies — saying "no companies yet" here would tell
|
||||
an operator their tenants had vanished. */
|
||||
<Text size="sm" color="secondary">
|
||||
No companies match that search. {all.length}{' '}
|
||||
{all.length === 1 ? 'company' : 'companies'} in total.
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveTable
|
||||
data={visible.map(toRow)}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
summaryKeys={['status', 'sites']}
|
||||
columns={COLUMNS}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
{visible.map((c) => (
|
||||
<HStack
|
||||
key={c.id}
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
width="100%"
|
||||
>
|
||||
<Text size="xsm" color="secondary">
|
||||
{c.name}
|
||||
</Text>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setAction({kind: 'reset', company: c})}
|
||||
label="Reset owner password"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setAction({kind: 'suspend', company: c})}
|
||||
label={c.isActive ? 'Suspend' : 'Reinstate'}
|
||||
/>
|
||||
{/* Delete is offered only on a suspended company, because
|
||||
the platform refuses it otherwise. The dialog still
|
||||
sends an active one and shows the real 409 if this is
|
||||
ever reached another way — the guard is a courtesy,
|
||||
never the rule. */}
|
||||
{c.isActive ? null : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setAction({kind: 'delete', company: c})}
|
||||
label="Delete"
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
{action?.kind === 'create' ? (
|
||||
<CreateCompanyDialog
|
||||
onClose={() => setAction(null)}
|
||||
onCreated={refresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action?.kind === 'suspend' ? (
|
||||
<SuspendCompanyDialog
|
||||
company={action.company}
|
||||
onClose={() => setAction(null)}
|
||||
onDone={refresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action?.kind === 'reset' ? (
|
||||
<ResetOwnerPasswordDialog
|
||||
company={action.company}
|
||||
onClose={() => setAction(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action?.kind === 'delete' ? (
|
||||
<DeleteCompanyDialog
|
||||
company={action.company}
|
||||
onClose={() => setAction(null)}
|
||||
onDeleted={refresh}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
176
src/features/admin/components/CreateCompanyDialog.tsx
Normal file
176
src/features/admin/components/CreateCompanyDialog.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
|
||||
/**
|
||||
* Create a company and its owner, in one transaction.
|
||||
*
|
||||
* ── Why the owner is not optional ────────────────────────────────────────
|
||||
* The platform creates both together on purpose: a company with no owner is a
|
||||
* tenant nobody can sign into, and it looks entirely normal in the list. The
|
||||
* operator finds out weeks later, when the customer says their login does not
|
||||
* work. So this form asks for the owner up front rather than offering a
|
||||
* "add the owner later" path that upstream would not honour anyway.
|
||||
*
|
||||
* ── The password is not ours to choose ───────────────────────────────────
|
||||
* There is no password field, and adding one would be a mistake even though
|
||||
* the API accepts it. An operator inventing a password for somebody else
|
||||
* invents a weak one and then sends it over chat. The server generates it and
|
||||
* returns it exactly once — it is bcrypt-hashed on the way in and cannot be
|
||||
* fetched again — so the reveal below is the only chance to copy it.
|
||||
*
|
||||
* ── Slug ─────────────────────────────────────────────────────────────────
|
||||
* Optional, and left blank by default. The platform derives it from the name,
|
||||
* strips the characters that would break a broker topic, and then it can NEVER
|
||||
* be changed. A field that permanent deserves to be a deliberate act, not a
|
||||
* box somebody fills in because it is there.
|
||||
*/
|
||||
export function CreateCompanyDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
/** Fired once the list should refetch — on success, and on close after one. */
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [ownerName, setOwnerName] = useState('');
|
||||
const [ownerEmail, setOwnerEmail] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [created, setCreated] = useState<{
|
||||
email: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
const canSubmit =
|
||||
companyName.trim() !== '' && ownerEmail.trim() !== '' && !busy;
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const res = await companyRepository.create({
|
||||
company_name: companyName.trim(),
|
||||
owner_email: ownerEmail.trim(),
|
||||
owner_name: ownerName.trim(),
|
||||
...(slug.trim() ? {slug: slug.trim()} : {}),
|
||||
});
|
||||
setBusy(false);
|
||||
|
||||
if (!res.ok || !res.data) {
|
||||
// The platform's own wording. It names the rule that was hit — a
|
||||
// duplicate address, a slug already taken — and "Something went wrong"
|
||||
// would throw that away.
|
||||
setError(res.message ?? 'Could not create that company.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreated({email: res.data.ownerEmail, password: res.data.password});
|
||||
// The company exists now, whether or not they close this dialog politely.
|
||||
onCreated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closing is what discards the password.
|
||||
*
|
||||
* It lives in this component's state and nowhere else: not in the URL, not in
|
||||
* localStorage, not in a toast that outlives the dialog, and never in a log.
|
||||
* Unmounting drops it, which is the strongest guarantee available on a page
|
||||
* — and the reason the warning says to copy it first.
|
||||
*/
|
||||
function close() {
|
||||
setCreated(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
purpose="info"
|
||||
width={460}
|
||||
aria-label="Create company"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={created ? 'Company created' : 'Create company'}
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
/>
|
||||
|
||||
{created ? (
|
||||
<VStack gap={4} width="100%">
|
||||
<Banner
|
||||
status="success"
|
||||
title={companyName.trim()}
|
||||
description={`Owner account created for ${created.email}.`}
|
||||
/>
|
||||
<SecretOnce
|
||||
value={created.password}
|
||||
label={`Password for ${created.email}`}
|
||||
note="Send it to them now. It cannot be shown again, and closing this dialog discards it."
|
||||
/>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button onClick={close} label="Done" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack gap={4} width="100%">
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<TextInput
|
||||
label="Company name"
|
||||
value={companyName}
|
||||
onChange={setCompanyName}
|
||||
placeholder="TeNext Retail"
|
||||
/>
|
||||
<TextInput
|
||||
label="Slug (optional)"
|
||||
value={slug}
|
||||
onChange={setSlug}
|
||||
placeholder="Derived from the name"
|
||||
description="Permanent once set — it becomes part of the company's broker topic."
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner email"
|
||||
type="email"
|
||||
value={ownerEmail}
|
||||
onChange={setOwnerEmail}
|
||||
placeholder="owner@company.com"
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner name"
|
||||
value={ownerName}
|
||||
onChange={setOwnerName}
|
||||
placeholder="Their full name"
|
||||
/>
|
||||
|
||||
<Text size="xsm" color="secondary">
|
||||
The owner's password is generated by the server and shown
|
||||
once, here, immediately after this succeeds.
|
||||
</Text>
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={close} label="Cancel" />
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
isDisabled={!canSubmit}
|
||||
isLoading={busy}
|
||||
label="Create company"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
125
src/features/admin/components/DeleteCompanyDialog.tsx
Normal file
125
src/features/admin/components/DeleteCompanyDialog.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Delete a company. Permanently.
|
||||
*
|
||||
* ── Why the typed slug ───────────────────────────────────────────────────
|
||||
* The platform requires the body to repeat the company's slug, and this dialog
|
||||
* asks for it rather than filling it in. That is the whole safeguard: a
|
||||
* confirm button can be clicked by muscle memory on the wrong row, and typing
|
||||
* "tenext-retail" cannot. The button stays disabled until it matches exactly —
|
||||
* no trimming of case, because the slug is lowercase upstream and a console
|
||||
* that quietly "fixes" what was typed is not confirming anything.
|
||||
*
|
||||
* ── Why the suspended check is NOT enforced here ─────────────────────────
|
||||
* The platform refuses to delete an active company (`409 still_active`), and
|
||||
* this dialog does not pre-empt that. It says so plainly, and the button is
|
||||
* still live: an active company is sent, and the platform's real 409 is what
|
||||
* appears. A console that blocked the request itself would eventually disagree
|
||||
* with the server about what is deletable, and that disagreement surfaces as a
|
||||
* delete that looked like it worked and did not happen.
|
||||
*
|
||||
* ── What is actually destroyed ───────────────────────────────────────────
|
||||
* Stored face images go from object storage first — a failure there is
|
||||
* `502 storage_error` and nothing else is touched — then the shop PCs' broker
|
||||
* logins, then every row by cascade: face templates, visits, users, sessions,
|
||||
* cameras. The data is biometric. There is no undo and no backup to ask for.
|
||||
*/
|
||||
export function DeleteCompanyDialog({
|
||||
company,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: {
|
||||
company: Company;
|
||||
onClose: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const matches = confirm === company.slug;
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const res = await companyRepository.remove(company.id, confirm);
|
||||
setBusy(false);
|
||||
|
||||
if (!res.ok) {
|
||||
// `still_active` and `storage_error` both name a condition the operator
|
||||
// can act on — suspend it first, or retry once storage is healthy.
|
||||
setError(res.message ?? 'Could not delete that company.');
|
||||
return;
|
||||
}
|
||||
|
||||
onDeleted();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="required"
|
||||
width={460}
|
||||
aria-label="Delete company"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title="Delete company?"
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<Banner
|
||||
status="error"
|
||||
title="This cannot be undone"
|
||||
description={`Deletes ${company.name}, its ${company.sites} ${
|
||||
company.sites === 1 ? 'shop' : 'shops'
|
||||
}, its ${company.users} ${
|
||||
company.users === 1 ? 'account' : 'accounts'
|
||||
}, and every stored face image, visit and camera. The data is biometric and there is no backup to restore from.`}
|
||||
/>
|
||||
|
||||
{company.isActive ? (
|
||||
<Banner
|
||||
status="warning"
|
||||
title="Still active"
|
||||
description="The platform will refuse this until the company is suspended. Suspend it first, then come back."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="Type the company slug to confirm"
|
||||
value={confirm}
|
||||
onChange={setConfirm}
|
||||
placeholder={company.slug}
|
||||
description={`Exactly: ${company.slug}`}
|
||||
/>
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void submit()}
|
||||
isDisabled={!matches || busy}
|
||||
isLoading={busy}
|
||||
label="Delete company"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
149
src/features/admin/components/ResetOwnerPasswordDialog.tsx
Normal file
149
src/features/admin/components/ResetOwnerPasswordDialog.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Generate a new password for a company's owner.
|
||||
*
|
||||
* ── The support case this exists for ─────────────────────────────────────
|
||||
* The owner has locked themselves out and there is nobody above them inside
|
||||
* the company to reset it. Every other account is reset by their own owner;
|
||||
* this is the one that cannot be.
|
||||
*
|
||||
* ── Why the email box starts empty and hidden ────────────────────────────
|
||||
* With exactly one owner the platform does not need to be told which — and
|
||||
* that is the common case, so asking would be a field nobody can answer
|
||||
* usefully. With several owners it answers 400 and NAMES them. That message is
|
||||
* shown verbatim and the box appears, so the operator picks from the
|
||||
* platform's own list. This console never enumerates owners itself: there is
|
||||
* no endpoint for it, and inventing one would mean guessing.
|
||||
*
|
||||
* ── The password ─────────────────────────────────────────────────────────
|
||||
* Generated, never chosen. Shown once — it is bcrypt-hashed upstream and
|
||||
* cannot be fetched again. It lives in this component's state and nowhere
|
||||
* else, and closing the dialog discards it. Resetting also revokes every
|
||||
* session that owner held, so they are signed out wherever they were.
|
||||
*/
|
||||
export function ResetOwnerPasswordDialog({
|
||||
company,
|
||||
onClose,
|
||||
}: {
|
||||
company: Company;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [needsEmail, setNeedsEmail] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{
|
||||
email: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const res = await companyRepository.resetOwnerPassword(
|
||||
company.id,
|
||||
email.trim() || undefined,
|
||||
);
|
||||
setBusy(false);
|
||||
|
||||
if (!res.ok || !res.data) {
|
||||
// A 400 here is the platform saying "which owner?" and listing them.
|
||||
// Surface it word for word and reveal the box — a generic failure would
|
||||
// hide the one piece of information needed to succeed on the retry.
|
||||
if (res.status === 400) setNeedsEmail(true);
|
||||
setError(res.message ?? 'Could not reset that password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setResult({email: res.data.email, password: res.data.password});
|
||||
}
|
||||
|
||||
function close() {
|
||||
setResult(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
purpose="info"
|
||||
width={460}
|
||||
aria-label="Reset owner password"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={result ? 'Password reset' : 'Reset owner password?'}
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
/>
|
||||
|
||||
{result ? (
|
||||
<VStack gap={4} width="100%">
|
||||
<SecretOnce
|
||||
value={result.password}
|
||||
label={`New password for ${result.email}`}
|
||||
note="Send it to them now. It cannot be shown again, and closing this dialog discards it."
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
Every session that owner held has been revoked. They are signed
|
||||
out everywhere and will need this password to get back in.
|
||||
</Text>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button onClick={close} label="Done" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack gap={4} width="100%">
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
<Text size="sm" weight="medium">
|
||||
{company.name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
A new password is generated for the owner and shown once. Their
|
||||
existing sessions are revoked, so they are signed out
|
||||
immediately.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{needsEmail ? (
|
||||
<TextInput
|
||||
label="Owner email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="owner@company.com"
|
||||
description="This company has more than one owner — name the one to reset."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={close} label="Cancel" />
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void submit()}
|
||||
isLoading={busy}
|
||||
isDisabled={busy || (needsEmail && email.trim() === '')}
|
||||
label="Reset password"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
112
src/features/admin/components/SuspendCompanyDialog.tsx
Normal file
112
src/features/admin/components/SuspendCompanyDialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Suspend or reinstate a company.
|
||||
*
|
||||
* ── Why suspension is confirmed and reinstatement is too ─────────────────
|
||||
* Suspension is not a flag that takes effect at next sign-in: the platform
|
||||
* revokes every session the company holds in the same transaction, so a
|
||||
* manager halfway through a shift on a shop PC is signed out mid-task, and
|
||||
* visits from its shop PCs are dropped at ingest from that moment. That is
|
||||
* worth a sentence and a second click.
|
||||
*
|
||||
* Reinstatement is confirmed as well, for a quieter reason: it does NOT restore
|
||||
* the sessions it ended. Everybody signs in again. An operator who expects
|
||||
* "undo" should be told that before they click, not after the support call.
|
||||
*
|
||||
* ── The count is reported, not swallowed ─────────────────────────────────
|
||||
* "Suspended" on its own leaves the operator wondering whether somebody is
|
||||
* still signed in somewhere. `sessions_revoked` answers it, and it comes from
|
||||
* the platform's own transaction — this console does not count anything itself
|
||||
* and must not: a local tally would be a guess dressed as a fact.
|
||||
*/
|
||||
export function SuspendCompanyDialog({
|
||||
company,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
company: Company;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const suspending = company.isActive;
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const res = await companyRepository.setActive(company.id, !company.isActive);
|
||||
setBusy(false);
|
||||
|
||||
if (!res.ok || !res.data) {
|
||||
setError(
|
||||
res.message ??
|
||||
(suspending
|
||||
? 'Could not suspend that company.'
|
||||
: 'Could not reinstate that company.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onDone();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose={suspending ? 'required' : 'info'}
|
||||
width={440}
|
||||
aria-label={suspending ? 'Suspend company' : 'Reinstate company'}
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={suspending ? 'Suspend company?' : 'Reinstate company?'}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
<Text size="sm" weight="medium">
|
||||
{company.name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{suspending
|
||||
? `Nobody at ${company.name} will be able to sign in. Every session they hold is ended immediately — including anyone signed in on a shop PC right now — and visits from their shops stop being recorded.`
|
||||
: `${company.name} will be able to sign in again. Sessions ended by the suspension are not restored, so everyone signs in fresh.`}
|
||||
</Text>
|
||||
{suspending && company.users > 0 ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
{company.users} {company.users === 1 ? 'person' : 'people'} and{' '}
|
||||
{company.sites} {company.sites === 1 ? 'shop' : 'shops'} are
|
||||
affected.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
variant={suspending ? 'destructive' : 'primary'}
|
||||
onClick={() => void submit()}
|
||||
isLoading={busy}
|
||||
label={suspending ? 'Suspend company' : 'Reinstate company'}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user