Token rotation used to be immediate cutover — push gap from when ops rotates to when the customer's .env is updated and portal restarted. Now the old token keeps working for 24h after rotation, so customer ops has a full workday to swap it in without dropping a single push tick. Backend - Customer entity gains PreviousTokenHash + PreviousTokenExpiresAt (both nullable). Non-unique index on PreviousTokenHash so the OR-lookup in FindByTokenAsync stays cheap. - CustomerService.RotateTokenAsync(id, graceWindow=null, ct): copies the existing TokenHash into PreviousTokenHash with PreviousTokenExpiresAt = now + graceWindow (default 24h, lifted to CustomerService.DefaultTokenGracePeriod), then issues a new current token. Second rotation overwrites the previous slot — at most one previous token is ever honoured. - CustomerService.FindByTokenAsync matches either current OR (previous AND PreviousTokenExpiresAt > now). IsActive=false still rejects both. - DTO exposes PreviousTokenExpiresAt so the UI can render the grace window status. - New EF migration AddPreviousTokenGraceWindow on AdminDbContext. Frontend - Customers table "Token" column shows an "Old token valid until …" orange tag with a tooltip whenever the grace window is active, plus the issue/rotation date as before. - TokenShownOnceModal mentions the 24h grace window so ops knows they have time to update .env without urgency. - Rotate-token popconfirm copy updated to reflect the new behavior. Tests (+5, 61/61 passing) - CustomerTokenGraceTests covers: create doesn't set previous; rotate moves current into previous slot with future expiry; zero grace window rejects original immediately; second rotation overwrites previous (original dies, first-rotation becomes the new previous); inactive customer rejects both current AND previous. Verified end-to-end on the dev host - Migration applied cleanly on the existing admin_fleet DB (existing DEV0001 customer got NULL previous columns, no data loss). - Created GRACE01 → got token1. - Rotated → got token2. PreviousTokenExpiresAt = +24h. Both token1 and token2 push successfully (200). - Rotated again → got token3. token1 push now returns 401 (gone). token2 push still 200 (now the previous). token3 push 200 (current). Docs - FLEET-DESIGN.md §6 rewritten — no longer "immediate cutover". - §11 "open seams" row for this feature marked as shipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
208 lines
7.4 KiB
TypeScript
208 lines
7.4 KiB
TypeScript
import { useState } from 'react';
|
|
import { Card, Table, Button, Space, Tag, Popconfirm, Tooltip, Typography, message } from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
listCustomers, createCustomer, updateCustomer, rotateCustomerToken, deleteCustomer,
|
|
type CustomerListItem, type CreateCustomerPayload, type UpdateCustomerPayload,
|
|
} from '../api/customers';
|
|
import { CustomerFormModal } from '../components/customers/CustomerFormModal';
|
|
import { TokenShownOnceModal } from '../components/customers/TokenShownOnceModal';
|
|
|
|
const { Text } = Typography;
|
|
|
|
type FormMode = { kind: 'create' } | { kind: 'edit'; customer: CustomerListItem };
|
|
|
|
export function AdminCustomersPage() {
|
|
const qc = useQueryClient();
|
|
const navigate = useNavigate();
|
|
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [tokenModal, setTokenModal] = useState<{ open: boolean; code: string | null; token: string | null }>({
|
|
open: false, code: null, token: null,
|
|
});
|
|
|
|
const { data: customers = [], isLoading } = useQuery({
|
|
queryKey: ['admin', 'customers'],
|
|
queryFn: listCustomers,
|
|
});
|
|
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['admin', 'customers'] });
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: (p: CreateCustomerPayload) => createCustomer(p),
|
|
onSuccess: (result) => {
|
|
setFormMode(null);
|
|
setFormError(null);
|
|
setTokenModal({ open: true, code: result.customer.code, token: result.token });
|
|
invalidate();
|
|
},
|
|
onError: (err: unknown) => setFormError(extractError(err)),
|
|
});
|
|
|
|
const updateMut = useMutation({
|
|
mutationFn: ({ id, payload }: { id: string; payload: UpdateCustomerPayload }) => updateCustomer(id, payload),
|
|
onSuccess: () => {
|
|
message.success('Customer updated');
|
|
setFormMode(null);
|
|
setFormError(null);
|
|
invalidate();
|
|
},
|
|
onError: (err: unknown) => setFormError(extractError(err)),
|
|
});
|
|
|
|
const rotateMut = useMutation({
|
|
mutationFn: (id: string) => rotateCustomerToken(id),
|
|
onSuccess: (result) => {
|
|
setTokenModal({ open: true, code: result.customer.code, token: result.token });
|
|
invalidate();
|
|
},
|
|
onError: (err: unknown) => message.error(extractError(err)),
|
|
});
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: (id: string) => deleteCustomer(id),
|
|
onSuccess: () => { message.success('Customer deleted'); invalidate(); },
|
|
onError: (err: unknown) => message.error(extractError(err)),
|
|
});
|
|
|
|
const handleSubmit = (payload: CreateCustomerPayload | UpdateCustomerPayload) => {
|
|
setFormError(null);
|
|
if (formMode?.kind === 'edit') {
|
|
updateMut.mutate({ id: formMode.customer.id, payload: payload as UpdateCustomerPayload });
|
|
} else {
|
|
createMut.mutate(payload as CreateCustomerPayload);
|
|
}
|
|
};
|
|
|
|
const columns: ColumnsType<CustomerListItem> = [
|
|
{ title: 'Code', dataIndex: 'code', key: 'code', render: (v) => <Text strong>{v}</Text> },
|
|
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
|
{
|
|
title: 'Status',
|
|
dataIndex: 'isActive',
|
|
key: 'isActive',
|
|
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>),
|
|
},
|
|
{
|
|
title: 'Last push',
|
|
dataIndex: 'lastSeenAt',
|
|
key: 'lastSeenAt',
|
|
render: (v: string | null) =>
|
|
v ? new Date(v).toLocaleString() : <Text type="secondary">Never</Text>,
|
|
},
|
|
{
|
|
title: 'Token',
|
|
key: 'token',
|
|
render: (_, c) => {
|
|
const ts = c.tokenRotatedAt ?? c.tokenIssuedAt;
|
|
const issued = <span>{new Date(ts).toLocaleDateString()}</span>;
|
|
if (!c.previousTokenExpiresAt) return issued;
|
|
const exp = new Date(c.previousTokenExpiresAt);
|
|
const stillValid = exp > new Date();
|
|
if (!stillValid) return issued;
|
|
return (
|
|
<Space size={4} direction="vertical">
|
|
{issued}
|
|
<Tooltip title={`Old token still accepted by ingest until ${exp.toLocaleString()}. Customer ops should update their .env before then.`}>
|
|
<Tag color="orange" style={{ fontSize: 11 }}>
|
|
Old token valid until {exp.toLocaleTimeString()}
|
|
</Tag>
|
|
</Tooltip>
|
|
</Space>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: 'Actions',
|
|
key: 'actions',
|
|
render: (_, c) => (
|
|
<Space>
|
|
<Button size="small" icon={<EditOutlined />} onClick={() => { setFormError(null); setFormMode({ kind: 'edit', customer: c }); }}>
|
|
Edit
|
|
</Button>
|
|
<Tooltip title="Generate a new token. The old token keeps working for 24h so customer ops can update their .env without dropping pushes.">
|
|
<Popconfirm
|
|
title={`Rotate token for ${c.code}?`}
|
|
description="The old token stays valid for 24h. Update the customer's .env with the new token before that grace window expires."
|
|
okText="Rotate"
|
|
okButtonProps={{ danger: true }}
|
|
onConfirm={() => rotateMut.mutate(c.id)}
|
|
>
|
|
<Button size="small" icon={<ReloadOutlined />} loading={rotateMut.isPending && rotateMut.variables === c.id}>
|
|
Rotate token
|
|
</Button>
|
|
</Popconfirm>
|
|
</Tooltip>
|
|
<Popconfirm
|
|
title={`Delete ${c.code}?`}
|
|
description="All this customer's mirrored data (sites, devices, measurements, events) is removed."
|
|
okText="Delete"
|
|
okButtonProps={{ danger: true }}
|
|
onConfirm={() => deleteMut.mutate(c.id)}
|
|
>
|
|
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Card
|
|
title="Customers"
|
|
extra={
|
|
<Button
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={() => { setFormError(null); setFormMode({ kind: 'create' }); }}
|
|
>
|
|
Register customer
|
|
</Button>
|
|
}
|
|
>
|
|
<Table<CustomerListItem>
|
|
rowKey="id"
|
|
columns={columns}
|
|
dataSource={customers}
|
|
loading={isLoading}
|
|
pagination={{ pageSize: 25 }}
|
|
onRow={(record) => ({
|
|
onClick: (e) => {
|
|
const t = e.target as HTMLElement;
|
|
if (t.closest('button, .ant-popover')) return;
|
|
navigate(`/admin/customers/${record.id}`);
|
|
},
|
|
style: { cursor: 'pointer' },
|
|
})}
|
|
/>
|
|
|
|
<CustomerFormModal
|
|
open={formMode !== null}
|
|
mode={formMode}
|
|
submitting={createMut.isPending || updateMut.isPending}
|
|
error={formError}
|
|
onClose={() => { setFormMode(null); setFormError(null); }}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
|
|
<TokenShownOnceModal
|
|
open={tokenModal.open}
|
|
customerCode={tokenModal.code}
|
|
token={tokenModal.token}
|
|
onClose={() => setTokenModal({ open: false, code: null, token: null })}
|
|
/>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function extractError(err: unknown): string {
|
|
if (typeof err === 'object' && err !== null && 'response' in err) {
|
|
const data = (err as { response?: { data?: { error?: string } } }).response?.data;
|
|
if (data?.error) return data.error;
|
|
}
|
|
return 'Request failed.';
|
|
}
|