feat: restructure frontend routes — vendor/tenant persona split
Splits the flat 3-page UI into /vendor/* (platform:admin) and /tenant/* (all authenticated users) route trees, with stub pages, new API hooks, updated Layout with persona-aware sidebar, and SpaController forwarding. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,16 @@
|
|||||||
package net.siegeln.cameleer.saas.config;
|
package net.siegeln.cameleer.saas.config;
|
||||||
|
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
public class SpaController {
|
public class SpaController {
|
||||||
|
|
||||||
@GetMapping(value = {"/", "/login", "/callback", "/environments/**", "/license"})
|
@RequestMapping(value = {
|
||||||
public String spa() {
|
"/", "/login", "/callback",
|
||||||
|
"/vendor/**", "/tenant/**"
|
||||||
|
})
|
||||||
|
public String forward() {
|
||||||
return "forward:/index.html";
|
return "forward:/index.html";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
ui/src/api/tenant-hooks.ts
Normal file
63
ui/src/api/tenant-hooks.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from './client';
|
||||||
|
import type { DashboardData, TenantLicenseData, TenantSettings } from '../types/api';
|
||||||
|
|
||||||
|
export function useTenantDashboard() {
|
||||||
|
return useQuery<DashboardData>({
|
||||||
|
queryKey: ['tenant', 'dashboard'],
|
||||||
|
queryFn: () => api.get('/tenant/dashboard'),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTenantLicense() {
|
||||||
|
return useQuery<TenantLicenseData>({
|
||||||
|
queryKey: ['tenant', 'license'],
|
||||||
|
queryFn: () => api.get('/tenant/license'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTenantOidc() {
|
||||||
|
return useQuery<Record<string, unknown>>({
|
||||||
|
queryKey: ['tenant', 'oidc'],
|
||||||
|
queryFn: () => api.get('/tenant/oidc'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateOidc() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, Record<string, unknown>>({
|
||||||
|
mutationFn: (config) => api.post('/tenant/oidc', config),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'oidc'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTenantTeam() {
|
||||||
|
return useQuery<Array<Record<string, unknown>>>({
|
||||||
|
queryKey: ['tenant', 'team'],
|
||||||
|
queryFn: () => api.get('/tenant/team'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInviteTeamMember() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<{ userId: string }, Error, { email: string; roleId: string }>({
|
||||||
|
mutationFn: (body) => api.post('/tenant/team/invite', body),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveTeamMember() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (userId) => api.delete(`/tenant/team/${userId}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTenantSettings() {
|
||||||
|
return useQuery<TenantSettings>({
|
||||||
|
queryKey: ['tenant', 'settings'],
|
||||||
|
queryFn: () => api.get('/tenant/settings'),
|
||||||
|
});
|
||||||
|
}
|
||||||
59
ui/src/api/vendor-hooks.ts
Normal file
59
ui/src/api/vendor-hooks.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from './client';
|
||||||
|
import type { VendorTenantSummary, VendorTenantDetail, CreateTenantRequest, TenantResponse, LicenseResponse } from '../types/api';
|
||||||
|
|
||||||
|
export function useVendorTenants() {
|
||||||
|
return useQuery<VendorTenantSummary[]>({
|
||||||
|
queryKey: ['vendor', 'tenants'],
|
||||||
|
queryFn: () => api.get('/vendor/tenants'),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVendorTenant(id: string | null) {
|
||||||
|
return useQuery<VendorTenantDetail>({
|
||||||
|
queryKey: ['vendor', 'tenants', id],
|
||||||
|
queryFn: () => api.get(`/vendor/tenants/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateTenant() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<TenantResponse, Error, CreateTenantRequest>({
|
||||||
|
mutationFn: (req) => api.post('/vendor/tenants', req),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSuspendTenant() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<TenantResponse, Error, string>({
|
||||||
|
mutationFn: (id) => api.post(`/vendor/tenants/${id}/suspend`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActivateTenant() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<TenantResponse, Error, string>({
|
||||||
|
mutationFn: (id) => api.post(`/vendor/tenants/${id}/activate`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteTenant() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (id) => api.delete(`/vendor/tenants/${id}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRenewLicense() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<LicenseResponse, Error, string>({
|
||||||
|
mutationFn: (tenantId) => api.post(`/vendor/tenants/${tenantId}/license`),
|
||||||
|
onSuccess: (_, tenantId) => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', tenantId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { Outlet } from 'react-router';
|
||||||
import { useLogto } from '@logto/react';
|
import { useLogto } from '@logto/react';
|
||||||
import { Button, EmptyState, Spinner } from '@cameleer/design-system';
|
import { Spinner } from '@cameleer/design-system';
|
||||||
import { useMe } from '../api/hooks';
|
import { useMe } from '../api/hooks';
|
||||||
import { useOrgStore } from './useOrganization';
|
import { useOrgStore } from './useOrganization';
|
||||||
import { fetchConfig } from '../config';
|
import { fetchConfig } from '../config';
|
||||||
@@ -10,8 +11,8 @@ import { fetchConfig } from '../config';
|
|||||||
* Also reads OAuth2 scopes from the access token and stores them.
|
* Also reads OAuth2 scopes from the access token and stores them.
|
||||||
* Renders children once resolved.
|
* Renders children once resolved.
|
||||||
*/
|
*/
|
||||||
export function OrgResolver({ children }: { children: React.ReactNode }) {
|
export function OrgResolver({ children }: { children?: React.ReactNode }) {
|
||||||
const { data: me, isLoading, isError, refetch } = useMe();
|
const { data: me, isLoading, isError } = useMe();
|
||||||
const { getAccessToken } = useLogto();
|
const { getAccessToken } = useLogto();
|
||||||
const { getIdTokenClaims } = useLogto();
|
const { getIdTokenClaims } = useLogto();
|
||||||
const { setOrganizations, setCurrentOrg, setScopes, setUsername, currentOrgId } = useOrgStore();
|
const { setOrganizations, setCurrentOrg, setScopes, setUsername, currentOrgId } = useOrgStore();
|
||||||
@@ -86,18 +87,8 @@ export function OrgResolver({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return null;
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '50vh', gap: '1rem' }}>
|
|
||||||
<EmptyState
|
|
||||||
title="Unable to load account"
|
|
||||||
description="Failed to retrieve your organization. Please try again or contact support."
|
|
||||||
/>
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => refetch()}>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return children ? <>{children}</> : <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { Navigate } from 'react-router';
|
import { Navigate, Outlet } from 'react-router';
|
||||||
import { useLogto } from '@logto/react';
|
import { useLogto } from '@logto/react';
|
||||||
import { Spinner } from '@cameleer/design-system';
|
import { Spinner } from '@cameleer/design-system';
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
export function ProtectedRoute({ children }: { children?: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useLogto();
|
const { isAuthenticated, isLoading } = useLogto();
|
||||||
// The Logto SDK sets isLoading=true for EVERY async method (getAccessToken, etc.),
|
// The Logto SDK sets isLoading=true for EVERY async method (getAccessToken, etc.),
|
||||||
// not just initial auth. Only gate on the initial load — once isLoading is false
|
// not just initial auth. Only gate on the initial load — once isLoading is false
|
||||||
@@ -23,5 +23,5 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
return <>{children}</>;
|
return children ? <>{children}</> : <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useMemo } from 'react';
|
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router';
|
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||||
import { LayoutDashboard, ShieldCheck, Building, Activity } from 'lucide-react';
|
|
||||||
import {
|
import {
|
||||||
AppShell,
|
AppShell,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
TopBar,
|
TopBar,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
|
import { LayoutDashboard, ShieldCheck, Server, Users, Settings, KeyRound, Building } from 'lucide-react';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { useScopes } from '../auth/useScopes';
|
import { useScopes } from '../auth/useScopes';
|
||||||
import { useOrgStore } from '../auth/useOrganization';
|
import { useOrgStore } from '../auth/useOrganization';
|
||||||
@@ -23,70 +22,107 @@ function CameleerLogo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isActive(location: ReturnType<typeof useLocation>, path: string) {
|
||||||
|
return location.pathname === path || location.pathname.startsWith(path + '/');
|
||||||
|
}
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const scopes = useScopes();
|
const scopes = useScopes();
|
||||||
const { username } = useOrgStore();
|
const { username, organizations, currentOrgId } = useOrgStore();
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const isVendor = scopes.has('platform:admin');
|
||||||
|
|
||||||
const breadcrumb = useMemo(() => {
|
// Determine current org slug for server dashboard link
|
||||||
if (location.pathname.startsWith('/admin')) return [{ label: 'Admin' }, { label: 'Tenants' }];
|
const currentOrg = organizations.find((o) => o.id === currentOrgId);
|
||||||
if (location.pathname.startsWith('/license')) return [{ label: 'License' }];
|
const serverDashboardHref = currentOrg?.slug ? `/t/${currentOrg.slug}/` : '/server/';
|
||||||
return [{ label: 'Dashboard' }];
|
|
||||||
}, [location.pathname]);
|
// Build breadcrumbs from path
|
||||||
|
const segments = location.pathname.replace(/^\//, '').split('/').filter(Boolean);
|
||||||
|
const breadcrumb = segments.map((seg, i) => {
|
||||||
|
const label = seg.charAt(0).toUpperCase() + seg.slice(1).replace(/-/g, ' ');
|
||||||
|
return { label };
|
||||||
|
});
|
||||||
|
|
||||||
const sidebar = (
|
const sidebar = (
|
||||||
<Sidebar collapsed={collapsed} onCollapseToggle={() => setCollapsed(c => !c)}>
|
<Sidebar collapsed={false} onCollapseToggle={() => {}}>
|
||||||
<Sidebar.Header
|
<Sidebar.Header
|
||||||
logo={<CameleerLogo />}
|
logo={<CameleerLogo />}
|
||||||
title="Cameleer SaaS"
|
title="Cameleer SaaS"
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate(isVendor ? '/vendor/tenants' : '/tenant')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dashboard */}
|
{/* Vendor console — only visible to platform:admin */}
|
||||||
<Sidebar.Section
|
{isVendor && (
|
||||||
icon={<LayoutDashboard size={18} />}
|
|
||||||
label="Dashboard"
|
|
||||||
open={false}
|
|
||||||
active={location.pathname === '/' || location.pathname === ''}
|
|
||||||
onToggle={() => navigate('/')}
|
|
||||||
>
|
|
||||||
{null}
|
|
||||||
</Sidebar.Section>
|
|
||||||
|
|
||||||
{/* License */}
|
|
||||||
<Sidebar.Section
|
|
||||||
icon={<ShieldCheck size={18} />}
|
|
||||||
label="License"
|
|
||||||
open={false}
|
|
||||||
active={location.pathname.startsWith('/license')}
|
|
||||||
onToggle={() => navigate('/license')}
|
|
||||||
>
|
|
||||||
{null}
|
|
||||||
</Sidebar.Section>
|
|
||||||
|
|
||||||
{/* Platform Admin section */}
|
|
||||||
{scopes.has('platform:admin') && (
|
|
||||||
<Sidebar.Section
|
<Sidebar.Section
|
||||||
icon={<Building size={18} />}
|
icon={<Building size={16} />}
|
||||||
label="Platform"
|
label="Tenants"
|
||||||
open={false}
|
open={false}
|
||||||
active={location.pathname.startsWith('/admin')}
|
active={isActive(location, '/vendor/tenants')}
|
||||||
onToggle={() => navigate('/admin/tenants')}
|
onToggle={() => navigate('/vendor/tenants')}
|
||||||
>
|
>
|
||||||
{null}
|
{null}
|
||||||
</Sidebar.Section>
|
</Sidebar.Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tenant portal */}
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<LayoutDashboard size={16} />}
|
||||||
|
label="Dashboard"
|
||||||
|
open={false}
|
||||||
|
active={location.pathname === '/tenant'}
|
||||||
|
onToggle={() => navigate('/tenant')}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<ShieldCheck size={16} />}
|
||||||
|
label="License"
|
||||||
|
open={false}
|
||||||
|
active={isActive(location, '/tenant/license')}
|
||||||
|
onToggle={() => navigate('/tenant/license')}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<KeyRound size={16} />}
|
||||||
|
label="OIDC"
|
||||||
|
open={false}
|
||||||
|
active={isActive(location, '/tenant/oidc')}
|
||||||
|
onToggle={() => navigate('/tenant/oidc')}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<Users size={16} />}
|
||||||
|
label="Team"
|
||||||
|
open={false}
|
||||||
|
active={isActive(location, '/tenant/team')}
|
||||||
|
onToggle={() => navigate('/tenant/team')}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
<Sidebar.Section
|
||||||
|
icon={<Settings size={16} />}
|
||||||
|
label="Settings"
|
||||||
|
open={false}
|
||||||
|
active={isActive(location, '/tenant/settings')}
|
||||||
|
onToggle={() => navigate('/tenant/settings')}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Sidebar.Section>
|
||||||
|
|
||||||
<Sidebar.Footer>
|
<Sidebar.Footer>
|
||||||
{/* Link to the server observability dashboard */}
|
|
||||||
<Sidebar.FooterLink
|
<Sidebar.FooterLink
|
||||||
icon={<Activity size={18} />}
|
icon={<Server size={16} />}
|
||||||
label="Open Server Dashboard"
|
label="Open Server Dashboard"
|
||||||
onClick={() => window.open('/server/', '_blank', 'noopener')}
|
onClick={() => window.open(serverDashboardHref, '_blank', 'noopener')}
|
||||||
/>
|
/>
|
||||||
</Sidebar.Footer>
|
</Sidebar.Footer>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
@@ -94,17 +130,9 @@ export function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell sidebar={sidebar}>
|
<AppShell sidebar={sidebar}>
|
||||||
{/*
|
|
||||||
* TopBar always renders status filters, time range pills, auto-refresh, and
|
|
||||||
* command palette search via useGlobalFilters() / useCommandPalette(). Both
|
|
||||||
* hooks throw if their providers are absent, so GlobalFilterProvider and
|
|
||||||
* CommandPaletteProvider cannot be removed from main.tsx without crashing the
|
|
||||||
* app. The TopBar API has no props to suppress these server-oriented controls.
|
|
||||||
* Hiding them on platform pages would require a DS change.
|
|
||||||
*/}
|
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={breadcrumb}
|
breadcrumb={breadcrumb}
|
||||||
user={{ name: username || 'User' }}
|
user={username ? { name: username } : undefined}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
/>
|
/>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
DataTable,
|
|
||||||
EmptyState,
|
|
||||||
Spinner,
|
|
||||||
} from '@cameleer/design-system';
|
|
||||||
import type { Column } from '@cameleer/design-system';
|
|
||||||
import { useAllTenants } from '../api/hooks';
|
|
||||||
import { useOrgStore } from '../auth/useOrganization';
|
|
||||||
import type { TenantResponse } from '../types/api';
|
|
||||||
import styles from '../styles/platform.module.css';
|
|
||||||
|
|
||||||
const columns: Column<TenantResponse>[] = [
|
|
||||||
{ key: 'name', header: 'Name' },
|
|
||||||
{ key: 'slug', header: 'Slug' },
|
|
||||||
{
|
|
||||||
key: 'tier',
|
|
||||||
header: 'Tier',
|
|
||||||
render: (_v: unknown, row: TenantResponse) => <Badge label={row.tier} color="primary" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Status',
|
|
||||||
render: (_v: unknown, row: TenantResponse) => (
|
|
||||||
<Badge
|
|
||||||
label={row.status}
|
|
||||||
color={row.status === 'ACTIVE' ? 'success' : 'warning'}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ key: 'createdAt', header: 'Created', render: (_: unknown, row: TenantResponse) => new Date(row.createdAt).toLocaleDateString() },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AdminTenantsPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data: tenants, isLoading, isError } = useAllTenants();
|
|
||||||
const { setCurrentOrg } = useOrgStore();
|
|
||||||
const [switchTarget, setSwitchTarget] = useState<TenantResponse | null>(null);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<EmptyState
|
|
||||||
title="Unable to load tenants"
|
|
||||||
description="You may not have admin permissions, or the server is unavailable."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRowClick = (tenant: TenantResponse) => {
|
|
||||||
setSwitchTarget(tenant);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmSwitch = () => {
|
|
||||||
if (!switchTarget) return;
|
|
||||||
const orgs = useOrgStore.getState().organizations;
|
|
||||||
const match = orgs.find((o) => o.name === switchTarget.name || o.slug === switchTarget.slug);
|
|
||||||
if (match) {
|
|
||||||
setCurrentOrg(match.id);
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
setSwitchTarget(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className={styles.heading}>All Tenants</h1>
|
|
||||||
<Badge label="Platform Admin" color="warning" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card title={`${tenants?.length ?? 0} Tenants`}>
|
|
||||||
{(!tenants || tenants.length === 0) ? (
|
|
||||||
<EmptyState title="No tenants" description="No tenants have been created yet." />
|
|
||||||
) : (
|
|
||||||
<DataTable columns={columns} data={tenants} onRowClick={handleRowClick} />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={!!switchTarget}
|
|
||||||
onClose={() => setSwitchTarget(null)}
|
|
||||||
onConfirm={confirmSwitch}
|
|
||||||
title="Switch tenant?"
|
|
||||||
description={`Switch to tenant "${switchTarget?.name}"? Your dashboard context will change.`}
|
|
||||||
confirmLabel="Switch"
|
|
||||||
variant="warning"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
EmptyState,
|
|
||||||
KpiStrip,
|
|
||||||
Spinner,
|
|
||||||
} from '@cameleer/design-system';
|
|
||||||
import { useAuth } from '../auth/useAuth';
|
|
||||||
import { useTenant, useLicense } from '../api/hooks';
|
|
||||||
import styles from '../styles/platform.module.css';
|
|
||||||
import { tierColor } from '../utils/tier';
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
|
||||||
const { tenantId } = useAuth();
|
|
||||||
|
|
||||||
const { data: tenant, isLoading: tenantLoading, isError: tenantError } = useTenant(tenantId ?? '');
|
|
||||||
const { data: license, isLoading: licenseLoading, isError: licenseError } = useLicense(tenantId ?? '');
|
|
||||||
|
|
||||||
const isLoading = tenantLoading || licenseLoading;
|
|
||||||
|
|
||||||
const kpiItems = [
|
|
||||||
{
|
|
||||||
label: 'Tier',
|
|
||||||
value: tenant?.tier ?? '-',
|
|
||||||
subtitle: 'subscription level',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Status',
|
|
||||||
value: tenant?.status ?? '-',
|
|
||||||
subtitle: 'tenant status',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'License',
|
|
||||||
value: license ? 'Active' : 'None',
|
|
||||||
trend: license
|
|
||||||
? { label: `expires ${new Date(license.expiresAt).toLocaleDateString()}`, variant: 'success' as const }
|
|
||||||
: { label: 'no license', variant: 'warning' as const },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tenantError || licenseError) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<EmptyState
|
|
||||||
title="Unable to load dashboard"
|
|
||||||
description="Failed to retrieve tenant information. Please try again later."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tenantId) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
title="No tenant associated"
|
|
||||||
description="Your account is not linked to a tenant. Please contact your administrator."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* Tenant Header */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className={styles.heading}>
|
|
||||||
{tenant?.name ?? tenantId}
|
|
||||||
</h1>
|
|
||||||
{tenant?.tier && (
|
|
||||||
<Badge
|
|
||||||
label={tenant.tier.toUpperCase()}
|
|
||||||
color={tierColor(tenant.tier)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI Strip */}
|
|
||||||
<KpiStrip items={kpiItems} />
|
|
||||||
|
|
||||||
{/* Tenant Info */}
|
|
||||||
<Card title="Tenant Information">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className={styles.kvRow}>
|
|
||||||
<span className={styles.kvLabel}>Slug</span>
|
|
||||||
<span className={styles.kvValueMono}>{tenant?.slug ?? '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.kvRow}>
|
|
||||||
<span className={styles.kvLabel}>Status</span>
|
|
||||||
<Badge
|
|
||||||
label={tenant?.status ?? 'UNKNOWN'}
|
|
||||||
color={tenant?.status === 'ACTIVE' ? 'success' : 'warning'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.kvRow}>
|
|
||||||
<span className={styles.kvLabel}>Created</span>
|
|
||||||
<span className={styles.kvValue}>{tenant?.createdAt ? new Date(tenant.createdAt).toLocaleDateString() : '-'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Server Dashboard Link */}
|
|
||||||
<Card title="Server Management">
|
|
||||||
<p className={`${styles.description} mb-3`}>
|
|
||||||
Environments, applications, and deployments are managed through the server dashboard.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => window.open('/server/', '_blank', 'noopener')}
|
|
||||||
>
|
|
||||||
Open Server Dashboard
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
EmptyState,
|
|
||||||
Spinner,
|
|
||||||
useToast,
|
|
||||||
} from '@cameleer/design-system';
|
|
||||||
import { Copy } from 'lucide-react';
|
|
||||||
import { useAuth } from '../auth/useAuth';
|
|
||||||
import { useLicense } from '../api/hooks';
|
|
||||||
import styles from '../styles/platform.module.css';
|
|
||||||
import { tierColor } from '../utils/tier';
|
|
||||||
|
|
||||||
const FEATURE_LABELS: Record<string, string> = {
|
|
||||||
topology: 'Topology',
|
|
||||||
lineage: 'Lineage',
|
|
||||||
correlation: 'Correlation',
|
|
||||||
debugger: 'Debugger',
|
|
||||||
replay: 'Replay',
|
|
||||||
};
|
|
||||||
|
|
||||||
const LIMIT_LABELS: Record<string, string> = {
|
|
||||||
max_agents: 'Max Agents',
|
|
||||||
retention_days: 'Retention Days',
|
|
||||||
max_environments: 'Max Environments',
|
|
||||||
};
|
|
||||||
|
|
||||||
function daysRemaining(expiresAt: string): number {
|
|
||||||
const now = Date.now();
|
|
||||||
const exp = new Date(expiresAt).getTime();
|
|
||||||
return Math.max(0, Math.ceil((exp - now) / (1000 * 60 * 60 * 24)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LicensePage() {
|
|
||||||
const { tenantId } = useAuth();
|
|
||||||
const { data: license, isLoading, isError } = useLicense(tenantId ?? '');
|
|
||||||
const [tokenExpanded, setTokenExpanded] = useState(false);
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tenantId) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
title="No tenant associated"
|
|
||||||
description="Your account is not linked to a tenant. Please contact your administrator."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !license) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
title="License unavailable"
|
|
||||||
description="Unable to load license information. Please try again later."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const expDate = new Date(license.expiresAt).toLocaleDateString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
const days = daysRemaining(license.expiresAt);
|
|
||||||
const isExpiringSoon = days <= 30;
|
|
||||||
const isExpired = days === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className={styles.heading}>License</h1>
|
|
||||||
<Badge
|
|
||||||
label={license.tier.toUpperCase()}
|
|
||||||
color={tierColor(license.tier)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expiry info */}
|
|
||||||
<Card title="Validity">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className={styles.kvRow}>
|
|
||||||
<span className={styles.kvLabel}>Issued</span>
|
|
||||||
<span className={styles.kvValue}>
|
|
||||||
{new Date(license.issuedAt).toLocaleDateString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.kvRow}>
|
|
||||||
<span className={styles.kvLabel}>Expires</span>
|
|
||||||
<span className={styles.kvValue}>{expDate}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.kvRow}>
|
|
||||||
<span className={styles.kvLabel}>Days remaining</span>
|
|
||||||
<Badge
|
|
||||||
label={isExpired ? 'Expired' : `${days} days`}
|
|
||||||
color={isExpired ? 'error' : isExpiringSoon ? 'warning' : 'success'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Feature matrix */}
|
|
||||||
<Card title="Features">
|
|
||||||
<div className={styles.dividerList}>
|
|
||||||
{Object.entries(FEATURE_LABELS).map(([key, label]) => {
|
|
||||||
const enabled = license.features[key] ?? false;
|
|
||||||
return (
|
|
||||||
<div key={key} className={styles.dividerRow}>
|
|
||||||
<span className={styles.kvLabel}>{label}</span>
|
|
||||||
<Badge
|
|
||||||
label={enabled ? 'Enabled' : 'Not included'}
|
|
||||||
color={enabled ? 'success' : 'warning'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Limits */}
|
|
||||||
<Card title="Limits">
|
|
||||||
<div className={styles.dividerList}>
|
|
||||||
{Object.entries(LIMIT_LABELS).map(([key, label]) => {
|
|
||||||
const value = license.limits[key];
|
|
||||||
return (
|
|
||||||
<div key={key} className={styles.dividerRow}>
|
|
||||||
<span className={styles.kvLabel}>{label}</span>
|
|
||||||
<span className={styles.kvValueMono}>
|
|
||||||
{value !== undefined ? value : '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* License token */}
|
|
||||||
<Card title="License Token">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className={styles.description}>
|
|
||||||
Use this token when registering Cameleer agents with your tenant.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setTokenExpanded((v) => !v)}>
|
|
||||||
{tokenExpanded ? 'Hide token' : 'Show token'}
|
|
||||||
</Button>
|
|
||||||
{tokenExpanded && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => {
|
|
||||||
navigator.clipboard.writeText(license.token);
|
|
||||||
toast({ title: 'Token copied to clipboard', variant: 'success' });
|
|
||||||
}}>
|
|
||||||
<Copy size={14} /> Copy
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{tokenExpanded && (
|
|
||||||
<div className={styles.tokenBlock}>
|
|
||||||
<code className={styles.tokenCode}>
|
|
||||||
{license.token}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
1
ui/src/pages/tenant/OidcConfigPage.tsx
Normal file
1
ui/src/pages/tenant/OidcConfigPage.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export function OidcConfigPage() { return <div>OidcConfigPage (TODO)</div>; }
|
||||||
1
ui/src/pages/tenant/SettingsPage.tsx
Normal file
1
ui/src/pages/tenant/SettingsPage.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export function SettingsPage() { return <div>SettingsPage (TODO)</div>; }
|
||||||
1
ui/src/pages/tenant/TeamPage.tsx
Normal file
1
ui/src/pages/tenant/TeamPage.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export function TeamPage() { return <div>TeamPage (TODO)</div>; }
|
||||||
1
ui/src/pages/tenant/TenantDashboardPage.tsx
Normal file
1
ui/src/pages/tenant/TenantDashboardPage.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export function TenantDashboardPage() { return <div>TenantDashboardPage (TODO)</div>; }
|
||||||
1
ui/src/pages/tenant/TenantLicensePage.tsx
Normal file
1
ui/src/pages/tenant/TenantLicensePage.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export function TenantLicensePage() { return <div>TenantLicensePage (TODO)</div>; }
|
||||||
1
ui/src/pages/vendor/CreateTenantPage.tsx
vendored
Normal file
1
ui/src/pages/vendor/CreateTenantPage.tsx
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export function CreateTenantPage() { return <div>CreateTenantPage (TODO)</div>; }
|
||||||
1
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
Normal file
1
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export function TenantDetailPage() { return <div>TenantDetailPage (TODO)</div>; }
|
||||||
1
ui/src/pages/vendor/VendorTenantsPage.tsx
vendored
Normal file
1
ui/src/pages/vendor/VendorTenantsPage.tsx
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export function VendorTenantsPage() { return <div>VendorTenantsPage (TODO)</div>; }
|
||||||
@@ -1,30 +1,56 @@
|
|||||||
import { Routes, Route } from 'react-router';
|
import { Routes, Route, Navigate } from 'react-router';
|
||||||
import { LoginPage } from './auth/LoginPage';
|
import { LoginPage } from './auth/LoginPage';
|
||||||
import { CallbackPage } from './auth/CallbackPage';
|
import { CallbackPage } from './auth/CallbackPage';
|
||||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
import { OrgResolver } from './auth/OrgResolver';
|
import { OrgResolver } from './auth/OrgResolver';
|
||||||
import { Layout } from './components/Layout';
|
import { Layout } from './components/Layout';
|
||||||
import { DashboardPage } from './pages/DashboardPage';
|
import { RequireScope } from './components/RequireScope';
|
||||||
import { LicensePage } from './pages/LicensePage';
|
|
||||||
import { AdminTenantsPage } from './pages/AdminTenantsPage';
|
import { VendorTenantsPage } from './pages/vendor/VendorTenantsPage';
|
||||||
|
import { CreateTenantPage } from './pages/vendor/CreateTenantPage';
|
||||||
|
import { TenantDetailPage } from './pages/vendor/TenantDetailPage';
|
||||||
|
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
|
||||||
|
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
|
||||||
|
import { OidcConfigPage } from './pages/tenant/OidcConfigPage';
|
||||||
|
import { TeamPage } from './pages/tenant/TeamPage';
|
||||||
|
import { SettingsPage } from './pages/tenant/SettingsPage';
|
||||||
|
|
||||||
export function AppRouter() {
|
export function AppRouter() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/callback" element={<CallbackPage />} />
|
<Route path="/callback" element={<CallbackPage />} />
|
||||||
<Route
|
<Route element={<ProtectedRoute />}>
|
||||||
element={
|
<Route element={<OrgResolver />}>
|
||||||
<ProtectedRoute>
|
<Route element={<Layout />}>
|
||||||
<OrgResolver>
|
{/* Vendor console */}
|
||||||
<Layout />
|
<Route path="/vendor/tenants" element={
|
||||||
</OrgResolver>
|
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
|
||||||
</ProtectedRoute>
|
<VendorTenantsPage />
|
||||||
}
|
</RequireScope>
|
||||||
>
|
} />
|
||||||
<Route index element={<DashboardPage />} />
|
<Route path="/vendor/tenants/new" element={
|
||||||
<Route path="license" element={<LicensePage />} />
|
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
|
||||||
<Route path="admin/tenants" element={<AdminTenantsPage />} />
|
<CreateTenantPage />
|
||||||
|
</RequireScope>
|
||||||
|
} />
|
||||||
|
<Route path="/vendor/tenants/:id" element={
|
||||||
|
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
|
||||||
|
<TenantDetailPage />
|
||||||
|
</RequireScope>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* Tenant portal */}
|
||||||
|
<Route path="/tenant" element={<TenantDashboardPage />} />
|
||||||
|
<Route path="/tenant/license" element={<TenantLicensePage />} />
|
||||||
|
<Route path="/tenant/oidc" element={<OidcConfigPage />} />
|
||||||
|
<Route path="/tenant/team" element={<TeamPage />} />
|
||||||
|
<Route path="/tenant/settings" element={<SettingsPage />} />
|
||||||
|
|
||||||
|
{/* Default redirect */}
|
||||||
|
<Route index element={<Navigate to="/tenant" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface TenantResponse {
|
|||||||
slug: string;
|
slug: string;
|
||||||
tier: string;
|
tier: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
serverEndpoint: string | null;
|
||||||
|
provisionError: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -28,3 +30,64 @@ export interface MeResponse {
|
|||||||
logtoOrgId: string;
|
logtoOrgId: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vendor API types
|
||||||
|
export interface VendorTenantSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
tier: string;
|
||||||
|
status: string;
|
||||||
|
serverState: string;
|
||||||
|
licenseExpiry: string | null;
|
||||||
|
provisionError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorTenantDetail {
|
||||||
|
tenant: TenantResponse;
|
||||||
|
serverState: string;
|
||||||
|
serverHealthy: boolean;
|
||||||
|
serverStatus: string;
|
||||||
|
license: LicenseResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTenantRequest {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
tier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant portal API types
|
||||||
|
export interface DashboardData {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
tier: string;
|
||||||
|
status: string;
|
||||||
|
serverHealthy: boolean;
|
||||||
|
serverStatus: string;
|
||||||
|
serverEndpoint: string | null;
|
||||||
|
licenseTier: string | null;
|
||||||
|
licenseDaysRemaining: number;
|
||||||
|
limits: Record<string, number>;
|
||||||
|
features: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantLicenseData {
|
||||||
|
id: string;
|
||||||
|
tier: string;
|
||||||
|
features: Record<string, boolean>;
|
||||||
|
limits: Record<string, number>;
|
||||||
|
issuedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
token: string;
|
||||||
|
daysRemaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantSettings {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
tier: string;
|
||||||
|
status: string;
|
||||||
|
serverEndpoint: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user