179 lines
5.6 KiB
TypeScript
179 lines
5.6 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useState } from 'react';
|
||
|
|
import { apiFetch } from '@/lib/api';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
|
||
|
|
interface Org {
|
||
|
|
id: string;
|
||
|
|
slug: string;
|
||
|
|
name: string;
|
||
|
|
plan: string;
|
||
|
|
monthlyCallQuota: number;
|
||
|
|
callsThisPeriod: number;
|
||
|
|
periodStartsAt: string;
|
||
|
|
createdAt: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Member {
|
||
|
|
id: string;
|
||
|
|
userId: string;
|
||
|
|
email: string;
|
||
|
|
name: string | null;
|
||
|
|
role: string;
|
||
|
|
createdAt: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function SettingsPage() {
|
||
|
|
const [org, setOrg] = useState<Org | null>(null);
|
||
|
|
const [members, setMembers] = useState<Member[]>([]);
|
||
|
|
const [err, setErr] = useState<string | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
apiFetch<{ org: Org; members: Member[] }>('/v1/me/org')
|
||
|
|
.then((r) => {
|
||
|
|
setOrg(r.org);
|
||
|
|
setMembers(r.members);
|
||
|
|
})
|
||
|
|
.catch((e) => setErr((e as Error).message));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
if (err?.includes('401')) {
|
||
|
|
if (typeof window !== 'undefined') window.location.href = '/login';
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mx-auto max-w-5xl px-6 py-8">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-[22px] font-semibold tracking-tight">Settings</h1>
|
||
|
|
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||
|
|
Organization, plan, members, billing.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{!org && <div className="mt-6 text-[12.5px] text-[--color-fg-muted]">Loading…</div>}
|
||
|
|
|
||
|
|
{org && (
|
||
|
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||
|
|
<Card title="Organization">
|
||
|
|
<Row label="Name" value={org.name} />
|
||
|
|
<Row label="Slug" value={org.slug} mono />
|
||
|
|
<Row label="Created" value={new Date(org.createdAt).toLocaleString()} />
|
||
|
|
<Row label="Organization ID" value={org.id} mono small />
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card title="Plan & usage">
|
||
|
|
<Row label="Plan" value={org.plan.charAt(0).toUpperCase() + org.plan.slice(1)} />
|
||
|
|
<Row
|
||
|
|
label="Calls this period"
|
||
|
|
value={`${org.callsThisPeriod.toLocaleString()} / ${org.monthlyCallQuota.toLocaleString()}`}
|
||
|
|
/>
|
||
|
|
<Row
|
||
|
|
label="Period started"
|
||
|
|
value={new Date(org.periodStartsAt).toLocaleString()}
|
||
|
|
/>
|
||
|
|
<div className="mt-3">
|
||
|
|
<Button variant="secondary" size="sm" disabled>
|
||
|
|
Manage billing (Sprint 4)
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card title="Members" className="md:col-span-2">
|
||
|
|
<table className="w-full text-[12.5px]">
|
||
|
|
<thead className="border-b border-[--color-border] text-[--color-fg-subtle]">
|
||
|
|
<tr>
|
||
|
|
<th className="py-2 text-left font-medium">Email</th>
|
||
|
|
<th className="py-2 text-left font-medium">Name</th>
|
||
|
|
<th className="py-2 text-left font-medium">Role</th>
|
||
|
|
<th className="py-2 text-left font-medium">Joined</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{members.map((m) => (
|
||
|
|
<tr key={m.id} className="border-b border-[--color-border] last:border-0">
|
||
|
|
<td className="py-2.5 mono">{m.email}</td>
|
||
|
|
<td className="py-2.5 text-[--color-fg-muted]">{m.name ?? '—'}</td>
|
||
|
|
<td className="py-2.5">
|
||
|
|
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2 py-0.5 text-[11px]">
|
||
|
|
{m.role}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td className="py-2.5 text-[--color-fg-muted]">
|
||
|
|
{new Date(m.createdAt).toLocaleDateString()}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
<div className="mt-3">
|
||
|
|
<Button variant="secondary" size="sm" disabled>
|
||
|
|
Invite member (Team plan)
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card title="API keys">
|
||
|
|
<p className="text-[12.5px] text-[--color-fg-muted]">
|
||
|
|
Programmatic access for the upcoming <span className="mono">bmm</span> CLI.
|
||
|
|
</p>
|
||
|
|
<div className="mt-3">
|
||
|
|
<Button variant="secondary" size="sm" disabled>
|
||
|
|
Generate key (Sprint 4)
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card title="Encryption">
|
||
|
|
<Row label="Secret storage" value="AES-256-GCM" mono />
|
||
|
|
<Row label="Key source" value="env (SECRETS_ENCRYPTION_KEY)" mono />
|
||
|
|
<p className="mt-3 text-[12px] leading-relaxed text-[--color-fg-subtle]">
|
||
|
|
Secrets are encrypted before write to Postgres, decrypted only at the moment of
|
||
|
|
container env injection. Plaintext is never logged.
|
||
|
|
</p>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function Card({
|
||
|
|
title,
|
||
|
|
children,
|
||
|
|
className,
|
||
|
|
}: {
|
||
|
|
title: string;
|
||
|
|
children: React.ReactNode;
|
||
|
|
className?: string;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<div className={`panel p-4 ${className ?? ''}`}>
|
||
|
|
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{title}</div>
|
||
|
|
<div className="mt-3 space-y-1.5">{children}</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function Row({
|
||
|
|
label,
|
||
|
|
value,
|
||
|
|
mono,
|
||
|
|
small,
|
||
|
|
}: {
|
||
|
|
label: string;
|
||
|
|
value: string;
|
||
|
|
mono?: boolean;
|
||
|
|
small?: boolean;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<div className="flex items-baseline justify-between gap-3 text-[12.5px]">
|
||
|
|
<span className="text-[--color-fg-subtle]">{label}</span>
|
||
|
|
<span className={`${mono ? 'mono' : ''} ${small ? 'text-[11px]' : ''} text-[--color-fg]`}>
|
||
|
|
{value}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|