The hand-rolled OIDC flow (manual PKCE, token exchange, URL construction) was fragile and accumulated multiple bugs. Replaced with the official @logto/react SDK which handles PKCE, token exchange, storage, and refresh automatically. - Add @logto/react SDK dependency - Add LogtoProvider with runtime config in main.tsx - Add TokenSync component bridging SDK tokens to API client - Add useAuth hook replacing Zustand auth store - Simplify LoginPage to signIn(), CallbackPage to useHandleSignInCallback() - Delete pkce.ts and auth-store.ts (replaced by SDK) - Fix react-router-dom → react-router imports in page files - All 17 React Query hooks unchanged (token provider pattern) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
194 lines
5.2 KiB
TypeScript
194 lines
5.2 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
DataTable,
|
|
EmptyState,
|
|
FormField,
|
|
Input,
|
|
Modal,
|
|
Spinner,
|
|
useToast,
|
|
} from '@cameleer/design-system';
|
|
import type { Column } from '@cameleer/design-system';
|
|
import { useAuth } from '../auth/useAuth';
|
|
import { useEnvironments, useCreateEnvironment } from '../api/hooks';
|
|
import { RequirePermission } from '../components/RequirePermission';
|
|
import type { EnvironmentResponse } from '../types/api';
|
|
|
|
interface TableRow {
|
|
id: string;
|
|
displayName: string;
|
|
slug: string;
|
|
status: string;
|
|
createdAt: string;
|
|
_raw: EnvironmentResponse;
|
|
}
|
|
|
|
const columns: Column<TableRow>[] = [
|
|
{
|
|
key: 'displayName',
|
|
header: 'Name',
|
|
render: (_val, row) => (
|
|
<span className="font-medium text-white">{row.displayName}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'slug',
|
|
header: 'Slug',
|
|
render: (_val, row) => (
|
|
<Badge label={row.slug} color="primary" variant="outlined" />
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Status',
|
|
render: (_val, row) => (
|
|
<Badge
|
|
label={row.status}
|
|
color={row.status === 'ACTIVE' ? 'success' : 'warning'}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'createdAt',
|
|
header: 'Created',
|
|
render: (_val, row) =>
|
|
new Date(row.createdAt).toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
}),
|
|
},
|
|
];
|
|
|
|
export function EnvironmentsPage() {
|
|
const navigate = useNavigate();
|
|
const { toast } = useToast();
|
|
const { tenantId } = useAuth();
|
|
|
|
const { data: environments, isLoading } = useEnvironments(tenantId ?? '');
|
|
const createMutation = useCreateEnvironment(tenantId ?? '');
|
|
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [slug, setSlug] = useState('');
|
|
const [displayName, setDisplayName] = useState('');
|
|
|
|
const tableData: TableRow[] = (environments ?? []).map((env) => ({
|
|
id: env.id,
|
|
displayName: env.displayName,
|
|
slug: env.slug,
|
|
status: env.status,
|
|
createdAt: env.createdAt,
|
|
_raw: env,
|
|
}));
|
|
|
|
function openModal() {
|
|
setSlug('');
|
|
setDisplayName('');
|
|
setModalOpen(true);
|
|
}
|
|
|
|
function closeModal() {
|
|
setModalOpen(false);
|
|
}
|
|
|
|
async function handleCreate(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!slug.trim() || !displayName.trim()) return;
|
|
try {
|
|
await createMutation.mutateAsync({ slug: slug.trim(), displayName: displayName.trim() });
|
|
toast({ title: 'Environment created', variant: 'success' });
|
|
closeModal();
|
|
} catch {
|
|
toast({ title: 'Failed to create environment', variant: 'error' });
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<Spinner />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 p-6">
|
|
{/* Page header */}
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-semibold text-white">Environments</h1>
|
|
<RequirePermission permission="apps:manage">
|
|
<Button variant="primary" size="sm" onClick={openModal}>
|
|
Create Environment
|
|
</Button>
|
|
</RequirePermission>
|
|
</div>
|
|
|
|
{/* Table / empty state */}
|
|
{tableData.length === 0 ? (
|
|
<EmptyState
|
|
title="No environments yet"
|
|
description="Create your first environment to start deploying Camel applications."
|
|
action={
|
|
<RequirePermission permission="apps:manage">
|
|
<Button variant="primary" onClick={openModal}>
|
|
Create Environment
|
|
</Button>
|
|
</RequirePermission>
|
|
}
|
|
/>
|
|
) : (
|
|
<Card>
|
|
<DataTable<TableRow>
|
|
columns={columns}
|
|
data={tableData}
|
|
onRowClick={(row) => navigate(`/environments/${row.id}`)}
|
|
flush
|
|
/>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Create Environment Modal */}
|
|
<Modal open={modalOpen} onClose={closeModal} title="Create Environment" size="sm">
|
|
<form onSubmit={handleCreate} className="space-y-4">
|
|
<FormField label="Slug" htmlFor="env-slug" required>
|
|
<Input
|
|
id="env-slug"
|
|
value={slug}
|
|
onChange={(e) => setSlug(e.target.value)}
|
|
placeholder="e.g. production"
|
|
required
|
|
/>
|
|
</FormField>
|
|
<FormField label="Display Name" htmlFor="env-display-name" required>
|
|
<Input
|
|
id="env-display-name"
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
placeholder="e.g. Production"
|
|
required
|
|
/>
|
|
</FormField>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button type="button" variant="secondary" size="sm" onClick={closeModal}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
size="sm"
|
|
loading={createMutation.isPending}
|
|
disabled={!slug.trim() || !displayName.trim()}
|
|
>
|
|
Create
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|