style(admin-web): format dashboard sources
Some checks failed
Publish @bytelyst/* packages / publish (push) Failing after 13s
CI — Common Platform / Build, Test & Typecheck (push) Successful in 53s

This commit is contained in:
Saravana Kumar 2026-05-30 21:18:09 +00:00
parent 20e1ac0e67
commit 7465b21d91
29 changed files with 422 additions and 308 deletions

View File

@ -37,10 +37,10 @@ curl -X POST "http://localhost:3001/api/seed?secret=<SEED_SECRET>"
### Default Logins ### Default Logins
| Email | Password | Role | | Email | Password | Role |
| ------------------ | ----------- | ----------- | | -------------------- | ----------- | ----------- |
| `admin@example.com` | `Admin123!` | Super Admin | | `admin@example.com` | `Admin123!` | Super Admin |
| `viewer@example.com` | `viewer123` | Viewer | | `viewer@example.com` | `viewer123` | Viewer |
## Environment Variables ## Environment Variables

View File

@ -52,13 +52,15 @@ describe('GET /api/settings/kill-switch', () => {
it('returns existing kill_switch flag state', async () => { it('returns existing kill_switch flag state', async () => {
mockListFlags.mockResolvedValue({ mockListFlags.mockResolvedValue({
flags: [{ flags: [
key: 'kill_switch', {
enabled: true, key: 'kill_switch',
platforms: ['desktop', 'ios'], enabled: true,
description: 'Maintenance window', platforms: ['desktop', 'ios'],
updatedAt: '2026-02-16T00:00:00Z', description: 'Maintenance window',
}], updatedAt: '2026-02-16T00:00:00Z',
},
],
}); });
const res = await makeGet(); const res = await makeGet();

View File

@ -138,7 +138,7 @@ export default function ExtractionPage() {
} }
}, [inputText, selectedTask]); }, [inputText, selectedTask]);
const currentTask = tasks.find((t) => t.id === selectedTask); const currentTask = tasks.find(t => t.id === selectedTask);
// Group extractions by class // Group extractions by class
const groupedExtractions = result?.extractions.reduce( const groupedExtractions = result?.extractions.reduce(
@ -148,7 +148,7 @@ export default function ExtractionPage() {
acc[cls].push(e); acc[cls].push(e);
return acc; return acc;
}, },
{} as Record<string, ExtractionEntity[]>, {} as Record<string, ExtractionEntity[]>
); );
return ( return (
@ -189,7 +189,7 @@ export default function ExtractionPage() {
className="w-full min-h-[200px] rounded-lg border border-border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring" className="w-full min-h-[200px] rounded-lg border border-border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Paste a transcript, meeting notes, or any text to extract structured entities from..." placeholder="Paste a transcript, meeting notes, or any text to extract structured entities from..."
value={inputText} value={inputText}
onChange={(e) => setInputText(e.target.value)} onChange={e => setInputText(e.target.value)}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={handleExtract} disabled={loading || !inputText.trim()}> <Button onClick={handleExtract} disabled={loading || !inputText.trim()}>
@ -212,10 +212,10 @@ export default function ExtractionPage() {
<select <select
className="w-full rounded-lg border border-border bg-background p-2 text-sm" className="w-full rounded-lg border border-border bg-background p-2 text-sm"
value={selectedTask} value={selectedTask}
onChange={(e) => setSelectedTask(e.target.value)} onChange={e => setSelectedTask(e.target.value)}
> >
{tasks.length > 0 ? ( {tasks.length > 0 ? (
tasks.map((t) => ( tasks.map(t => (
<option key={t.id} value={t.id}> <option key={t.id} value={t.id}>
{t.name} {t.builtIn ? '(built-in)' : ''} {t.name} {t.builtIn ? '(built-in)' : ''}
</option> </option>
@ -249,7 +249,7 @@ export default function ExtractionPage() {
<CardContent className="pt-4 space-y-2 text-xs"> <CardContent className="pt-4 space-y-2 text-xs">
<p className="text-muted-foreground">{currentTask.description}</p> <p className="text-muted-foreground">{currentTask.description}</p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{currentTask.classes.map((cls) => ( {currentTask.classes.map(cls => (
<Badge <Badge
key={cls} key={cls}
variant="outline" variant="outline"
@ -365,11 +365,7 @@ export default function ExtractionPage() {
{e.attributes && Object.keys(e.attributes).length > 0 && ( {e.attributes && Object.keys(e.attributes).length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{Object.entries(e.attributes).map(([k, v]) => ( {Object.entries(e.attributes).map(([k, v]) => (
<Badge <Badge key={k} variant="secondary" className="text-[10px]">
key={k}
variant="secondary"
className="text-[10px]"
>
{k}: {v} {k}: {v}
</Badge> </Badge>
))} ))}
@ -400,7 +396,10 @@ export default function ExtractionPage() {
{result.extractions.map((e, i) => ( {result.extractions.map((e, i) => (
<TableRow key={i}> <TableRow key={i}>
<TableCell> <TableCell>
<Badge variant="outline" className={`text-xs ${getClassColor(e.extraction_class)}`}> <Badge
variant="outline"
className={`text-xs ${getClassColor(e.extraction_class)}`}
>
{e.extraction_class} {e.extraction_class}
</Badge> </Badge>
</TableCell> </TableCell>

View File

@ -1,14 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import { Flag, Plus, Loader2, Trash2, ToggleLeft, ToggleRight } from 'lucide-react';
Flag,
Plus,
Loader2,
Trash2,
ToggleLeft,
ToggleRight,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -83,7 +76,10 @@ export default function FlagsPage() {
enabled: form.enabled, enabled: form.enabled,
percentage: form.percentage, percentage: form.percentage,
platforms: form.platforms platforms: form.platforms
? form.platforms.split(',').map(s => s.trim()).filter(Boolean) ? form.platforms
.split(',')
.map(s => s.trim())
.filter(Boolean)
: [], : [],
}), }),
}); });
@ -251,9 +247,7 @@ export default function FlagsPage() {
<div> <div>
<CardTitle className="text-sm font-mono">{flag.key}</CardTitle> <CardTitle className="text-sm font-mono">{flag.key}</CardTitle>
{flag.description && ( {flag.description && (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">{flag.description}</p>
{flag.description}
</p>
)} )}
</div> </div>
</div> </div>
@ -284,19 +278,9 @@ export default function FlagsPage() {
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
<div className="flex items-center gap-4 text-xs text-muted-foreground"> <div className="flex items-center gap-4 text-xs text-muted-foreground">
{flag.platforms.length > 0 && ( {flag.platforms.length > 0 && <span>Platforms: {flag.platforms.join(', ')}</span>}
<span> {flag.segments.length > 0 && <span>Segments: {flag.segments.join(', ')}</span>}
Platforms: {flag.platforms.join(', ')} <span>Updated {new Date(flag.updatedAt).toLocaleDateString()}</span>
</span>
)}
{flag.segments.length > 0 && (
<span>
Segments: {flag.segments.join(', ')}
</span>
)}
<span>
Updated {new Date(flag.updatedAt).toLocaleDateString()}
</span>
</div> </div>
{flag.enabled && ( {flag.enabled && (
<div className="mt-3 flex items-center gap-3"> <div className="mt-3 flex items-center gap-3">

View File

@ -1,17 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { import { Key, Search, Plus, Copy, Check, Loader2, Monitor, Smartphone, X } from 'lucide-react';
Key,
Search,
Plus,
Copy,
Check,
Loader2,
Monitor,
Smartphone,
X,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -131,9 +121,7 @@ export default function LicensesPage() {
body: JSON.stringify({ key, action: 'revoke' }), body: JSON.stringify({ key, action: 'revoke' }),
}); });
if (res.ok) { if (res.ok) {
setLicenses(prev => setLicenses(prev => prev.map(l => (l.key === key ? { ...l, status: 'revoked' } : l)));
prev.map(l => (l.key === key ? { ...l, status: 'revoked' } : l))
);
} }
} catch { } catch {
// ignore // ignore
@ -220,11 +208,7 @@ export default function LicensesPage() {
<code className="text-sm font-mono font-bold text-emerald-800 dark:text-emerald-200"> <code className="text-sm font-mono font-bold text-emerald-800 dark:text-emerald-200">
{generatedKey} {generatedKey}
</code> </code>
<Button <Button variant="ghost" size="sm" onClick={() => copyToClipboard(generatedKey)}>
variant="ghost"
size="sm"
onClick={() => copyToClipboard(generatedKey)}
>
{copiedKey === generatedKey ? ( {copiedKey === generatedKey ? (
<Check className="h-4 w-4 text-emerald-600" /> <Check className="h-4 w-4 text-emerald-600" />
) : ( ) : (
@ -371,9 +355,7 @@ export default function LicensesPage() {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-xs text-muted-foreground italic"> <p className="text-xs text-muted-foreground italic">No devices activated yet</p>
No devices activated yet
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,16 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { import { Bell, Search, Loader2, Monitor, Smartphone, Tablet, Check, X } from 'lucide-react';
Bell,
Search,
Loader2,
Monitor,
Smartphone,
Tablet,
Check,
X,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -86,7 +77,9 @@ export default function NotificationsPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Look Up User</CardTitle> <CardTitle className="text-base">Look Up User</CardTitle>
<CardDescription>Search by user ID to view their devices and notification preferences</CardDescription> <CardDescription>
Search by user ID to view their devices and notification preferences
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex gap-2"> <div className="flex gap-2">
@ -122,11 +115,17 @@ export default function NotificationsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">Push:</span> <span className="text-muted-foreground">Push:</span>
{prefs.pushEnabled ? ( {prefs.pushEnabled ? (
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400"> <Badge
variant="secondary"
className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400"
>
<Check className="mr-1 h-3 w-3" /> Enabled <Check className="mr-1 h-3 w-3" /> Enabled
</Badge> </Badge>
) : ( ) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"> <Badge
variant="secondary"
className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
>
<X className="mr-1 h-3 w-3" /> Disabled <X className="mr-1 h-3 w-3" /> Disabled
</Badge> </Badge>
)} )}
@ -134,11 +133,17 @@ export default function NotificationsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground">Email:</span> <span className="text-muted-foreground">Email:</span>
{prefs.emailEnabled ? ( {prefs.emailEnabled ? (
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400"> <Badge
variant="secondary"
className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400"
>
<Check className="mr-1 h-3 w-3" /> Enabled <Check className="mr-1 h-3 w-3" /> Enabled
</Badge> </Badge>
) : ( ) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"> <Badge
variant="secondary"
className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
>
<X className="mr-1 h-3 w-3" /> Disabled <X className="mr-1 h-3 w-3" /> Disabled
</Badge> </Badge>
)} )}
@ -167,9 +172,7 @@ export default function NotificationsPage() {
{/* Devices */} {/* Devices */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base"> <CardTitle className="text-base">Registered Devices ({devices.length})</CardTitle>
Registered Devices ({devices.length})
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{devices.length === 0 ? ( {devices.length === 0 ? (
@ -205,11 +208,17 @@ export default function NotificationsPage() {
</div> </div>
</div> </div>
{device.pushToken ? ( {device.pushToken ? (
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-[10px]"> <Badge
variant="secondary"
className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-[10px]"
>
Push token Push token
</Badge> </Badge>
) : ( ) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 text-[10px]"> <Badge
variant="secondary"
className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 text-[10px]"
>
No push token No push token
</Badge> </Badge>
)} )}

View File

@ -221,9 +221,7 @@ export default function SecretsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-3xl font-bold tracking-tight">Secrets Manager</h2> <h2 className="text-3xl font-bold tracking-tight">Secrets Manager</h2>
{vaultUrl && ( {vaultUrl && <p className="text-sm text-muted-foreground mt-1 font-mono">{vaultUrl}</p>}
<p className="text-sm text-muted-foreground mt-1 font-mono">{vaultUrl}</p>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchSecrets} disabled={loading}> <Button variant="outline" size="sm" onClick={fetchSecrets} disabled={loading}>
@ -423,7 +421,11 @@ export default function SecretsPage() {
className="h-7 w-7" className="h-7 w-7"
onClick={() => setShowValue(!showValue)} onClick={() => setShowValue(!showValue)}
> >
{showValue ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />} {showValue ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy}> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy}>
{copied ? ( {copied ? (
@ -545,8 +547,8 @@ export default function SecretsPage() {
<DialogTitle className="text-red-500">Delete Secret</DialogTitle> <DialogTitle className="text-red-500">Delete Secret</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to delete{' '} Are you sure you want to delete{' '}
<span className="font-mono font-bold">{deleteTarget}</span>? This will soft-delete <span className="font-mono font-bold">{deleteTarget}</span>? This will soft-delete the
the secret in Azure Key Vault. It can be recovered within the retention period. secret in Azure Key Vault. It can be recovered within the retention period.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>

View File

@ -84,7 +84,11 @@ export default function TelemetryPoliciesPage() {
const [formPercentage, setFormPercentage] = useState(100); const [formPercentage, setFormPercentage] = useState(100);
const [formStartsAt, setFormStartsAt] = useState(''); const [formStartsAt, setFormStartsAt] = useState('');
const [formExpiresAt, setFormExpiresAt] = useState(''); const [formExpiresAt, setFormExpiresAt] = useState('');
const [preview, setPreview] = useState<{ matchedClients: number; totalClients: number; sampleSize: number } | null>(null); const [preview, setPreview] = useState<{
matchedClients: number;
totalClients: number;
sampleSize: number;
} | null>(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const fetchPolicies = useCallback(async () => { const fetchPolicies = useCallback(async () => {
@ -154,7 +158,12 @@ export default function TelemetryPoliciesPage() {
enabled: formEnabled, enabled: formEnabled,
priority: formPriority, priority: formPriority,
eventTypes: formEventTypes, eventTypes: formEventTypes,
modules: formModules ? formModules.split(',').map(m => m.trim()).filter(Boolean) : [], modules: formModules
? formModules
.split(',')
.map(m => m.trim())
.filter(Boolean)
: [],
samplingRate: formSamplingRate, samplingRate: formSamplingRate,
targeting: { targeting: {
platforms: formPlatforms.length > 0 ? formPlatforms : undefined, platforms: formPlatforms.length > 0 ? formPlatforms : undefined,
@ -213,11 +222,7 @@ export default function TelemetryPoliciesPage() {
} }
}; };
const toggleArrayItem = ( const toggleArrayItem = (arr: string[], item: string, setter: (v: string[]) => void) => {
arr: string[],
item: string,
setter: (v: string[]) => void
) => {
setter(arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item]); setter(arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item]);
}; };
@ -471,12 +476,15 @@ export default function TelemetryPoliciesPage() {
targeting: { targeting: {
platforms: formPlatforms.length > 0 ? formPlatforms : undefined, platforms: formPlatforms.length > 0 ? formPlatforms : undefined,
channels: formChannels.length > 0 ? formChannels : undefined, channels: formChannels.length > 0 ? formChannels : undefined,
releaseChannels: formReleaseChannels.length > 0 ? formReleaseChannels : undefined, releaseChannels:
formReleaseChannels.length > 0 ? formReleaseChannels : undefined,
}, },
}), }),
}); });
if (res.ok) setPreview(await res.json()); if (res.ok) setPreview(await res.json());
} catch { /* best effort */ } finally { } catch {
/* best effort */
} finally {
setPreviewLoading(false); setPreviewLoading(false);
} }
}} }}
@ -488,7 +496,8 @@ export default function TelemetryPoliciesPage() {
<span className="text-sm"> <span className="text-sm">
<strong className="text-primary">{preview.matchedClients}</strong> <strong className="text-primary">{preview.matchedClients}</strong>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{' '}/ {preview.totalClients} clients would match {' '}
/ {preview.totalClients} clients would match
</span> </span>
<span className="text-xs text-muted-foreground ml-1"> <span className="text-xs text-muted-foreground ml-1">
(from {preview.sampleSize} recent events) (from {preview.sampleSize} recent events)
@ -581,9 +590,7 @@ export default function TelemetryPoliciesPage() {
<div> <div>
<p className="font-medium">{policy.name}</p> <p className="font-medium">{policy.name}</p>
{policy.description && ( {policy.description && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{policy.description}</p>
{policy.description}
</p>
)} )}
</div> </div>
</TableCell> </TableCell>
@ -625,13 +632,9 @@ export default function TelemetryPoliciesPage() {
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground"> <TableCell className="text-xs text-muted-foreground">
{policy.startsAt {policy.startsAt ? new Date(policy.startsAt).toLocaleDateString() : '—'}
? new Date(policy.startsAt).toLocaleDateString()
: '—'}
{' → '} {' → '}
{policy.expiresAt {policy.expiresAt ? new Date(policy.expiresAt).toLocaleDateString() : '∞'}
? new Date(policy.expiresAt).toLocaleDateString()
: '∞'}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
@ -647,18 +650,10 @@ export default function TelemetryPoliciesPage() {
<ToggleLeft className="h-4 w-4" /> <ToggleLeft className="h-4 w-4" />
)} )}
</Button> </Button>
<Button <Button variant="ghost" size="sm" onClick={() => openEditForm(policy)}>
variant="ghost"
size="sm"
onClick={() => openEditForm(policy)}
>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button <Button variant="ghost" size="sm" onClick={() => handleDelete(policy.id)}>
variant="ghost"
size="sm"
onClick={() => handleDelete(policy.id)}
>
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>
</div> </div>

View File

@ -124,9 +124,16 @@ export default function ProductsPage() {
const product = await res.json(); const product = await res.json();
setCreateOpen(false); setCreateOpen(false);
setForm({ setForm({
productId: '', displayName: '', licensePrefix: '', packageName: '', productId: '',
defaultPlan: 'free', trialDays: '14', websiteUrl: '', displayName: '',
deviceLimitFree: '1', deviceLimitPro: '3', deviceLimitEnterprise: '10', licensePrefix: '',
packageName: '',
defaultPlan: 'free',
trialDays: '14',
websiteUrl: '',
deviceLimitFree: '1',
deviceLimitPro: '3',
deviceLimitEnterprise: '10',
}); });
// Auto-onboard: seed plans + kill_switch flag // Auto-onboard: seed plans + kill_switch flag
await handleOnboard(product.productId); await handleOnboard(product.productId);
@ -230,9 +237,7 @@ export default function ProductsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Products</h1> <h1 className="text-3xl font-bold tracking-tight">Products</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">Manage registered products in the platform</p>
Manage registered products in the platform
</p>
</div> </div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@ -271,7 +276,9 @@ export default function ProductsPage() {
<Input <Input
placeholder="PROD" placeholder="PROD"
value={form.licensePrefix} value={form.licensePrefix}
onChange={e => setForm({ ...form, licensePrefix: e.target.value.toUpperCase() })} onChange={e =>
setForm({ ...form, licensePrefix: e.target.value.toUpperCase() })
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -290,7 +297,9 @@ export default function ProductsPage() {
value={form.defaultPlan} value={form.defaultPlan}
onValueChange={v => setForm({ ...form, defaultPlan: v as 'free' | 'pro' })} onValueChange={v => setForm({ ...form, defaultPlan: v as 'free' | 'pro' })}
> >
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="free">Free</SelectItem> <SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem> <SelectItem value="pro">Pro</SelectItem>
@ -322,7 +331,8 @@ export default function ProductsPage() {
<div> <div>
<p className="text-[10px] text-muted-foreground mb-1">Free</p> <p className="text-[10px] text-muted-foreground mb-1">Free</p>
<Input <Input
type="number" min={0} type="number"
min={0}
value={form.deviceLimitFree} value={form.deviceLimitFree}
onChange={e => setForm({ ...form, deviceLimitFree: e.target.value })} onChange={e => setForm({ ...form, deviceLimitFree: e.target.value })}
/> />
@ -330,7 +340,8 @@ export default function ProductsPage() {
<div> <div>
<p className="text-[10px] text-muted-foreground mb-1">Pro</p> <p className="text-[10px] text-muted-foreground mb-1">Pro</p>
<Input <Input
type="number" min={0} type="number"
min={0}
value={form.deviceLimitPro} value={form.deviceLimitPro}
onChange={e => setForm({ ...form, deviceLimitPro: e.target.value })} onChange={e => setForm({ ...form, deviceLimitPro: e.target.value })}
/> />
@ -338,7 +349,8 @@ export default function ProductsPage() {
<div> <div>
<p className="text-[10px] text-muted-foreground mb-1">Enterprise</p> <p className="text-[10px] text-muted-foreground mb-1">Enterprise</p>
<Input <Input
type="number" min={0} type="number"
min={0}
value={form.deviceLimitEnterprise} value={form.deviceLimitEnterprise}
onChange={e => setForm({ ...form, deviceLimitEnterprise: e.target.value })} onChange={e => setForm({ ...form, deviceLimitEnterprise: e.target.value })}
/> />
@ -367,7 +379,8 @@ export default function ProductsPage() {
Product &quot;{onboardResult.productId}&quot; onboarded successfully Product &quot;{onboardResult.productId}&quot; onboarded successfully
</p> </p>
<p className="text-xs text-emerald-700 dark:text-emerald-300"> <p className="text-xs text-emerald-700 dark:text-emerald-300">
{onboardResult.plans} plans seeded{onboardResult.flags > 0 ? `, ${onboardResult.flags} flag(s) created` : ''} {onboardResult.plans} plans seeded
{onboardResult.flags > 0 ? `, ${onboardResult.flags} flag(s) created` : ''}
</p> </p>
</div> </div>
</div> </div>
@ -403,7 +416,11 @@ export default function ProductsPage() {
: 'bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400' : 'bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400'
} }
> >
{p.status === 'active' ? <Check className="mr-1 h-3 w-3" /> : <X className="mr-1 h-3 w-3" />} {p.status === 'active' ? (
<Check className="mr-1 h-3 w-3" />
) : (
<X className="mr-1 h-3 w-3" />
)}
{p.status} {p.status}
</Badge> </Badge>
<Button <Button
@ -444,7 +461,8 @@ export default function ProductsPage() {
<div>{p.trialDays}</div> <div>{p.trialDays}</div>
<div className="text-muted-foreground">Device Limits</div> <div className="text-muted-foreground">Device Limits</div>
<div> <div>
Free: {p.deviceLimits.free} · Pro: {p.deviceLimits.pro} · Ent: {p.deviceLimits.enterprise} Free: {p.deviceLimits.free} · Pro: {p.deviceLimits.pro} · Ent:{' '}
{p.deviceLimits.enterprise}
</div> </div>
{p.websiteUrl && ( {p.websiteUrl && (
<> <>
@ -488,7 +506,9 @@ export default function ProductsPage() {
value={editForm.status ?? 'active'} value={editForm.status ?? 'active'}
onValueChange={v => setEditForm({ ...editForm, status: v })} onValueChange={v => setEditForm({ ...editForm, status: v })}
> >
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="active">Active</SelectItem> <SelectItem value="active">Active</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem> <SelectItem value="disabled">Disabled</SelectItem>
@ -503,7 +523,9 @@ export default function ProductsPage() {
value={editForm.defaultPlan ?? 'free'} value={editForm.defaultPlan ?? 'free'}
onValueChange={v => setEditForm({ ...editForm, defaultPlan: v })} onValueChange={v => setEditForm({ ...editForm, defaultPlan: v })}
> >
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="free">Free</SelectItem> <SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem> <SelectItem value="pro">Pro</SelectItem>
@ -513,7 +535,9 @@ export default function ProductsPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Trial Days</Label> <Label>Trial Days</Label>
<Input <Input
type="number" min={0} max={365} type="number"
min={0}
max={365}
value={editForm.trialDays ?? '14'} value={editForm.trialDays ?? '14'}
onChange={e => setEditForm({ ...editForm, trialDays: e.target.value })} onChange={e => setEditForm({ ...editForm, trialDays: e.target.value })}
/> />
@ -532,7 +556,8 @@ export default function ProductsPage() {
<div> <div>
<p className="text-[10px] text-muted-foreground mb-1">Free</p> <p className="text-[10px] text-muted-foreground mb-1">Free</p>
<Input <Input
type="number" min={0} type="number"
min={0}
value={editForm.deviceLimitFree ?? '1'} value={editForm.deviceLimitFree ?? '1'}
onChange={e => setEditForm({ ...editForm, deviceLimitFree: e.target.value })} onChange={e => setEditForm({ ...editForm, deviceLimitFree: e.target.value })}
/> />
@ -540,7 +565,8 @@ export default function ProductsPage() {
<div> <div>
<p className="text-[10px] text-muted-foreground mb-1">Pro</p> <p className="text-[10px] text-muted-foreground mb-1">Pro</p>
<Input <Input
type="number" min={0} type="number"
min={0}
value={editForm.deviceLimitPro ?? '3'} value={editForm.deviceLimitPro ?? '3'}
onChange={e => setEditForm({ ...editForm, deviceLimitPro: e.target.value })} onChange={e => setEditForm({ ...editForm, deviceLimitPro: e.target.value })}
/> />
@ -548,9 +574,12 @@ export default function ProductsPage() {
<div> <div>
<p className="text-[10px] text-muted-foreground mb-1">Enterprise</p> <p className="text-[10px] text-muted-foreground mb-1">Enterprise</p>
<Input <Input
type="number" min={0} type="number"
min={0}
value={editForm.deviceLimitEnterprise ?? '10'} value={editForm.deviceLimitEnterprise ?? '10'}
onChange={e => setEditForm({ ...editForm, deviceLimitEnterprise: e.target.value })} onChange={e =>
setEditForm({ ...editForm, deviceLimitEnterprise: e.target.value })
}
/> />
</div> </div>
</div> </div>

View File

@ -30,7 +30,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { apiListPromos, apiCreatePromo, apiDeletePromo, apiUpdatePromo, type ApiPromo } from '@/lib/api'; import {
apiListPromos,
apiCreatePromo,
apiDeletePromo,
apiUpdatePromo,
type ApiPromo,
} from '@/lib/api';
function formatDate(iso: string) { function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', { return new Date(iso).toLocaleDateString('en-US', {
@ -69,7 +75,8 @@ export default function PromosPage() {
const handleToggleActive = async (promo: ApiPromo) => { const handleToggleActive = async (promo: ApiPromo) => {
const { data } = await apiUpdatePromo(promo.id, { active: !promo.active }); const { data } = await apiUpdatePromo(promo.id, { active: !promo.active });
if (data) setPromos(prev => prev.map(p => p.id === promo.id ? { ...p, active: !p.active } : p)); if (data)
setPromos(prev => prev.map(p => (p.id === promo.id ? { ...p, active: !p.active } : p)));
}; };
const loadPromos = useCallback(async () => { const loadPromos = useCallback(async () => {

View File

@ -435,10 +435,13 @@ export default function SettingsPage() {
<CardTitle className="text-base">Azure Configuration</CardTitle> <CardTitle className="text-base">Azure Configuration</CardTitle>
<CardDescription> <CardDescription>
Azure secrets are managed via the{' '} Azure secrets are managed via the{' '}
<a href="/ops/secrets" className="text-primary underline underline-offset-2 hover:text-primary/80"> <a
href="/ops/secrets"
className="text-primary underline underline-offset-2 hover:text-primary/80"
>
Secrets Manager Secrets Manager
</a> </a>{' '}
{' '}(Key Vault) (Key Vault)
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@ -449,8 +452,8 @@ export default function SettingsPage() {
in Azure Key Vault and resolved at runtime. Use the{' '} in Azure Key Vault and resolved at runtime. Use the{' '}
<a href="/ops/secrets" className="text-primary underline underline-offset-2"> <a href="/ops/secrets" className="text-primary underline underline-offset-2">
Secrets Manager Secrets Manager
</a> </a>{' '}
{' '}to view, rotate, or update them. to view, rotate, or update them.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -473,7 +476,12 @@ export default function SettingsPage() {
<Input <Input
type="number" type="number"
value={settings.rateLimits.globalPerMin} value={settings.rateLimits.globalPerMin}
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, globalPerMin: parseInt(e.target.value) || 0 } }))} onChange={e =>
setSettings(s => ({
...s,
rateLimits: { ...s.rateLimits, globalPerMin: parseInt(e.target.value) || 0 },
}))
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -481,7 +489,12 @@ export default function SettingsPage() {
<Input <Input
type="number" type="number"
value={settings.rateLimits.perUserPerMin} value={settings.rateLimits.perUserPerMin}
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, perUserPerMin: parseInt(e.target.value) || 0 } }))} onChange={e =>
setSettings(s => ({
...s,
rateLimits: { ...s.rateLimits, perUserPerMin: parseInt(e.target.value) || 0 },
}))
}
/> />
</div> </div>
</div> </div>
@ -491,7 +504,12 @@ export default function SettingsPage() {
<Input <Input
type="number" type="number"
value={settings.rateLimits.maxTokenBurst} value={settings.rateLimits.maxTokenBurst}
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, maxTokenBurst: parseInt(e.target.value) || 0 } }))} onChange={e =>
setSettings(s => ({
...s,
rateLimits: { ...s.rateLimits, maxTokenBurst: parseInt(e.target.value) || 0 },
}))
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -499,7 +517,12 @@ export default function SettingsPage() {
<Input <Input
type="number" type="number"
value={settings.rateLimits.abuseThreshold} value={settings.rateLimits.abuseThreshold}
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, abuseThreshold: parseInt(e.target.value) || 0 } }))} onChange={e =>
setSettings(s => ({
...s,
rateLimits: { ...s.rateLimits, abuseThreshold: parseInt(e.target.value) || 0 },
}))
}
/> />
</div> </div>
</div> </div>
@ -513,7 +536,9 @@ export default function SettingsPage() {
</div> </div>
<Switch <Switch
checked={settings.rateLimits.autoSuspendOnAbuse} checked={settings.rateLimits.autoSuspendOnAbuse}
onCheckedChange={v => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, autoSuspendOnAbuse: v } }))} onCheckedChange={v =>
setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, autoSuspendOnAbuse: v } }))
}
/> />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -525,7 +550,9 @@ export default function SettingsPage() {
</div> </div>
<Switch <Switch
checked={settings.rateLimits.ipBlocklist} checked={settings.rateLimits.ipBlocklist}
onCheckedChange={v => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, ipBlocklist: v } }))} onCheckedChange={v =>
setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, ipBlocklist: v } }))
}
/> />
</div> </div>
</CardContent> </CardContent>
@ -552,7 +579,12 @@ export default function SettingsPage() {
</div> </div>
<Switch <Switch
checked={settings.notifications.newUserSignup} checked={settings.notifications.newUserSignup}
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, newUserSignup: v } }))} onCheckedChange={v =>
setSettings(s => ({
...s,
notifications: { ...s.notifications, newUserSignup: v },
}))
}
/> />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -564,7 +596,12 @@ export default function SettingsPage() {
</div> </div>
<Switch <Switch
checked={settings.notifications.usageThreshold} checked={settings.notifications.usageThreshold}
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, usageThreshold: v } }))} onCheckedChange={v =>
setSettings(s => ({
...s,
notifications: { ...s.notifications, usageThreshold: v },
}))
}
/> />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -576,7 +613,12 @@ export default function SettingsPage() {
</div> </div>
<Switch <Switch
checked={settings.notifications.failedPayment} checked={settings.notifications.failedPayment}
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, failedPayment: v } }))} onCheckedChange={v =>
setSettings(s => ({
...s,
notifications: { ...s.notifications, failedPayment: v },
}))
}
/> />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -588,7 +630,12 @@ export default function SettingsPage() {
</div> </div>
<Switch <Switch
checked={settings.notifications.securityAlerts} checked={settings.notifications.securityAlerts}
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, securityAlerts: v } }))} onCheckedChange={v =>
setSettings(s => ({
...s,
notifications: { ...s.notifications, securityAlerts: v },
}))
}
/> />
</div> </div>
</CardContent> </CardContent>
@ -620,7 +667,9 @@ export default function SettingsPage() {
<Input <Input
type="number" type="number"
value={settings.dataRetentionDays} value={settings.dataRetentionDays}
onChange={e => setSettings(s => ({ ...s, dataRetentionDays: parseInt(e.target.value) || 365 }))} onChange={e =>
setSettings(s => ({ ...s, dataRetentionDays: parseInt(e.target.value) || 365 }))
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@ -71,10 +71,7 @@ export default function SubscriptionsPage() {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
try { try {
const [plansRes, usersRes] = await Promise.allSettled([ const [plansRes, usersRes] = await Promise.allSettled([apiListPlans(), apiListUsers()]);
apiListPlans(),
apiListUsers(),
]);
let loadedPlans: LocalPlan[] = []; let loadedPlans: LocalPlan[] = [];
if (plansRes.status === 'fulfilled' && plansRes.value.data?.plans?.length) { if (plansRes.status === 'fulfilled' && plansRes.value.data?.plans?.length) {
loadedPlans = plansRes.value.data.plans.filter(p => p.active).map(planDocToLocal); loadedPlans = plansRes.value.data.plans.filter(p => p.active).map(planDocToLocal);
@ -238,7 +235,9 @@ export default function SubscriptionsPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{totalActiveUsers > 0 ? formatCurrency(totalMRR / totalActiveUsers) : '$0.00'}</div> <div className="text-2xl font-bold">
{totalActiveUsers > 0 ? formatCurrency(totalMRR / totalActiveUsers) : '$0.00'}
</div>
<p className="text-xs text-muted-foreground mt-1">ARPU</p> <p className="text-xs text-muted-foreground mt-1">ARPU</p>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -50,7 +50,13 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { mockUsers, formatNumber, formatCurrency, formatDate, type User } from '@/lib/mock-data'; import { mockUsers, formatNumber, formatCurrency, formatDate, type User } from '@/lib/mock-data';
import { apiListUsers, apiUpdateUser, apiDeleteUser, apiCreateInvitation, type ApiUser } from '@/lib/api'; import {
apiListUsers,
apiUpdateUser,
apiDeleteUser,
apiCreateInvitation,
type ApiUser,
} from '@/lib/api';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/toast'; import { useToast } from '@/components/ui/toast';
@ -127,10 +133,15 @@ export default function UsersPage() {
}); });
setInviteCreating(false); setInviteCreating(false);
if (data) { if (data) {
const userDashboardUrl = process.env.NEXT_PUBLIC_USER_DASHBOARD_URL || 'http://localhost:3002'; const userDashboardUrl =
process.env.NEXT_PUBLIC_USER_DASHBOARD_URL || 'http://localhost:3002';
setInviteLink(`${userDashboardUrl}/login?ref=${encodeURIComponent(data.code)}`); setInviteLink(`${userDashboardUrl}/login?ref=${encodeURIComponent(data.code)}`);
} else { } else {
toast({ title: 'Failed to create invite', description: error || 'Unknown error', variant: 'error' }); toast({
title: 'Failed to create invite',
description: error || 'Unknown error',
variant: 'error',
});
} }
}; };
@ -160,7 +171,10 @@ export default function UsersPage() {
setUsers(prev => setUsers(prev =>
prev.map(u => (u.id === user.id ? { ...u, status: newStatus as User['status'] } : u)) prev.map(u => (u.id === user.id ? { ...u, status: newStatus as User['status'] } : u))
); );
toast({ title: `User ${newStatus === 'suspended' ? 'suspended' : 'activated'}`, variant: newStatus === 'suspended' ? 'warning' : 'success' }); toast({
title: `User ${newStatus === 'suspended' ? 'suspended' : 'activated'}`,
variant: newStatus === 'suspended' ? 'warning' : 'success',
});
} else { } else {
toast({ title: 'Action failed', description: error, variant: 'error' }); toast({ title: 'Action failed', description: error, variant: 'error' });
} }
@ -232,13 +246,15 @@ export default function UsersPage() {
<h1 className="text-3xl font-bold tracking-tight">Users</h1> <h1 className="text-3xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">Manage platform users and their subscriptions</p> <p className="text-muted-foreground">Manage platform users and their subscriptions</p>
</div> </div>
<Button onClick={() => { <Button
setShowInvite(true); onClick={() => {
setInviteLink(null); setShowInvite(true);
setInviteDescription(''); setInviteLink(null);
setInvitePlan('pro'); setInviteDescription('');
setInviteCopied(false); setInvitePlan('pro');
}}> setInviteCopied(false);
}}
>
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
Invite User Invite User
</Button> </Button>
@ -434,9 +450,24 @@ export default function UsersPage() {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleChangePlan(user.id, user.plan === 'free' ? 'pro' : user.plan === 'pro' ? 'enterprise' : 'free')} onClick={() =>
handleChangePlan(
user.id,
user.plan === 'free'
? 'pro'
: user.plan === 'pro'
? 'enterprise'
: 'free'
)
}
> >
Cycle Plan ({user.plan} {user.plan === 'free' ? 'pro' : user.plan === 'pro' ? 'enterprise' : 'free'}) Cycle Plan ({user.plan} {' '}
{user.plan === 'free'
? 'pro'
: user.plan === 'pro'
? 'enterprise'
: 'free'}
)
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
@ -578,12 +609,7 @@ export default function UsersPage() {
<code className="flex-1 text-sm font-mono break-all select-all"> <code className="flex-1 text-sm font-mono break-all select-all">
{inviteLink} {inviteLink}
</code> </code>
<Button <Button variant="ghost" size="icon" className="shrink-0" onClick={copyInviteLink}>
variant="ghost"
size="icon"
className="shrink-0"
onClick={copyInviteLink}
>
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</div> </div>

View File

@ -13,10 +13,7 @@ function getSecretClient(): SecretClient {
} }
/** GET /api/ops/secrets/[name] — read a specific secret value */ /** GET /api/ops/secrets/[name] — read a specific secret value */
export async function GET( export async function GET(_req: NextRequest, { params }: { params: Promise<{ name: string }> }) {
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
try { try {
const { name } = await params; const { name } = await params;
const client = getSecretClient(); const client = getSecretClient();
@ -41,10 +38,7 @@ export async function GET(
} }
/** DELETE /api/ops/secrets/[name] — soft-delete a secret */ /** DELETE /api/ops/secrets/[name] — soft-delete a secret */
export async function DELETE( export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ name: string }> }) {
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
try { try {
const { name } = await params; const { name } = await params;
const client = getSecretClient(); const client = getSecretClient();
@ -55,7 +49,7 @@ export async function DELETE(
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) }, { error: err instanceof Error ? err.message : String(err) },
{ status: 500 }, { status: 500 }
); );
} }
} }

View File

@ -49,7 +49,7 @@ export async function GET() {
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) }, { error: err instanceof Error ? err.message : String(err) },
{ status: 500 }, { status: 500 }
); );
} }
} }
@ -67,10 +67,7 @@ export async function POST(req: NextRequest) {
}; };
if (!name || !value) { if (!name || !value) {
return NextResponse.json( return NextResponse.json({ error: 'name and value are required' }, { status: 400 });
{ error: 'name and value are required' },
{ status: 400 },
);
} }
const client = getSecretClient(); const client = getSecretClient();
@ -89,7 +86,7 @@ export async function POST(req: NextRequest) {
} catch (err) { } catch (err) {
return NextResponse.json( return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) }, { error: err instanceof Error ? err.message : String(err) },
{ status: 500 }, { status: 500 }
); );
} }
} }

View File

@ -34,7 +34,10 @@ function parseInfoValue(info: string, key: string): string | undefined {
return line?.split(':').slice(1).join(':'); return line?.split(':').slice(1).join(':');
} }
async function getPreview(client: ReturnType<typeof createClient>, key: string): Promise<ValkeyPreview> { async function getPreview(
client: ReturnType<typeof createClient>,
key: string
): Promise<ValkeyPreview> {
const [type, ttlSeconds] = await Promise.all([client.type(key), client.ttl(key)]); const [type, ttlSeconds] = await Promise.all([client.type(key), client.ttl(key)]);
if (type === 'string') { if (type === 'string') {
@ -76,7 +79,10 @@ async function getPreview(client: ReturnType<typeof createClient>, key: string):
} }
if (type === 'zset') { if (type === 'zset') {
const [size, entries] = await Promise.all([client.zCard(key), client.zRangeWithScores(key, 0, 4)]); const [size, entries] = await Promise.all([
client.zCard(key),
client.zRangeWithScores(key, 0, 4),
]);
return { return {
key, key,
type, type,
@ -142,7 +148,10 @@ export async function GET(req: NextRequest) {
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unable to inspect Valkey'; const message = error instanceof Error ? error.message : 'Unable to inspect Valkey';
return NextResponse.json({ error: message }, { status: message === 'Unauthorized' ? 401 : 500 }); return NextResponse.json(
{ error: message },
{ status: message === 'Unauthorized' ? 401 : 500 }
);
} }
} }
@ -215,6 +224,9 @@ export async function POST(req: NextRequest) {
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Valkey write failed'; const message = error instanceof Error ? error.message : 'Valkey write failed';
return NextResponse.json({ error: message }, { status: message === 'Unauthorized' ? 401 : 500 }); return NextResponse.json(
{ error: message },
{ status: message === 'Unauthorized' ? 401 : 500 }
);
} }
} }

View File

@ -76,7 +76,10 @@ export async function PUT(req: NextRequest) {
const reason = typeof body.reason === 'string' ? body.reason : ''; const reason = typeof body.reason === 'string' ? body.reason : '';
const platforms: PlatformFlags = body.platforms ?? { const platforms: PlatformFlags = body.platforms ?? {
desktop: true, ios: true, android: true, web: true, desktop: true,
ios: true,
android: true,
web: true,
}; };
const result = await listFlags(); const result = await listFlags();

View File

@ -8,10 +8,7 @@ function getJwt(req: NextRequest): string {
return req.headers.get('authorization')?.replace('Bearer ', '') ?? ''; return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
} }
export async function PATCH( export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const jwt = getJwt(req); const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

View File

@ -1,8 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { import { updateTelemetryPolicy, deleteTelemetryPolicy } from '@/lib/platform-client';
updateTelemetryPolicy,
deleteTelemetryPolicy,
} from '@/lib/platform-client';
function getJwt(req: NextRequest): string { function getJwt(req: NextRequest): string {
const cookie = req.headers.get('cookie') ?? ''; const cookie = req.headers.get('cookie') ?? '';
@ -11,10 +8,7 @@ function getJwt(req: NextRequest): string {
return req.headers.get('authorization')?.replace('Bearer ', '') ?? ''; return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
} }
export async function PUT( export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const jwt = getJwt(req); const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@ -28,10 +22,7 @@ export async function PUT(
} }
} }
export async function DELETE( export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const jwt = getJwt(req); const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

View File

@ -1,8 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { import { listTelemetryPolicies, createTelemetryPolicy } from '@/lib/platform-client';
listTelemetryPolicies,
createTelemetryPolicy,
} from '@/lib/platform-client';
function getJwt(req: NextRequest): string { function getJwt(req: NextRequest): string {
const cookie = req.headers.get('cookie') ?? ''; const cookie = req.headers.get('cookie') ?? '';

View File

@ -1,58 +1,48 @@
import * as React from "react" import * as React from 'react';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{ {
variants: { variants: {
variant: { variant: {
default: "bg-background text-foreground", default: 'bg-background text-foreground',
destructive: destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
} }
) );
const Alert = React.forwardRef< const Alert = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => ( >(({ className, variant, ...props }, ref) => (
<div <div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
ref={ref} ));
role="alert" Alert.displayName = 'Alert';
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef< const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
HTMLParagraphElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLHeadingElement> <h5
>(({ className, ...props }, ref) => ( ref={ref}
<h5 className={cn('mb-1 font-medium leading-none tracking-tight', className)}
ref={ref} {...props}
className={cn("mb-1 font-medium leading-none tracking-tight", className)} />
{...props} )
/> );
)) AlertTitle.displayName = 'AlertTitle';
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef< const AlertDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
ref={ref} ));
className={cn("text-sm [&_p]:leading-relaxed", className)} AlertDescription.displayName = 'AlertDescription';
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription };

View File

@ -1,13 +1,12 @@
'use client' 'use client';
import * as React from 'react' import * as React from 'react';
import { Check } from 'lucide-react' import { Check } from 'lucide-react';
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils';
export interface CheckboxProps export interface CheckboxProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
extends React.ButtonHTMLAttributes<HTMLButtonElement> { checked?: boolean;
checked?: boolean onCheckedChange?: (checked: boolean) => void;
onCheckedChange?: (checked: boolean) => void
} }
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>( const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
@ -28,7 +27,7 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
{checked && <Check className="h-4 w-4" />} {checked && <Check className="h-4 w-4" />}
</button> </button>
) )
) );
Checkbox.displayName = 'Checkbox' Checkbox.displayName = 'Checkbox';
export { Checkbox } export { Checkbox };

View File

@ -11,10 +11,7 @@ const Slider = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SliderPrimitive.Root <SliderPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn('relative flex w-full touch-none select-none items-center', className)}
'relative flex w-full touch-none select-none items-center',
className
)}
{...props} {...props}
> >
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"> <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">

View File

@ -597,7 +597,9 @@ export async function apiGetBroadcast(id: string) {
return apiFetch<ApiBroadcast>(`/admin/broadcasts/${id}`); return apiFetch<ApiBroadcast>(`/admin/broadcasts/${id}`);
} }
export async function apiCreateBroadcast(body: Omit<ApiBroadcast, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>) { export async function apiCreateBroadcast(
body: Omit<ApiBroadcast, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>
) {
return apiFetch<ApiBroadcast>('/admin/broadcasts', { return apiFetch<ApiBroadcast>('/admin/broadcasts', {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
@ -661,7 +663,16 @@ export interface ApiSurvey {
description?: string; description?: string;
questions: { questions: {
id: string; id: string;
type: 'single_choice' | 'multiple_choice' | 'rating' | 'nps' | 'text_short' | 'text_long' | 'dropdown' | 'scale' | 'ranking'; type:
| 'single_choice'
| 'multiple_choice'
| 'rating'
| 'nps'
| 'text_short'
| 'text_long'
| 'dropdown'
| 'scale'
| 'ranking';
text: string; text: string;
description?: string; description?: string;
required: boolean; required: boolean;
@ -676,7 +687,11 @@ export interface ApiSurvey {
status: 'draft' | 'active' | 'paused' | 'closed'; status: 'draft' | 'active' | 'paused' | 'closed';
startsAt?: string; startsAt?: string;
endsAt?: string; endsAt?: string;
displayTrigger: { type: 'immediate' } | { type: 'delay_seconds'; seconds: number } | { type: 'event'; eventName: string } | { type: 'page_view'; pagePattern: string }; displayTrigger:
| { type: 'immediate' }
| { type: 'delay_seconds'; seconds: number }
| { type: 'event'; eventName: string }
| { type: 'page_view'; pagePattern: string };
incentive?: { type: 'pro_days' | 'credits'; amount: number }; incentive?: { type: 'pro_days' | 'credits'; amount: number };
metrics: { metrics: {
impressions: number; impressions: number;
@ -733,7 +748,9 @@ export async function apiGetSurvey(id: string) {
return apiFetch<ApiSurvey>(`/admin/surveys/${id}`); return apiFetch<ApiSurvey>(`/admin/surveys/${id}`);
} }
export async function apiCreateSurvey(body: Omit<ApiSurvey, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>) { export async function apiCreateSurvey(
body: Omit<ApiSurvey, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>
) {
return apiFetch<ApiSurvey>('/admin/surveys', { return apiFetch<ApiSurvey>('/admin/surveys', {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
@ -759,12 +776,17 @@ export async function apiPauseSurvey(id: string) {
return apiFetch<{ success: boolean }>(`/admin/surveys/${id}/pause`, { method: 'POST' }); return apiFetch<{ success: boolean }>(`/admin/surveys/${id}/pause`, { method: 'POST' });
} }
export async function apiGetSurveyResponses(id: string, options?: { isComplete?: boolean; limit?: number; offset?: number }) { export async function apiGetSurveyResponses(
id: string,
options?: { isComplete?: boolean; limit?: number; offset?: number }
) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options?.isComplete !== undefined) params.set('isComplete', String(options.isComplete)); if (options?.isComplete !== undefined) params.set('isComplete', String(options.isComplete));
if (options?.limit) params.set('limit', String(options.limit)); if (options?.limit) params.set('limit', String(options.limit));
if (options?.offset) params.set('offset', String(options.offset)); if (options?.offset) params.set('offset', String(options.offset));
return apiFetch<{ responses: ApiSurveyResponse[]; total: number }>(`/admin/surveys/${id}/responses?${params}`); return apiFetch<{ responses: ApiSurveyResponse[]; total: number }>(
`/admin/surveys/${id}/responses?${params}`
);
} }
export async function apiGetSurveyRespondents(id: string) { export async function apiGetSurveyRespondents(id: string) {

View File

@ -58,7 +58,9 @@ export async function updateSubscription(
// ── Usage ─────────────────────────────────────────────────────── // ── Usage ───────────────────────────────────────────────────────
export async function listUsage(options: { userId?: string; days?: number; limit?: number; productId?: string } = {}) { export async function listUsage(
options: { userId?: string; days?: number; limit?: number; productId?: string } = {}
) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options.userId) params.set('userId', options.userId); if (options.userId) params.set('userId', options.userId);
if (options.days) params.set('days', String(options.days)); if (options.days) params.set('days', String(options.days));

View File

@ -180,7 +180,9 @@ export function createDiagnosticsClient(config: DiagnosticsClientConfig) {
if (options.limit) params.set('limit', options.limit.toString()); if (options.limit) params.set('limit', options.limit.toString());
if (options.offset) params.set('offset', options.offset.toString()); if (options.offset) params.set('offset', options.offset.toString());
const result = await client.safeFetch<QuerySessionsResult>(`/api/diagnostics/sessions?${params.toString()}`); const result = await client.safeFetch<QuerySessionsResult>(
`/api/diagnostics/sessions?${params.toString()}`
);
if (result.error) throw new Error(result.error); if (result.error) throw new Error(result.error);
return result.data!; return result.data!;
}, },
@ -202,11 +204,14 @@ export function createDiagnosticsClient(config: DiagnosticsClientConfig) {
}, },
async updateSession(sessionId: string, request: UpdateSessionRequest): Promise<DebugSession> { async updateSession(sessionId: string, request: UpdateSessionRequest): Promise<DebugSession> {
const result = await client.safeFetch<DebugSession>(`/api/diagnostics/sessions/${sessionId}`, { const result = await client.safeFetch<DebugSession>(
method: 'PATCH', `/api/diagnostics/sessions/${sessionId}`,
headers: { 'Content-Type': 'application/json' }, {
body: JSON.stringify(request), method: 'PATCH',
}); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
}
);
if (result.error) throw new Error(result.error); if (result.error) throw new Error(result.error);
return result.data!; return result.data!;
}, },
@ -267,7 +272,10 @@ export function createDiagnosticsClient(config: DiagnosticsClientConfig) {
// Screenshots // Screenshots
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
async getScreenshots(sessionId: string, productId: string): Promise< async getScreenshots(
sessionId: string,
productId: string
): Promise<
Array<{ Array<{
id: string; id: string;
blobUrl: string; blobUrl: string;

View File

@ -54,7 +54,10 @@ export async function restartServiceContainer(serviceId: string): Promise<{ cont
throw new Error('Service is not restartable from admin ops'); throw new Error('Service is not restartable from admin ops');
} }
const response = await dockerRequest('POST', `/containers/${encodeURIComponent(container)}/restart?t=10`); const response = await dockerRequest(
'POST',
`/containers/${encodeURIComponent(container)}/restart?t=10`
);
if (![204, 304].includes(response.statusCode)) { if (![204, 304].includes(response.statusCode)) {
throw new Error(response.body || `Docker restart failed with status ${response.statusCode}`); throw new Error(response.body || `Docker restart failed with status ${response.statusCode}`);
} }

View File

@ -48,7 +48,7 @@ export interface ExtractionTask {
export async function extractText( export async function extractText(
text: string, text: string,
taskId?: string, taskId?: string,
modelId?: string, modelId?: string
): Promise<ExtractResponse | null> { ): Promise<ExtractResponse | null> {
try { try {
return await extractionApi.fetch<ExtractResponse>('/extract', { return await extractionApi.fetch<ExtractResponse>('/extract', {
@ -66,7 +66,7 @@ export async function extractTranscript(text: string): Promise<ExtractResponse |
export async function extractBatch( export async function extractBatch(
inputs: Array<{ text: string; taskId?: string }>, inputs: Array<{ text: string; taskId?: string }>,
modelId?: string, modelId?: string
): Promise<ExtractResponse[] | null> { ): Promise<ExtractResponse[] | null> {
try { try {
const result = await extractionApi.fetch<{ results: ExtractResponse[] }>('/extract/batch', { const result = await extractionApi.fetch<{ results: ExtractResponse[] }>('/extract/batch', {
@ -102,7 +102,9 @@ export async function getTask(id: string): Promise<ExtractionTask | null> {
export async function getSidecarHealth(): Promise<{ status: string; sidecar?: unknown } | null> { export async function getSidecarHealth(): Promise<{ status: string; sidecar?: unknown } | null> {
try { try {
return await extractionApi.fetch<{ status: string; sidecar?: unknown }>('/extract/sidecar-health'); return await extractionApi.fetch<{ status: string; sidecar?: unknown }>(
'/extract/sidecar-health'
);
} catch { } catch {
return null; return null;
} }

View File

@ -60,7 +60,9 @@ export async function getProductHealthDetail(productId: string): Promise<Product
} }
export async function getHealthTrends(productId: string, days = 30): Promise<ProductHealth[]> { export async function getHealthTrends(productId: string, days = 30): Promise<ProductHealth[]> {
return predictiveApi.fetch<ProductHealth[]>(`/predictive/health/${productId}/trends?days=${days}`); return predictiveApi.fetch<ProductHealth[]>(
`/predictive/health/${productId}/trends?days=${days}`
);
} }
// ── Churn Prediction ───────────────────────────────────────── // ── Churn Prediction ─────────────────────────────────────────
@ -89,7 +91,11 @@ export interface ChurnPrediction {
}; };
} }
export async function getChurnScore(userId: string, productId: string, horizon = 30): Promise<ChurnPrediction> { export async function getChurnScore(
userId: string,
productId: string,
horizon = 30
): Promise<ChurnPrediction> {
return predictiveApi.fetch<ChurnPrediction>('/predictive/churn-score', { return predictiveApi.fetch<ChurnPrediction>('/predictive/churn-score', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId, productId, horizon: String(horizon) }), body: JSON.stringify({ userId, productId, horizon: String(horizon) }),
@ -106,18 +112,22 @@ export interface AtRiskUser {
predictionTimestamp: string; predictionTimestamp: string;
} }
export async function getAtRiskUsers(options: { export async function getAtRiskUsers(
productId?: string; options: {
segment?: RiskSegment; productId?: string;
limit?: number; segment?: RiskSegment;
offset?: number; limit?: number;
} = {}): Promise<{ users: AtRiskUser[]; total: number }> { offset?: number;
} = {}
): Promise<{ users: AtRiskUser[]; total: number }> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options.productId) params.set('productId', options.productId); if (options.productId) params.set('productId', options.productId);
if (options.segment) params.set('segment', options.segment); if (options.segment) params.set('segment', options.segment);
if (options.limit) params.set('limit', String(options.limit)); if (options.limit) params.set('limit', String(options.limit));
if (options.offset) params.set('offset', String(options.offset)); if (options.offset) params.set('offset', String(options.offset));
return predictiveApi.fetch<{ users: AtRiskUser[]; total: number }>(`/predictive/at-risk-users?${params}`); return predictiveApi.fetch<{ users: AtRiskUser[]; total: number }>(
`/predictive/at-risk-users?${params}`
);
} }
export interface UserRiskProfile extends ChurnPrediction { export interface UserRiskProfile extends ChurnPrediction {
@ -149,8 +159,12 @@ export async function getModelPerformance(): Promise<ModelPerformance> {
return predictiveApi.fetch<ModelPerformance>('/predictive/model/performance'); return predictiveApi.fetch<ModelPerformance>('/predictive/model/performance');
} }
export async function getFeatureImportance(): Promise<Array<{ feature: string; importance: number }>> { export async function getFeatureImportance(): Promise<
const res = await predictiveApi.fetch<{ features: Array<{ feature: string; importance: number }> }>('/predictive/model/features'); Array<{ feature: string; importance: number }>
> {
const res = await predictiveApi.fetch<{
features: Array<{ feature: string; importance: number }>;
}>('/predictive/model/features');
return res.features; return res.features;
} }
@ -242,7 +256,10 @@ export async function getCampaignStats(id: string): Promise<Campaign['stats']> {
return predictiveApi.fetch<Campaign['stats']>(`/predictive/campaigns/${id}/stats`); return predictiveApi.fetch<Campaign['stats']>(`/predictive/campaigns/${id}/stats`);
} }
export async function triggerCampaign(id: string, testUserId?: string): Promise<{ triggered: number }> { export async function triggerCampaign(
id: string,
testUserId?: string
): Promise<{ triggered: number }> {
return predictiveApi.fetch<{ triggered: number }>(`/predictive/campaigns/${id}/trigger`, { return predictiveApi.fetch<{ triggered: number }>(`/predictive/campaigns/${id}/trigger`, {
method: 'POST', method: 'POST',
body: JSON.stringify(testUserId ? { testUserId } : {}), body: JSON.stringify(testUserId ? { testUserId } : {}),