diff --git a/ui/src/pages/tenant/OidcConfigPage.tsx b/ui/src/pages/tenant/OidcConfigPage.tsx index d44fde3..34840f4 100644 --- a/ui/src/pages/tenant/OidcConfigPage.tsx +++ b/ui/src/pages/tenant/OidcConfigPage.tsx @@ -1 +1,187 @@ -export function OidcConfigPage() { return
OidcConfigPage (TODO)
; } +import { useState, useEffect } from 'react'; +import { + Alert, + Badge, + Button, + Card, + FormField, + Input, + Spinner, + useToast, +} from '@cameleer/design-system'; +import { useTenantOidc, useUpdateOidc } from '../../api/tenant-hooks'; +import styles from '../../styles/platform.module.css'; + +interface OidcFormState { + issuerUri: string; + clientId: string; + clientSecret: string; + audience: string; + rolesClaim: string; +} + +const EMPTY_FORM: OidcFormState = { + issuerUri: '', + clientId: '', + clientSecret: '', + audience: '', + rolesClaim: '', +}; + +function isExternalOidc(config: Record): boolean { + return typeof config['issuerUri'] === 'string' && config['issuerUri'] !== ''; +} + +export function OidcConfigPage() { + const { data: oidcConfig, isLoading, isError } = useTenantOidc(); + const updateOidc = useUpdateOidc(); + const { toast } = useToast(); + + const [form, setForm] = useState(EMPTY_FORM); + + // Pre-fill form when config loads + useEffect(() => { + if (!oidcConfig) return; + setForm({ + issuerUri: String(oidcConfig['issuerUri'] ?? ''), + clientId: String(oidcConfig['clientId'] ?? ''), + clientSecret: String(oidcConfig['clientSecret'] ?? ''), + audience: String(oidcConfig['audience'] ?? ''), + rolesClaim: String(oidcConfig['rolesClaim'] ?? ''), + }); + }, [oidcConfig]); + + function handleChange(field: keyof OidcFormState, value: string) { + setForm((prev) => ({ ...prev, [field]: value })); + } + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + try { + await updateOidc.mutateAsync({ + issuerUri: form.issuerUri, + clientId: form.clientId, + clientSecret: form.clientSecret, + audience: form.audience, + rolesClaim: form.rolesClaim, + }); + toast({ title: 'OIDC configuration saved', variant: 'success' }); + } catch (err) { + toast({ title: 'Save failed', description: String(err), variant: 'error' }); + } + } + + async function handleReset() { + try { + await updateOidc.mutateAsync({}); + setForm(EMPTY_FORM); + toast({ title: 'Reset to Logto (default)', variant: 'success' }); + } catch (err) { + toast({ title: 'Reset failed', description: String(err), variant: 'error' }); + } + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+ + Could not fetch OIDC config. Please refresh. + +
+ ); + } + + const hasExternalOidc = oidcConfig ? isExternalOidc(oidcConfig) : false; + + return ( +
+
+

OIDC Configuration

+ +
+ + +

+ Configure an external OIDC provider for your team to sign in. Leave blank to use the + default Logto-based authentication. +

+ +
+ + handleChange('issuerUri', e.target.value)} + /> + + + + handleChange('clientId', e.target.value)} + /> + + + + handleChange('clientSecret', e.target.value)} + /> + + + + handleChange('audience', e.target.value)} + /> + + + + handleChange('rolesClaim', e.target.value)} + /> + + +
+ + {hasExternalOidc && ( + + )} +
+
+
+
+ ); +} diff --git a/ui/src/pages/tenant/SettingsPage.tsx b/ui/src/pages/tenant/SettingsPage.tsx index af787a9..d9fc9f7 100644 --- a/ui/src/pages/tenant/SettingsPage.tsx +++ b/ui/src/pages/tenant/SettingsPage.tsx @@ -1 +1,80 @@ -export function SettingsPage() { return
SettingsPage (TODO)
; } +import { + Alert, + Badge, + Card, + Spinner, +} from '@cameleer/design-system'; +import { useTenantSettings } from '../../api/tenant-hooks'; +import { tierColor } from '../../utils/tier'; +import styles from '../../styles/platform.module.css'; + +function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' { + switch (status?.toUpperCase()) { + case 'ACTIVE': return 'success'; + case 'SUSPENDED': return 'warning'; + case 'PROVISIONING': return 'auto'; + case 'ERROR': return 'error'; + default: return 'auto'; + } +} + +export function SettingsPage() { + const { data, isLoading, isError } = useTenantSettings(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return ( +
+ + Could not fetch settings. Please refresh. + +
+ ); + } + + return ( +
+

Settings

+ + +
+
+ Name + {data.name} +
+
+ Slug + {data.slug} +
+
+ Tier + +
+
+ Status + +
+
+ Server Endpoint + {data.serverEndpoint ?? '—'} +
+
+ Created + {new Date(data.createdAt).toLocaleDateString()} +
+
+ +

+ To change your tier or other billing-related settings, please contact support. +

+
+
+ ); +} diff --git a/ui/src/pages/tenant/TeamPage.tsx b/ui/src/pages/tenant/TeamPage.tsx index 24abc70..d5b218f 100644 --- a/ui/src/pages/tenant/TeamPage.tsx +++ b/ui/src/pages/tenant/TeamPage.tsx @@ -1 +1,236 @@ -export function TeamPage() { return
TeamPage (TODO)
; } +import { useState } from 'react'; +import { + Alert, + AlertDialog, + Badge, + Button, + Card, + DataTable, + EmptyState, + FormField, + Input, + Spinner, + useToast, +} from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { Plus, Users } from 'lucide-react'; +import { + useTenantTeam, + useInviteTeamMember, + useRemoveTeamMember, +} from '../../api/tenant-hooks'; +import styles from '../../styles/platform.module.css'; + +// DataTable requires T extends { id: string } +interface TeamMember { + id: string; + name: string; + email: string; + role: string; +} + +function toMember(raw: Record): TeamMember { + return { + id: String(raw['id'] ?? raw['userId'] ?? ''), + name: String(raw['name'] ?? raw['username'] ?? '—'), + email: String(raw['email'] ?? '—'), + role: String(raw['role'] ?? raw['orgRole'] ?? '—'), + }; +} + +const ROLES = [ + { id: 'owner', label: 'Owner' }, + { id: 'operator', label: 'Operator' }, + { id: 'viewer', label: 'Viewer' }, +]; + +function roleColor(role: string): 'primary' | 'success' | 'warning' | 'auto' { + switch (role?.toLowerCase()) { + case 'owner': return 'primary'; + case 'operator': return 'success'; + case 'viewer': return 'warning'; + default: return 'auto'; + } +} + +export function TeamPage() { + const { data: rawTeam, isLoading, isError } = useTenantTeam(); + const inviteMember = useInviteTeamMember(); + const removeMember = useRemoveTeamMember(); + const { toast } = useToast(); + + const [showInvite, setShowInvite] = useState(false); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteRole, setInviteRole] = useState('viewer'); + + const [removeTarget, setRemoveTarget] = useState(null); + + const team: TeamMember[] = (rawTeam ?? []).map(toMember).filter((m) => m.id !== ''); + + const columns: Column[] = [ + { + key: 'name', + header: 'Name', + render: (_v, row) => row.name, + }, + { + key: 'email', + header: 'Email', + render: (_v, row) => ( + {row.email} + ), + }, + { + key: 'role', + header: 'Role', + render: (_v, row) => , + }, + { + key: 'id', + header: 'Actions', + render: (_v, row) => ( + + ), + }, + ]; + + async function handleInvite(e: React.FormEvent) { + e.preventDefault(); + if (!inviteEmail.trim()) return; + try { + await inviteMember.mutateAsync({ email: inviteEmail.trim(), roleId: inviteRole }); + toast({ title: `Invited ${inviteEmail}`, variant: 'success' }); + setInviteEmail(''); + setInviteRole('viewer'); + setShowInvite(false); + } catch (err) { + toast({ title: 'Invite failed', description: String(err), variant: 'error' }); + } + } + + async function handleRemove() { + if (!removeTarget) return; + try { + await removeMember.mutateAsync(removeTarget.id); + toast({ title: `Removed ${removeTarget.name}`, variant: 'success' }); + setRemoveTarget(null); + } catch (err) { + toast({ title: 'Remove failed', description: String(err), variant: 'error' }); + setRemoveTarget(null); + } + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+ + Could not fetch team members. Please refresh. + +
+ ); + } + + return ( +
+ {/* Header */} +
+

Team

+ +
+ + {/* Inline invite form */} + {showInvite && ( + +
+ + setInviteEmail(e.target.value)} + required + /> + + + + + + +
+ + +
+
+
+ )} + + {/* Team table */} + {team.length === 0 ? ( + } + title="No team members yet" + description="Invite colleagues to collaborate on this tenant." + action={ + + } + /> + ) : ( + + )} + + {/* Remove confirmation dialog */} + setRemoveTarget(null)} + onConfirm={handleRemove} + title="Remove Team Member" + description={`Are you sure you want to remove "${removeTarget?.name ?? ''}" from the team? They will lose access immediately.`} + confirmLabel="Remove" + cancelLabel="Cancel" + variant="danger" + loading={removeMember.isPending} + /> +
+ ); +} diff --git a/ui/src/pages/tenant/TenantDashboardPage.tsx b/ui/src/pages/tenant/TenantDashboardPage.tsx index 07a28ae..fde710a 100644 --- a/ui/src/pages/tenant/TenantDashboardPage.tsx +++ b/ui/src/pages/tenant/TenantDashboardPage.tsx @@ -1 +1,135 @@ -export function TenantDashboardPage() { return
TenantDashboardPage (TODO)
; } +import { useNavigate } from 'react-router'; +import { + Alert, + Badge, + Button, + Card, + KpiStrip, + Spinner, +} from '@cameleer/design-system'; +import { ExternalLink, Key, Settings } from 'lucide-react'; +import { useTenantDashboard } from '../../api/tenant-hooks'; +import { ServerStatusBadge } from '../../components/ServerStatusBadge'; +import { UsageIndicator } from '../../components/UsageIndicator'; +import { tierColor } from '../../utils/tier'; +import styles from '../../styles/platform.module.css'; + +function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' { + switch (status?.toUpperCase()) { + case 'ACTIVE': return 'success'; + case 'SUSPENDED': return 'warning'; + case 'PROVISIONING': return 'auto'; + case 'ERROR': return 'error'; + default: return 'auto'; + } +} + +export function TenantDashboardPage() { + const navigate = useNavigate(); + const { data, isLoading, isError } = useTenantDashboard(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return ( +
+ + Could not fetch dashboard data. Please refresh. + +
+ ); + } + + const serverDown = !data.serverHealthy; + const daysRemaining = data.licenseDaysRemaining ?? 0; + + const agentLimit = data.limits?.['agents'] ?? -1; + const envLimit = data.limits?.['environments'] ?? -1; + // Dashboard doesn't expose usage counts directly — show limit info from license + const agentUsed = 0; + const envUsed = 0; + + return ( +
+ {/* Header */} +
+

{data.name}

+ + +
+ + {/* Server-down alert */} + {serverDown && ( + + Your Cameleer server is currently unreachable. Agent data collection is paused. + + )} + + {/* KPI strip */} + 0 ? `${daysRemaining}d remaining` : 'Expired' }, + ]} + /> + + {/* Cards */} +
+ {/* Server card */} + +
+
+ State + +
+ {data.serverEndpoint && ( +
+ Endpoint + {data.serverEndpoint} +
+ )} +
+
+ + {/* Usage card */} + +
+ + +
+
+ + {/* Quick links card */} + +
+ {data.serverEndpoint && ( + + )} + + +
+
+
+
+ ); +} diff --git a/ui/src/pages/tenant/TenantLicensePage.tsx b/ui/src/pages/tenant/TenantLicensePage.tsx index 3aa9236..66a0dd8 100644 --- a/ui/src/pages/tenant/TenantLicensePage.tsx +++ b/ui/src/pages/tenant/TenantLicensePage.tsx @@ -1 +1,148 @@ -export function TenantLicensePage() { return
TenantLicensePage (TODO)
; } +import { useState } from 'react'; +import { + Alert, + Badge, + Button, + Card, + Spinner, + useToast, +} from '@cameleer/design-system'; +import { Copy, Eye, EyeOff } from 'lucide-react'; +import { useTenantLicense } from '../../api/tenant-hooks'; +import { UsageIndicator } from '../../components/UsageIndicator'; +import { tierColor } from '../../utils/tier'; +import styles from '../../styles/platform.module.css'; + +function daysColor(days: number): 'success' | 'warning' | 'error' | 'auto' { + if (days <= 0) return 'error'; + if (days <= 30) return 'warning'; + return 'success'; +} + +export function TenantLicensePage() { + const { data, isLoading, isError } = useTenantLicense(); + const { toast } = useToast(); + const [showToken, setShowToken] = useState(false); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return ( +
+ + Could not fetch license data. Please refresh. + +
+ ); + } + + async function handleCopy() { + try { + await navigator.clipboard.writeText(data!.token); + toast({ title: 'Token copied to clipboard', variant: 'success' }); + } catch { + toast({ title: 'Copy failed', variant: 'error' }); + } + } + + const agentLimit = data.limits?.['agents'] ?? -1; + const envLimit = data.limits?.['environments'] ?? -1; + const retentionDays = data.limits?.['retentionDays'] ?? -1; + + return ( +
+ {/* Header */} +
+

License

+ +
+ +
+ {/* Validity card */} + +
+
+ Issued + {new Date(data.issuedAt).toLocaleDateString()} +
+
+ Expires + {new Date(data.expiresAt).toLocaleDateString()} +
+
+ Days remaining + +
+
+
+ + {/* Features card */} + +
+ {Object.entries(data.features).map(([key, enabled]) => ( +
+ {key} + +
+ ))} + {Object.keys(data.features).length === 0 && ( +

No feature flags configured.

+ )} +
+
+ + {/* Limits & Usage card */} + +
+ + + {retentionDays >= 0 && ( +
+ Data retention + {retentionDays}d +
+ )} +
+
+
+ + {/* License token card — full width */} + +
+

+ This token is embedded in your Cameleer server configuration. Keep it secret. +

+
+ + {showToken ? data.token : '••••••••••••••••••••••••••••••••'} + +
+
+ + +
+
+
+
+ ); +}