freeDashboard · Next.js + Tailwind
Keystone
A dashboard for organisation admin settings: members and roles with invites, single sign-on setup, seat usage on the plan, security policies and a danger zone with typed confirmation.
loopkitui.com/t/org-admin-settingsscroll inside ↓
Built from
- Dash kit (shell, charts, tables)in the zip
For: SaaS teams that need a complete organisation settings area.
prompt
You are building a complete dashboard app screen with Loopkit: the "Keystone" template.
Goal: A dashboard for organisation admin settings: members and roles with invites, single sign-on setup, seat usage on the plan, security policies and a danger zone with typed confirmation.
Audience: SaaS teams that need a complete organisation settings area.
Stack: Next.js App Router, TypeScript, Tailwind CSS v4, framer-motion. Colour tokens are shadcn's plus Loopkit's --signal, --ok, --warn, --info (see https://loopkitui.com/docs/theming). Light theme by default, dark via the .dark class.
1. Install the Loopkit components this page uses:
npx shadcn@latest add https://loopkitui.com/r/dash-kit.json # dash-kit
2. Create app/page.tsx with the source below. Keep the section order and the copy structure; replace the product name, copy and links with mine.
3. Check it at 360, 768, 1440 and 2560 px wide, in light and dark, with reduced motion on and off. The console must be clean.
Page source:
```tsx
"use client";
import * as React from "react";
import {
DashShell,
Panel,
Kpi,
DataTable,
Pill,
Seg,
Btn,
Confirm,
Toggle,
Dropdown,
Avatar,
Progress,
Sheet,
Facts,
useToast,
type Column,
type Tone,
} from "@/components/loopkit/dash-kit";
/**
* Keystone — organisation admin settings: members and roles, single sign-on,
* seat usage on the current plan, security policies and a danger zone.
*/
type Role = "owner" | "admin" | "member" | "viewer";
interface Member {
id: string;
name: string;
email: string;
role: Role;
status: "active" | "invited" | "suspended";
last: string;
mfa: boolean;
}
const MEMBERS: Member[] = [
{ id: "u1", name: "Astrid Lunde", email: "[email protected]", role: "owner", status: "active", last: "Now", mfa: true },
{ id: "u2", name: "Omar Haddad", email: "[email protected]", role: "admin", status: "active", last: "2h ago", mfa: true },
{ id: "u3", name: "Bea Fontaine", email: "[email protected]", role: "member", status: "active", last: "Yesterday", mfa: true },
{ id: "u4", name: "Yusuf Demir", email: "[email protected]", role: "member", status: "active", last: "3 days ago", mfa: false },
{ id: "u5", name: "Clara Jensen", email: "[email protected]", role: "viewer", status: "invited", last: "—", mfa: false },
{ id: "u6", name: "Theo Marsh", email: "[email protected]", role: "member", status: "suspended", last: "Oct 2", mfa: true },
];
const ROLE_TONE: Record<Role, Tone> = { owner: "accent", admin: "info", member: "muted", viewer: "muted" };
const STATUS_TONE: Record<Member["status"], Tone> = { active: "ok", invited: "warn", suspended: "danger" };
export default function Page() {
const [view, setView] = React.useState("members");
const titles: Record<string, [string, string]> = {
members: ["Members", "Who's in the organisation and what they can do"],
sso: ["Single sign-on", "Let people sign in with your identity provider"],
billing: ["Seats & plan", "How many seats you use and what they cost"],
security: ["Security", "Policies that apply to every member"],
danger: ["Danger zone", "Actions that can't be undone"],
};
return (
<DashShell
brand="Keystone"
mark="K"
accent="info"
variant="top"
nav={[
{ id: "members", label: "Members", icon: "users" },
{ id: "sso", label: "SSO", icon: "key" },
{ id: "billing", label: "Seats", icon: "card" },
{ id: "security", label: "Security", icon: "lock" },
{ id: "danger", label: "Danger zone", icon: "alert" },
]}
active={view}
onNavigate={setView}
title={titles[view][0]}
subtitle={titles[view][1]}
searchPlaceholder="Search members"
user={{ name: "Astrid Lunde", role: "Owner" }}
>
{view === "members" && <Members />}
{view === "sso" && <Sso />}
{view === "billing" && <Seats />}
{view === "security" && <Security />}
{view === "danger" && <Danger />}
</DashShell>
);
}
function Members() {
const toast = useToast();
const [rows, setRows] = React.useState(MEMBERS);
const [filter, setFilter] = React.useState<"all" | Member["status"]>("all");
const [open, setOpen] = React.useState<string | null>(null);
const [remove, setRemove] = React.useState(false);
const [invite, setInvite] = React.useState(false);
const [email, setEmail] = React.useState("");
const [role, setRole] = React.useState<Role>("member");
const cur = rows.find((r) => r.id === open) ?? null;
const shown = rows.filter((r) => filter === "all" || r.status === filter);
const cols: Column<Member>[] = [
{ key: "name", label: "Member", sort: (r) => r.name, render: (r) => <span className="flex items-center gap-3"><Avatar name={r.name} /><span className="min-w-0"><span className="block font-medium">{r.name}</span><span className="block text-xs text-muted-foreground">{r.email}</span></span></span> },
{ key: "role", label: "Role", sort: (r) => r.role, render: (r) => <Pill tone={ROLE_TONE[r.role]}>{r.role}</Pill> },
{ key: "status", label: "Status", sort: (r) => r.status, render: (r) => <Pill tone={STATUS_TONE[r.status]} dot>{r.status}</Pill> },
{ key: "mfa", label: "2FA", render: (r) => (r.mfa ? <Pill tone="ok">on</Pill> : <Pill tone="warn">off</Pill>) },
{ key: "last", label: "Last active", align: "right" },
];
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
<Kpi label="Members" value={String(rows.filter((r) => r.status === "active").length)} hint="active" />
<Kpi label="Pending invites" value={String(rows.filter((r) => r.status === "invited").length)} tone="warn" />
<Kpi label="2FA coverage" value={`${Math.round((rows.filter((r) => r.mfa).length / rows.length) * 100)}%`} tone="ok" />
<Kpi label="Admins" value={String(rows.filter((r) => r.role === "admin" || r.role === "owner").length)} tone="info" />
</div>
<Panel
title="Members"
pad={false}
action={
<div className="flex flex-wrap gap-2">
<Seg size="sm" label="Status" options={[{ value: "all", label: "All" }, { value: "active", label: "Active" }, { value: "invited", label: "Invited" }, { value: "suspended", label: "Suspended" }]} value={filter} onChange={setFilter} />
<Btn variant="primary" icon="plus" onClick={() => setInvite(true)}>Invite</Btn>
</div>
}
>
<DataTable columns={cols} rows={shown} onRowClick={(r) => setOpen(r.id)} selected={open} />
</Panel>
<Sheet
open={!!cur}
onClose={() => setOpen(null)}
title={cur?.name ?? ""}
footer={
cur &&
cur.role !== "owner" && (
<>
{cur.status === "invited" && <Btn icon="msg" onClick={() => toast(`Invite re-sent to ${cur.email}`, "ok")}>Resend invite</Btn>}
<Btn variant="danger" onClick={() => setRemove(true)}>Remove</Btn>
</>
)
}
>
{cur && (
<div className="space-y-5">
<Facts items={[["Email", cur.email], ["Status", <Pill key="s" tone={STATUS_TONE[cur.status]} dot>{cur.status}</Pill>], ["Two-factor", cur.mfa ? "On" : "Off"], ["Last active", cur.last]]} />
{cur.role === "owner" ? (
<p className="text-sm text-muted-foreground">Ownership moves from the danger zone.</p>
) : (
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">Role</p>
<Seg
label="Role"
options={[{ value: "admin", label: "Admin" }, { value: "member", label: "Member" }, { value: "viewer", label: "Viewer" }]}
value={cur.role}
onChange={(v) => {
setRows((s) => s.map((r) => (r.id === cur.id ? { ...r, role: v } : r)));
toast(`${cur.name} is now ${v}`, "ok");
}}
/>
</div>
)}
</div>
)}
</Sheet>
<Confirm
open={remove}
danger
title={cur ? `Remove ${cur.name}?` : ""}
body="They lose access right away. Their work stays in the organisation."
confirmLabel="Remove"
onCancel={() => setRemove(false)}
onConfirm={() => {
setRows((s) => s.filter((r) => r.id !== open));
toast(`${cur?.name} removed`, "warn");
setRemove(false);
setOpen(null);
}}
/>
<Sheet
open={invite}
onClose={() => setInvite(false)}
title="Invite a member"
footer={
<Btn
variant="primary"
disabled={!/^\S+@\S+\.\S+$/.test(email)}
onClick={() => {
const name = email.split("@")[0].replace(/[._-]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
setRows((s) => [...s, { id: `u${s.length + 10}`, name, email, role, status: "invited", last: "—", mfa: false }]);
toast(`Invite sent to ${email}`, "ok");
setEmail("");
setInvite(false);
}}
>
Send invite
</Btn>
}
>
<div className="space-y-5">
<label className="block text-sm">
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">Email</span>
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" placeholder="[email protected]" className="h-10 w-full rounded-lg border border-border bg-background px-3 outline-none focus:border-[var(--acc)]" />
</label>
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">Role</p>
<Dropdown label="Role" value={role} onChange={setRole} options={[{ value: "admin", label: "Admin — manage members and settings" }, { value: "member", label: "Member — create and edit" }, { value: "viewer", label: "Viewer — read only" }]} />
</div>
</div>
</Sheet>
</div>
);
}
function Sso() {
const toast = useToast();
const [provider, setProvider] = React.useState<"saml" | "oidc">("saml");
const [enforce, setEnforce] = React.useState(false);
const [jit, setJit] = React.useState(true);
const [tested, setTested] = React.useState(false);
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]">
<Panel title="Identity provider" subtitle="Paste these into your provider, then test the connection" action={<Seg size="sm" label="Protocol" options={[{ value: "saml", label: "SAML" }, { value: "oidc", label: "OIDC" }]} value={provider} onChange={setProvider} />}>
<div className="space-y-3">
{(provider === "saml"
? [["ACS URL", "https://auth.keystone.app/saml/acs/org_7Hq2"], ["Entity ID", "urn:keystone:org_7Hq2"]]
: [["Redirect URI", "https://auth.keystone.app/oidc/callback"], ["Client ID", "ks_org_7Hq2"]]
).map(([k, v]) => (
<div key={k}>
<p className="mb-1 text-xs font-medium text-muted-foreground">{k}</p>
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2">
<span className="min-w-0 flex-1 truncate font-mono text-xs">{v}</span>
<Btn variant="plain" icon="copy" aria-label={`Copy ${k}`} onClick={() => toast(`${k} copied`, "ok")} />
</div>
</div>
))}
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<Btn variant="primary" onClick={() => { setTested(true); toast("Test sign-in succeeded", "ok"); }}>Test connection</Btn>
{tested && <Pill tone="ok" dot>Connected</Pill>}
</div>
</Panel>
<Panel title="Sign-in rules">
<ul className="space-y-5 text-sm">
<li className="flex items-center justify-between gap-4">
<div>
<p className="font-medium">Require SSO</p>
<p className="text-xs text-muted-foreground">Passwords stop working for everyone except the owner</p>
</div>
<Toggle label="Require SSO" checked={enforce} onChange={(v) => { if (v && !tested) { toast("Test the connection first", "warn"); return; } setEnforce(v); toast(v ? "SSO is now required" : "Passwords allowed again", v ? "ok" : "warn"); }} />
</li>
<li className="flex items-center justify-between gap-4">
<div>
<p className="font-medium">Create accounts on first sign-in</p>
<p className="text-xs text-muted-foreground">New people from your domain join as members</p>
</div>
<Toggle label="Just-in-time provisioning" checked={jit} onChange={(v) => { setJit(v); toast(v ? "Accounts are created on first sign-in" : "Only invited people can join", "ok"); }} />
</li>
</ul>
</Panel>
</div>
);
}
function Seats() {
const toast = useToast();
const [seats, setSeats] = React.useState(25);
const used = 18;
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Panel title="Seat usage" subtitle="Business plan · billed yearly">
<p className="font-display text-4xl font-bold tabular-nums">
{used}
<span className="text-lg font-normal text-muted-foreground"> / {seats} seats</span>
</p>
<Progress className="mt-4 h-2.5" value={(used / seats) * 100} tone={used / seats > 0.85 ? "warn" : "accent"} />
<p className="mt-3 text-sm text-muted-foreground">Invited people count once they accept.</p>
</Panel>
<Panel title="Change seats" subtitle="$18 per seat a month, prorated">
<Seg label="Seats" options={["20", "25", "30", "40", "50"].map((v) => ({ value: v, label: v }))} value={String(seats)} onChange={(v) => { if (Number(v) < used) { toast(`You have ${used} members; remove some first`, "warn"); return; } setSeats(Number(v)); toast(`Seats set to ${v} · $${Number(v) * 18}/mo`, "ok"); }} />
<p className="mt-4 font-display text-2xl font-bold tabular-nums">${seats * 18}<span className="text-sm font-normal text-muted-foreground">/month</span></p>
</Panel>
</div>
);
}
function Security() {
const toast = useToast();
const [mfa, setMfa] = React.useState(true);
const [session, setSession] = React.useState("7d");
const [ips, setIps] = React.useState(false);
const [domain, setDomain] = React.useState(true);
const row = (title: string, body: string, ctl: React.ReactNode) => (
<li className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{body}</p>
</div>
{ctl}
</li>
);
return (
<Panel title="Policies">
<ul className="divide-y divide-border text-sm">
{row("Require two-factor", "Members without it are asked to set it up on next sign-in", <Toggle label="Require two-factor" checked={mfa} onChange={(v) => { setMfa(v); toast(v ? "Two-factor required" : "Two-factor optional", v ? "ok" : "warn"); }} />)}
{row("Session length", "How long until people sign in again", <Dropdown className="w-36" label="Session length" value={session} onChange={(v) => { setSession(v); toast("Session length updated", "ok"); }} options={[{ value: "1d", label: "1 day" }, { value: "7d", label: "7 days" }, { value: "30d", label: "30 days" }]} />)}
{row("Restrict by IP", "Only allow sign-ins from listed office networks", <Toggle label="Restrict by IP" checked={ips} onChange={(v) => { setIps(v); toast(v ? "IP allow-list on" : "IP allow-list off", "ok"); }} />)}
{row("Verified domain only", "Invites can only go to keystone.app addresses", <Toggle label="Verified domain only" checked={domain} onChange={(v) => { setDomain(v); toast("Invite rule updated", "ok"); }} />)}
</ul>
</Panel>
);
}
function Danger() {
const toast = useToast();
const [ask, setAsk] = React.useState<null | "transfer" | "export" | "delete">(null);
const [typed, setTyped] = React.useState("");
const items = [
{ id: "export" as const, title: "Export all data", body: "A zip of every project, comment and file, emailed to the owner.", label: "Export", danger: false },
{ id: "transfer" as const, title: "Transfer ownership", body: "Make another admin the owner. You become an admin.", label: "Transfer", danger: true },
{ id: "delete" as const, title: "Delete organisation", body: "Everything is deleted after 14 days. Members lose access immediately.", label: "Delete", danger: true },
];
const cur = items.find((i) => i.id === ask);
return (
<div className="rounded-2xl border border-destructive/40">
<ul className="divide-y divide-border">
{items.map((i) => (
<li key={i.id} className="flex flex-col gap-3 p-5 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="font-medium">{i.title}</p>
<p className="text-sm text-muted-foreground">{i.body}</p>
</div>
<Btn variant={i.danger ? "danger" : "ghost"} onClick={() => { setTyped(""); setAsk(i.id); }}>{i.label}</Btn>
</li>
))}
</ul>
<Confirm
open={!!cur}
danger={cur?.danger}
title={cur ? `${cur.title}?` : ""}
body={
ask === "delete" ? (
<div className="space-y-2">
<p>Type the organisation name, Keystone, to confirm.</p>
<input value={typed} onChange={(e) => setTyped(e.target.value)} aria-label="Organisation name" className="h-9 w-full rounded-lg border border-border bg-background px-3 text-foreground outline-none focus:border-destructive" />
</div>
) : (
cur?.body
)
}
confirmLabel={cur?.label}
onCancel={() => setAsk(null)}
onConfirm={() => {
if (ask === "delete" && typed !== "Keystone") {
toast("The name doesn't match", "danger");
return;
}
toast(ask === "export" ? "Export started — we'll email the owner" : ask === "transfer" ? "Transfer request sent to Omar Haddad" : "Organisation scheduled for deletion", ask === "export" ? "ok" : "warn");
setAsk(null);
}}
/>
</div>
);
}
```