feat: tenant portal — all 5 pages (dashboard, license, OIDC, team, settings)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,187 @@
|
|||||||
export function OidcConfigPage() { return <div>OidcConfigPage (TODO)</div>; }
|
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<string, unknown>): 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<OidcFormState>(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 (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Alert variant="error" title="Failed to load OIDC configuration">
|
||||||
|
Could not fetch OIDC config. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExternalOidc = oidcConfig ? isExternalOidc(oidcConfig) : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>OIDC Configuration</h1>
|
||||||
|
<Badge
|
||||||
|
label={hasExternalOidc ? 'External OIDC configured' : 'Using Logto (default)'}
|
||||||
|
color={hasExternalOidc ? 'primary' : 'auto'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card title="External OIDC Provider">
|
||||||
|
<p className={styles.description} style={{ marginBottom: 16 }}>
|
||||||
|
Configure an external OIDC provider for your team to sign in. Leave blank to use the
|
||||||
|
default Logto-based authentication.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<FormField label="Issuer URI" htmlFor="oidc-issuer">
|
||||||
|
<Input
|
||||||
|
id="oidc-issuer"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://your-idp.example.com/oidc"
|
||||||
|
value={form.issuerUri}
|
||||||
|
onChange={(e) => handleChange('issuerUri', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Client ID" htmlFor="oidc-client-id">
|
||||||
|
<Input
|
||||||
|
id="oidc-client-id"
|
||||||
|
placeholder="your-client-id"
|
||||||
|
value={form.clientId}
|
||||||
|
onChange={(e) => handleChange('clientId', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Client Secret" htmlFor="oidc-client-secret">
|
||||||
|
<Input
|
||||||
|
id="oidc-client-secret"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={form.clientSecret}
|
||||||
|
onChange={(e) => handleChange('clientSecret', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Audience" htmlFor="oidc-audience">
|
||||||
|
<Input
|
||||||
|
id="oidc-audience"
|
||||||
|
placeholder="https://api.your-service.example.com"
|
||||||
|
value={form.audience}
|
||||||
|
onChange={(e) => handleChange('audience', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Roles Claim" htmlFor="oidc-roles-claim">
|
||||||
|
<Input
|
||||||
|
id="oidc-roles-claim"
|
||||||
|
placeholder="roles"
|
||||||
|
value={form.rolesClaim}
|
||||||
|
onChange={(e) => handleChange('rolesClaim', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button type="submit" variant="primary" loading={updateOidc.isPending}>
|
||||||
|
Save Configuration
|
||||||
|
</Button>
|
||||||
|
{hasExternalOidc && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleReset}
|
||||||
|
loading={updateOidc.isPending}
|
||||||
|
>
|
||||||
|
Reset to Logto
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,80 @@
|
|||||||
export function SettingsPage() { return <div>SettingsPage (TODO)</div>; }
|
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 (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Alert variant="error" title="Failed to load settings">
|
||||||
|
Could not fetch settings. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Settings</h1>
|
||||||
|
|
||||||
|
<Card title="Tenant Details">
|
||||||
|
<div className={styles.dividerList}>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Name</span>
|
||||||
|
<span className={styles.kvValue}>{data.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Slug</span>
|
||||||
|
<span className={styles.kvValueMono}>{data.slug}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Tier</span>
|
||||||
|
<Badge label={data.tier} color={tierColor(data.tier)} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Status</span>
|
||||||
|
<Badge label={data.status} color={statusColor(data.status)} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Server Endpoint</span>
|
||||||
|
<span className={styles.kvValueMono}>{data.serverEndpoint ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Created</span>
|
||||||
|
<span className={styles.kvValue}>{new Date(data.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={styles.description} style={{ marginTop: 16 }}>
|
||||||
|
To change your tier or other billing-related settings, please contact support.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,236 @@
|
|||||||
export function TeamPage() { return <div>TeamPage (TODO)</div>; }
|
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<string, unknown>): 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<TeamMember | null>(null);
|
||||||
|
|
||||||
|
const team: TeamMember[] = (rawTeam ?? []).map(toMember).filter((m) => m.id !== '');
|
||||||
|
|
||||||
|
const columns: Column<TeamMember>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
render: (_v, row) => row.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
header: 'Email',
|
||||||
|
render: (_v, row) => (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>{row.email}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'role',
|
||||||
|
header: 'Role',
|
||||||
|
render: (_v, row) => <Badge label={row.role} color={roleColor(row.role)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'id',
|
||||||
|
header: 'Actions',
|
||||||
|
render: (_v, row) => (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setRemoveTarget(row); }}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Alert variant="error" title="Failed to load team">
|
||||||
|
Could not fetch team members. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Team</h1>
|
||||||
|
<Button variant="primary" onClick={() => setShowInvite((v) => !v)}>
|
||||||
|
<Plus size={16} style={{ marginRight: 6 }} />
|
||||||
|
Invite Member
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline invite form */}
|
||||||
|
{showInvite && (
|
||||||
|
<Card title="Invite Team Member">
|
||||||
|
<form onSubmit={handleInvite} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<FormField label="Email address" htmlFor="invite-email">
|
||||||
|
<Input
|
||||||
|
id="invite-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="colleague@example.com"
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Role" htmlFor="invite-role">
|
||||||
|
<select
|
||||||
|
id="invite-role"
|
||||||
|
value={inviteRole}
|
||||||
|
onChange={(e) => setInviteRole(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button type="submit" variant="primary" loading={inviteMember.isPending}>
|
||||||
|
Send Invite
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setShowInvite(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Team table */}
|
||||||
|
{team.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Users size={32} />}
|
||||||
|
title="No team members yet"
|
||||||
|
description="Invite colleagues to collaborate on this tenant."
|
||||||
|
action={
|
||||||
|
<Button variant="primary" onClick={() => setShowInvite(true)}>
|
||||||
|
<Plus size={16} style={{ marginRight: 6 }} />
|
||||||
|
Invite Member
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={team} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remove confirmation dialog */}
|
||||||
|
<AlertDialog
|
||||||
|
open={removeTarget !== null}
|
||||||
|
onClose={() => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,135 @@
|
|||||||
export function TenantDashboardPage() { return <div>TenantDashboardPage (TODO)</div>; }
|
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 (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Alert variant="error" title="Failed to load dashboard">
|
||||||
|
Could not fetch dashboard data. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>{data.name}</h1>
|
||||||
|
<Badge label={data.tier} color={tierColor(data.tier)} />
|
||||||
|
<Badge label={data.status} color={statusColor(data.status)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server-down alert */}
|
||||||
|
{serverDown && (
|
||||||
|
<Alert variant="error" title="Server is not responding">
|
||||||
|
Your Cameleer server is currently unreachable. Agent data collection is paused.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* KPI strip */}
|
||||||
|
<KpiStrip
|
||||||
|
items={[
|
||||||
|
{ label: 'Tier', value: data.tier },
|
||||||
|
{ label: 'Status', value: data.status },
|
||||||
|
{ label: 'Server', value: data.serverHealthy ? 'Healthy' : 'Down' },
|
||||||
|
{ label: 'License', value: daysRemaining > 0 ? `${daysRemaining}d remaining` : 'Expired' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cards */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
|
||||||
|
{/* Server card */}
|
||||||
|
<Card title="Server">
|
||||||
|
<div className={styles.dividerList}>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>State</span>
|
||||||
|
<ServerStatusBadge state={data.serverStatus} />
|
||||||
|
</div>
|
||||||
|
{data.serverEndpoint && (
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Endpoint</span>
|
||||||
|
<span className={styles.kvValueMono}>{data.serverEndpoint}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Usage card */}
|
||||||
|
<Card title="Usage">
|
||||||
|
<div className={styles.dividerList}>
|
||||||
|
<UsageIndicator used={agentUsed} limit={agentLimit} label="Agents" />
|
||||||
|
<UsageIndicator used={envUsed} limit={envLimit} label="Environments" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick links card */}
|
||||||
|
<Card title="Quick Links">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{data.serverEndpoint && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => window.open(`/t/${data.slug}/`, '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} style={{ marginRight: 6 }} />
|
||||||
|
Open Server Dashboard
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" onClick={() => navigate('../license')}>
|
||||||
|
<Key size={14} style={{ marginRight: 6 }} />
|
||||||
|
View License
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => navigate('../oidc')}>
|
||||||
|
<Settings size={14} style={{ marginRight: 6 }} />
|
||||||
|
Configure OIDC
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,148 @@
|
|||||||
export function TenantLicensePage() { return <div>TenantLicensePage (TODO)</div>; }
|
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 (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Alert variant="error" title="Failed to load license">
|
||||||
|
Could not fetch license data. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>License</h1>
|
||||||
|
<Badge label={data.tier} color={tierColor(data.tier)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
|
||||||
|
{/* Validity card */}
|
||||||
|
<Card title="Validity">
|
||||||
|
<div className={styles.dividerList}>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Issued</span>
|
||||||
|
<span className={styles.kvValue}>{new Date(data.issuedAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Expires</span>
|
||||||
|
<span className={styles.kvValue}>{new Date(data.expiresAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Days remaining</span>
|
||||||
|
<Badge
|
||||||
|
label={data.daysRemaining <= 0 ? 'Expired' : `${data.daysRemaining}d`}
|
||||||
|
color={daysColor(data.daysRemaining)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Features card */}
|
||||||
|
<Card title="Features">
|
||||||
|
<div className={styles.dividerList}>
|
||||||
|
{Object.entries(data.features).map(([key, enabled]) => (
|
||||||
|
<div key={key} className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>{key}</span>
|
||||||
|
<Badge
|
||||||
|
label={enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
color={enabled ? 'success' : 'auto'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.keys(data.features).length === 0 && (
|
||||||
|
<p className={styles.description}>No feature flags configured.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Limits & Usage card */}
|
||||||
|
<Card title="Limits & Usage">
|
||||||
|
<div className={styles.dividerList}>
|
||||||
|
<UsageIndicator used={0} limit={agentLimit} label="Agents" />
|
||||||
|
<UsageIndicator used={0} limit={envLimit} label="Environments" />
|
||||||
|
{retentionDays >= 0 && (
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Data retention</span>
|
||||||
|
<span className={styles.kvValue}>{retentionDays}d</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* License token card — full width */}
|
||||||
|
<Card title="License Token">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<p className={styles.description}>
|
||||||
|
This token is embedded in your Cameleer server configuration. Keep it secret.
|
||||||
|
</p>
|
||||||
|
<div className={styles.tokenBlock}>
|
||||||
|
<code className={styles.tokenCode}>
|
||||||
|
{showToken ? data.token : '••••••••••••••••••••••••••••••••'}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button variant="secondary" onClick={() => setShowToken((v) => !v)}>
|
||||||
|
{showToken
|
||||||
|
? <><EyeOff size={14} style={{ marginRight: 6 }} />Hide</>
|
||||||
|
: <><Eye size={14} style={{ marginRight: 6 }} />Show</>
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={handleCopy}>
|
||||||
|
<Copy size={14} style={{ marginRight: 6 }} />
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user