907 lines
46 KiB
TypeScript
907 lines
46 KiB
TypeScript
"use client";
|
||
|
||
import React, { useMemo, useState } from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
Settings2,
|
||
User,
|
||
Building2,
|
||
ShieldCheck,
|
||
Bell,
|
||
Palette,
|
||
KeyRound,
|
||
Save,
|
||
Plus,
|
||
Loader2,
|
||
FolderKanban,
|
||
CheckCircle2,
|
||
XCircle,
|
||
Calendar,
|
||
Pencil,
|
||
Trash2,
|
||
AlertTriangle,
|
||
} from "lucide-react";
|
||
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
|
||
import { Button } from "@/components/ui/Button";
|
||
import { Badge } from "@/components/ui/Badge";
|
||
import { EmptyState } from "@/components/ui/EmptyState";
|
||
import { orgs, projects as projectsApi } from "@/lib/api";
|
||
import type { Organization, Project } from "@/lib/types";
|
||
import { cn, fmtDate, shortId } from "@/lib/utils";
|
||
|
||
const SECTIONS = [
|
||
{ key: "profile", label: "Profile", icon: User },
|
||
{ key: "org", label: "Organization", icon: Building2 },
|
||
{ key: "security", label: "Security", icon: ShieldCheck },
|
||
{ key: "ai", label: "AI Policy", icon: KeyRound },
|
||
{ key: "notifs", label: "Notifications", icon: Bell },
|
||
{ key: "theme", label: "Appearance", icon: Palette },
|
||
] as const;
|
||
|
||
type SecKey = (typeof SECTIONS)[number]["key"];
|
||
|
||
const PLAN_TONES: Record<string, "brand" | "violet" | "green" | "amber" | "neutral" | "blue" | "red"> = {
|
||
Free: "neutral", Team: "blue", Business: "brand", Enterprise: "violet",
|
||
};
|
||
|
||
const STATUS_TONES: Record<string, "green" | "red" | "neutral" | "brand" | "violet" | "amber" | "blue"> = {
|
||
true: "green", false: "red", active: "green", inactive: "red",
|
||
};
|
||
|
||
export default function SettingsPage() {
|
||
const queryClient = useQueryClient();
|
||
const [sec, setSec] = useState<SecKey>("org");
|
||
const [name, setName] = useState("Mario Rossi");
|
||
const [email, setEmail] = useState("mario.rossi@azurian.example");
|
||
const [role, setRole] = useState("Owner");
|
||
const [mfa, setMfa] = useState(true);
|
||
const [ceiling, setCeiling] = useState("50");
|
||
|
||
/* ---------------- Organizations ---------------- */
|
||
const orgsQ = useQuery({
|
||
queryKey: ["orgs", "list"],
|
||
queryFn: () => orgs.list({ limit: 200 }),
|
||
retry: false,
|
||
refetchOnWindowFocus: false,
|
||
});
|
||
const [showNewOrg, setShowNewOrg] = useState(false);
|
||
const [newOrgName, setNewOrgName] = useState("");
|
||
const [newOrgSlug, setNewOrgSlug] = useState("");
|
||
const [newOrgPlan, setNewOrgPlan] = useState<"Free" | "Team" | "Business" | "Enterprise">("Team");
|
||
const [newOrgActive, setNewOrgActive] = useState(true);
|
||
const createOrgM = useMutation({
|
||
mutationFn: async () => {
|
||
const slugRgx = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/;
|
||
if (!newOrgName.trim()) throw new Error("Organization name required");
|
||
if (!slugRgx.test(newOrgSlug.trim())) throw new Error("Slug must be 3-60 chars: lowercase letters, digits, dashes (first+last alphanumeric)");
|
||
return orgs.create({
|
||
name: newOrgName.trim(),
|
||
slug: newOrgSlug.trim().toLowerCase(),
|
||
plan: newOrgPlan,
|
||
is_active: newOrgActive,
|
||
});
|
||
},
|
||
onSuccess: () => {
|
||
setShowNewOrg(false);
|
||
setNewOrgName(""); setNewOrgSlug(""); setNewOrgPlan("Team"); setNewOrgActive(true);
|
||
queryClient.invalidateQueries({ queryKey: ["orgs", "list"] }).catch(() => undefined);
|
||
},
|
||
});
|
||
const slugHint = useMemo(() => {
|
||
if (!newOrgSlug) return null;
|
||
const ok = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(newOrgSlug);
|
||
return ok ? "Valid slug format" : "⚠ Must be 3-60 a-z 0-9 -, first+last alphanumeric";
|
||
}, [newOrgSlug]);
|
||
|
||
/* ---------------- Projects ---------------- */
|
||
const projectsQ = useQuery({
|
||
queryKey: ["projects", "list"],
|
||
queryFn: () => projectsApi.list({ limit: 500 }),
|
||
retry: false,
|
||
refetchOnWindowFocus: false,
|
||
});
|
||
const [showNewProj, setShowNewProj] = useState(false);
|
||
const [newProjName, setNewProjName] = useState("");
|
||
const [newProjKey, setNewProjKey] = useState("");
|
||
const [newProjDesc, setNewProjDesc] = useState("");
|
||
const [newProjBranch, setNewProjBranch] = useState("main");
|
||
const [newProjActive, setNewProjActive] = useState(true);
|
||
const createProjM = useMutation({
|
||
mutationFn: async () => {
|
||
const keyRgx = /^[A-Z][A-Z0-9]{1,19}$/;
|
||
if (!newProjName.trim()) throw new Error("Project name required");
|
||
if (!keyRgx.test(newProjKey.trim())) throw new Error("Project key must be 2-20 uppercase letters/digits, starting with a letter (e.g. ASF, PROJ2)");
|
||
return projectsApi.create({
|
||
name: newProjName.trim(),
|
||
key: newProjKey.trim().toUpperCase(),
|
||
description: newProjDesc.trim() || undefined,
|
||
default_branch: newProjBranch.trim() || undefined,
|
||
is_active: newProjActive,
|
||
});
|
||
},
|
||
onSuccess: () => {
|
||
setShowNewProj(false);
|
||
setNewProjName(""); setNewProjKey(""); setNewProjDesc(""); setNewProjBranch("main"); setNewProjActive(true);
|
||
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
|
||
},
|
||
});
|
||
const keyHint = useMemo(() => {
|
||
if (!newProjKey) return null;
|
||
const ok = /^[A-Z][A-Z0-9]{1,19}$/.test(newProjKey);
|
||
return ok ? "Valid project key format" : "⚠ Must be 2-20 A-Z 0-9 starting with letter (e.g. ASF)";
|
||
}, [newProjKey]);
|
||
|
||
/* ---------------- Edit Organization ---------------- */
|
||
const [showEditOrg, setShowEditOrg] = useState(false);
|
||
const [editOrg, setEditOrg] = useState<Organization | null>(null);
|
||
const [eoName, setEoName] = useState("");
|
||
const [eoSlug, setEoSlug] = useState("");
|
||
const [eoPlan, setEoPlan] = useState<"Free" | "Team" | "Business" | "Enterprise">("Team");
|
||
const [eoActive, setEoActive] = useState(true);
|
||
|
||
const openEditOrg = (o: Organization) => {
|
||
setEditOrg(o);
|
||
setEoName(o.name ?? "");
|
||
setEoSlug(o.slug ?? "");
|
||
setEoPlan((o.plan as typeof eoPlan) ?? "Team");
|
||
setEoActive(Boolean(o.is_active ?? true));
|
||
setShowEditOrg(true);
|
||
};
|
||
|
||
const editOrgM = useMutation({
|
||
mutationFn: async () => {
|
||
if (!editOrg) throw new Error("no organization");
|
||
const slugRgx = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/;
|
||
if (!eoName.trim()) throw new Error("Organization name required");
|
||
if (!slugRgx.test(eoSlug.trim())) throw new Error("Slug must be 3-60 chars: lowercase letters, digits, dashes (first+last alphanumeric)");
|
||
return orgs.update(String(editOrg.id), {
|
||
name: eoName.trim(),
|
||
slug: eoSlug.trim().toLowerCase(),
|
||
plan: eoPlan,
|
||
is_active: eoActive,
|
||
});
|
||
},
|
||
onSuccess: () => {
|
||
setShowEditOrg(false);
|
||
setEditOrg(null);
|
||
queryClient.invalidateQueries({ queryKey: ["orgs", "list"] }).catch(() => undefined);
|
||
},
|
||
});
|
||
|
||
const eoSlugHint = useMemo(() => {
|
||
if (!eoSlug) return null;
|
||
const ok = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(eoSlug);
|
||
return ok ? "Valid slug format" : "⚠ Must be 3-60 a-z 0-9 -, first+last alphanumeric";
|
||
}, [eoSlug]);
|
||
|
||
/* ---------------- Delete Organization ---------------- */
|
||
const [delOrg, setDelOrg] = useState<Organization | null>(null);
|
||
|
||
const deleteOrgM = useMutation({
|
||
mutationFn: async (id: string) => orgs.remove(id),
|
||
onSuccess: () => {
|
||
setDelOrg(null);
|
||
queryClient.invalidateQueries({ queryKey: ["orgs", "list"] }).catch(() => undefined);
|
||
},
|
||
});
|
||
|
||
/* ---------------- Edit Project ---------------- */
|
||
const [showEditProj, setShowEditProj] = useState(false);
|
||
const [editProj, setEditProj] = useState<Project | null>(null);
|
||
const [epName, setEpName] = useState("");
|
||
const [epKey, setEpKey] = useState("");
|
||
const [epDesc, setEpDesc] = useState("");
|
||
const [epBranch, setEpBranch] = useState("main");
|
||
const [epActive, setEpActive] = useState(true);
|
||
|
||
const openEditProj = (p: Project) => {
|
||
setEditProj(p);
|
||
setEpName(p.name ?? "");
|
||
setEpKey(p.key ?? "");
|
||
setEpDesc(p.description ?? "");
|
||
setEpBranch(p.default_branch ?? "main");
|
||
setEpActive(Boolean(p.is_active ?? true));
|
||
setShowEditProj(true);
|
||
};
|
||
|
||
const editProjM = useMutation({
|
||
mutationFn: async () => {
|
||
if (!editProj) throw new Error("no project");
|
||
const keyRgx = /^[A-Z][A-Z0-9]{1,19}$/;
|
||
if (!epName.trim()) throw new Error("Project name required");
|
||
if (!keyRgx.test(epKey.trim())) throw new Error("Project key must be 2-20 uppercase letters/digits, starting with a letter (e.g. ASF, PROJ2)");
|
||
return projectsApi.update(String(editProj.id), {
|
||
name: epName.trim(),
|
||
key: epKey.trim().toUpperCase(),
|
||
description: epDesc.trim() || undefined,
|
||
default_branch: epBranch.trim() || undefined,
|
||
is_active: epActive,
|
||
});
|
||
},
|
||
onSuccess: () => {
|
||
setShowEditProj(false);
|
||
setEditProj(null);
|
||
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
|
||
},
|
||
});
|
||
|
||
const epKeyHint = useMemo(() => {
|
||
if (!epKey) return null;
|
||
const ok = /^[A-Z][A-Z0-9]{1,19}$/.test(epKey);
|
||
return ok ? "Valid project key format" : "⚠ Must be 2-20 A-Z 0-9 starting with letter (e.g. ASF)";
|
||
}, [epKey]);
|
||
|
||
/* ---------------- Delete Project ---------------- */
|
||
const [delProj, setDelProj] = useState<Project | null>(null);
|
||
|
||
const deleteProjM = useMutation({
|
||
mutationFn: async (id: string) => projectsApi.remove(id),
|
||
onSuccess: () => {
|
||
setDelProj(null);
|
||
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
|
||
},
|
||
});
|
||
|
||
return (
|
||
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
|
||
<header className="mb-6">
|
||
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
|
||
<Settings2 className="w-6 h-6 text-brand-600" />
|
||
Settings
|
||
</h1>
|
||
<p className="mt-1 text-sm text-slate-500">
|
||
User profile, organization, security, AI policy, notifications and appearance.
|
||
</p>
|
||
</header>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||
<Card className="lg:col-span-1">
|
||
<CardBody className="p-2">
|
||
<ul className="space-y-0.5">
|
||
{SECTIONS.map(({ key, label, icon: Icon }) => (
|
||
<li key={key}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSec(key)}
|
||
className={cn(
|
||
"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||
sec === key
|
||
? "bg-brand-50 text-brand-700 ring-1 ring-inset ring-brand-100"
|
||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
||
)}
|
||
>
|
||
<Icon className="w-4 h-4" />
|
||
{label}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</CardBody>
|
||
</Card>
|
||
|
||
<Card className="lg:col-span-3">
|
||
<CardHeader>
|
||
<CardTitle className="capitalize flex items-center gap-2">
|
||
{sec === "profile" && <>User Profile <Badge tone="brand">You</Badge></>}
|
||
{sec === "org" && "Organization & Projects"}
|
||
{sec === "security" && "Security"}
|
||
{sec === "ai" && "AI Policy"}
|
||
{sec === "notifs" && "Notifications"}
|
||
{sec === "theme" && "Appearance"}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardBody>
|
||
{sec === "profile" && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl">
|
||
<Field label="Full name">
|
||
<input className="asf-input" value={name} onChange={(e) => setName(e.target.value)} />
|
||
</Field>
|
||
<Field label="Email">
|
||
<input className="asf-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||
</Field>
|
||
<Field label="Role">
|
||
<select className="asf-input" value={role} onChange={(e) => setRole(e.target.value)}>
|
||
<option>Owner</option><option>Admin</option><option>Member</option><option>Viewer</option>
|
||
</select>
|
||
</Field>
|
||
<div />
|
||
<div className="md:col-span-2 flex justify-end">
|
||
<Button variant="primary"><Save className="w-4 h-4" /> Save changes</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{sec === "org" && (
|
||
<div className="space-y-8">
|
||
{/* Organizations Card */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<Building2 className="w-5 h-5 text-slate-500" />
|
||
<h4 className="font-medium text-slate-800">Organizations</h4>
|
||
<Badge tone="brand">{orgsQ.data?.length ?? 0}</Badge>
|
||
</div>
|
||
<Button size="sm" variant="primary" onClick={() => setShowNewOrg(true)}>
|
||
<Plus className="w-4 h-4" /> New organization
|
||
</Button>
|
||
</div>
|
||
{orgsQ.isFetching && !orgsQ.data ? (
|
||
<div className="flex items-center gap-2 text-slate-500 text-sm px-3 py-6">
|
||
<Loader2 className="w-4 h-4 animate-spin" /> Loading organizations…
|
||
</div>
|
||
) : (orgsQ.data ?? []).length === 0 ? (
|
||
<EmptyState title="No organizations yet" description="Click 'New organization' above to create your first tenant." />
|
||
) : (
|
||
<div className="overflow-hidden rounded-xl border border-slate-200">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
||
<tr>
|
||
<th className="text-left font-medium px-4 py-2.5">Name</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Slug</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Plan</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Status</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Created</th>
|
||
<th className="text-right font-medium px-4 py-2.5">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{(orgsQ.data ?? []).map((o: Organization) => (
|
||
<tr key={String(o.id)} className="hover:bg-slate-50/50">
|
||
<td className="px-4 py-3 font-medium text-slate-800">{o.name} <span className="text-[10px] text-slate-400 font-mono ml-2">{shortId(String(o.id))}</span></td>
|
||
<td className="px-4 py-3 font-mono text-xs text-slate-600">{o.slug}</td>
|
||
<td className="px-4 py-3"><Badge tone={PLAN_TONES[String(o.plan ?? "Free")]}>{o.plan ?? "Free"}</Badge></td>
|
||
<td className="px-4 py-3"><Badge tone={STATUS_TONES[String(o.is_active ?? true)]}>{o.is_active ?? true ? <><CheckCircle2 className="w-3 h-3 mr-1"/>Active</> : <><XCircle className="w-3 h-3 mr-1"/>Inactive</>}</Badge></td>
|
||
<td className="px-4 py-3 text-slate-500 text-xs flex items-center gap-1"><Calendar className="w-3 h-3" />{o.created_at ? fmtDate(o.created_at) : "—"}</td>
|
||
<td className="px-4 py-3 text-right">
|
||
<div className="inline-flex items-center gap-1">
|
||
<Button variant="ghost" size="xs" onClick={() => openEditOrg(o)} title="Edit organization">
|
||
<Pencil className="w-3.5 h-3.5" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
className="!text-red-600 hover:!bg-red-50"
|
||
onClick={() => setDelOrg(o)}
|
||
title="Delete organization"
|
||
disabled={delOrg?.id === o.id && deleteOrgM.isPending}
|
||
>
|
||
{delOrg?.id === o.id && deleteOrgM.isPending
|
||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||
: <Trash2 className="w-3.5 h-3.5" />}
|
||
</Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Projects Card */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<FolderKanban className="w-5 h-5 text-slate-500" />
|
||
<h4 className="font-medium text-slate-800">Projects</h4>
|
||
<Badge tone="brand">{projectsQ.data?.length ?? 0}</Badge>
|
||
</div>
|
||
<Button size="sm" variant="primary" onClick={() => setShowNewProj(true)}>
|
||
<Plus className="w-4 h-4" /> New project
|
||
</Button>
|
||
</div>
|
||
{projectsQ.isFetching && !projectsQ.data ? (
|
||
<div className="flex items-center gap-2 text-slate-500 text-sm px-3 py-6">
|
||
<Loader2 className="w-4 h-4 animate-spin" /> Loading projects…
|
||
</div>
|
||
) : (projectsQ.data ?? []).length === 0 ? (
|
||
<EmptyState title="No projects yet" description="Create a project to start tracking requirements, code and deployments." />
|
||
) : (
|
||
<div className="overflow-hidden rounded-xl border border-slate-200">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
|
||
<tr>
|
||
<th className="text-left font-medium px-4 py-2.5">Key</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Name</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Default branch</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Status</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Updated</th>
|
||
<th className="text-right font-medium px-4 py-2.5">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{(projectsQ.data ?? []).map((p: Project) => (
|
||
<tr key={String(p.id)} className="hover:bg-slate-50/50">
|
||
<td className="px-4 py-3"><Badge tone="violet" className="font-mono">{p.key}</Badge></td>
|
||
<td className="px-4 py-3 font-medium text-slate-800">{p.name} <span className="text-[10px] text-slate-400 font-mono ml-2">{shortId(String(p.id))}</span></td>
|
||
<td className="px-4 py-3 font-mono text-xs text-slate-600">{p.default_branch ?? "—"}</td>
|
||
<td className="px-4 py-3"><Badge tone={STATUS_TONES[String(p.is_active ?? true)]}>{p.is_active ?? true ? <><CheckCircle2 className="w-3 h-3 mr-1"/>Active</> : <><XCircle className="w-3 h-3 mr-1"/>Archived</>}</Badge></td>
|
||
<td className="px-4 py-3 text-slate-500 text-xs flex items-center gap-1"><Calendar className="w-3 h-3" />{p.updated_at ? fmtDate(p.updated_at) : "—"}</td>
|
||
<td className="px-4 py-3 text-right">
|
||
<div className="inline-flex items-center gap-1">
|
||
<Button variant="ghost" size="xs" onClick={() => openEditProj(p)} title="Edit project">
|
||
<Pencil className="w-3.5 h-3.5" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
className="!text-red-600 hover:!bg-red-50"
|
||
onClick={() => setDelProj(p)}
|
||
title="Delete project"
|
||
disabled={delProj?.id === p.id && deleteProjM.isPending}
|
||
>
|
||
{delProj?.id === p.id && deleteProjM.isPending
|
||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||
: <Trash2 className="w-3.5 h-3.5" />}
|
||
</Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{sec === "security" && (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<Toggle label="Multi-factor authentication" hint="Require a second factor at every login." checked={mfa} onChange={setMfa} />
|
||
<Toggle label="Session timeout 15m" hint="Force re-authentication after idle period." checked={true} onChange={() => {}} />
|
||
<Toggle label="Audit log retention 365 days" hint="Append-only, tamper-evident export." checked={true} onChange={() => {}} />
|
||
</div>
|
||
)}
|
||
{sec === "ai" && (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<Field label="Monthly $ ceiling (USD)">
|
||
<input className="asf-input" type="number" value={ceiling} onChange={(e) => setCeiling(e.target.value)} />
|
||
</Field>
|
||
<Toggle label="Allow local models only" hint="Never route prompts to external providers." checked={false} onChange={() => {}} />
|
||
<Toggle label="Audit every inference" hint="Append every prompt + response to audit log." checked={true} onChange={() => {}} />
|
||
<p className="text-xs text-slate-500">AI Policy can be further refined per-project and per-team inside asf-ai.</p>
|
||
</div>
|
||
)}
|
||
{sec === "notifs" && (
|
||
<div className="space-y-4 max-w-2xl">
|
||
{[
|
||
{ l: "Job failures", h: "Email + in-app when any job fails." },
|
||
{ l: "Security findings critical/high", h: "Instant via configured channel." },
|
||
{ l: "Deployment completed", h: "Production deployments only." },
|
||
{ l: "Weekly digest", h: "Health, coverage and cost snapshot." },
|
||
].map((t) => (
|
||
<Toggle key={t.l} label={t.l} hint={t.h} defaultChecked onChange={() => {}} />
|
||
))}
|
||
</div>
|
||
)}
|
||
{sec === "theme" && (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{[
|
||
{ k: "light", label: "Light", sw: "bg-white border-slate-300" },
|
||
{ k: "system", label: "System", sw: "bg-gradient-to-r from-white to-slate-900 border-slate-400" },
|
||
{ k: "dark", label: "Dark", sw: "bg-slate-900 border-slate-700" },
|
||
].map((t) => (
|
||
<button key={t.k} type="button" className={cn("rounded-xl border p-3 text-left hover:border-brand-400 transition-colors", t.k === "light" ? "border-brand-400 ring-2 ring-brand-100" : "border-slate-200")}>
|
||
<div className={cn("w-full h-16 rounded-lg border mb-2", t.sw)} />
|
||
<div className="text-sm font-medium text-slate-800">{t.label}</div>
|
||
<div className="text-xs text-slate-500">Default</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<Toggle label="Density · Comfortable" hint="Looser paddings throughout the shell." checked={true} onChange={() => {}} />
|
||
</div>
|
||
)}
|
||
</CardBody>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* ---------- New Organization modal ---------- */}
|
||
{showNewOrg && (
|
||
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !createOrgM.isPending && setShowNewOrg(false)}>
|
||
<div
|
||
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
|
||
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||
<Building2 className="w-4 h-4 text-brand-600" /> New organization
|
||
</h3>
|
||
<button
|
||
onClick={() => !createOrgM.isPending && setShowNewOrg(false)}
|
||
disabled={createOrgM.isPending}
|
||
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
|
||
aria-label="close"
|
||
>×</button>
|
||
</div>
|
||
<form
|
||
className="p-5 space-y-3"
|
||
onSubmit={(e) => { e.preventDefault(); createOrgM.mutate(); }}
|
||
>
|
||
<Field label="Name *">
|
||
<input className="asf-input" autoFocus required
|
||
placeholder="Acme Inc." value={newOrgName}
|
||
onChange={(e) => setNewOrgName(e.target.value)} />
|
||
</Field>
|
||
<Field label={<>Slug * <span className="text-[10px] text-slate-400">(URL identifier)</span></>}>
|
||
<input className="asf-input font-mono text-sm"
|
||
placeholder="acme-inc"
|
||
value={newOrgSlug}
|
||
onChange={(e) => setNewOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} />
|
||
<div className={cn("text-[11px] mt-1", slugHint && slugHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{slugHint ?? "3-60 chars, lowercase letters, digits, dashes. First and last must be alphanumeric."}</div>
|
||
</Field>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Plan">
|
||
<select className="asf-input" value={newOrgPlan}
|
||
onChange={(e) => setNewOrgPlan(e.target.value as typeof newOrgPlan)}>
|
||
{(["Free","Team","Business","Enterprise"] as const).map((p) => <option key={p}>{p}</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Status">
|
||
<Toggle label="Active" hint="Organization visible and usable." checked={newOrgActive} onChange={setNewOrgActive} />
|
||
</Field>
|
||
</div>
|
||
{createOrgM.isError && (
|
||
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||
{createOrgM.error instanceof Error ? createOrgM.error.message : "Failed to create organization."}
|
||
</div>
|
||
)}
|
||
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
|
||
<Button type="button" variant="secondary" size="sm" disabled={createOrgM.isPending} onClick={() => setShowNewOrg(false)}>Cancel</Button>
|
||
<Button type="submit" variant="primary" size="sm" disabled={createOrgM.isPending || !newOrgName || !/^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(newOrgSlug)}>
|
||
{createOrgM.isPending ? (
|
||
<><Loader2 className="w-4 h-4 animate-spin" /> Creating…</>
|
||
) : (<><Plus className="w-4 h-4" /> Create organization</>)}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---------- New Project modal ---------- */}
|
||
{showNewProj && (
|
||
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !createProjM.isPending && setShowNewProj(false)}>
|
||
<div
|
||
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
|
||
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||
<FolderKanban className="w-4 h-4 text-brand-600" /> New project
|
||
</h3>
|
||
<button
|
||
onClick={() => !createProjM.isPending && setShowNewProj(false)}
|
||
disabled={createProjM.isPending}
|
||
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
|
||
aria-label="close"
|
||
>×</button>
|
||
</div>
|
||
<form
|
||
className="p-5 space-y-3"
|
||
onSubmit={(e) => { e.preventDefault(); createProjM.mutate(); }}
|
||
>
|
||
<Field label="Name *">
|
||
<input className="asf-input" required autoFocus
|
||
placeholder="ASF Platform MVP"
|
||
value={newProjName}
|
||
onChange={(e) => setNewProjName(e.target.value)} />
|
||
</Field>
|
||
<Field label={<>Key * <span className="text-[10px] text-slate-400">(short uppercase code)</span></>}>
|
||
<input className="asf-input font-mono text-sm uppercase tracking-wide"
|
||
placeholder="ASF"
|
||
maxLength={20}
|
||
value={newProjKey}
|
||
onChange={(e) => setNewProjKey(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))} />
|
||
<div className={cn("text-[11px] mt-1", keyHint && keyHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{keyHint ?? "2-20 characters. Uppercase letters/digits only — starts with a letter."}</div>
|
||
</Field>
|
||
<Field label="Default branch">
|
||
<input className="asf-input font-mono text-xs"
|
||
placeholder="main"
|
||
value={newProjBranch}
|
||
onChange={(e) => setNewProjBranch(e.target.value)} />
|
||
</Field>
|
||
<Field label="Description">
|
||
<textarea className="asf-input" rows={3}
|
||
placeholder="Scope, goals and non-goals of this project."
|
||
value={newProjDesc}
|
||
onChange={(e) => setNewProjDesc(e.target.value)} />
|
||
</Field>
|
||
<Field label="Status">
|
||
<Toggle label="Active" hint="Project appears in pickers and filters." checked={newProjActive} onChange={setNewProjActive} />
|
||
</Field>
|
||
{createProjM.isError && (
|
||
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||
{createProjM.error instanceof Error ? createProjM.error.message : "Failed to create project."}
|
||
</div>
|
||
)}
|
||
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
|
||
<Button type="button" variant="secondary" size="sm" disabled={createProjM.isPending} onClick={() => setShowNewProj(false)}>Cancel</Button>
|
||
<Button type="submit" variant="primary" size="sm"
|
||
disabled={createProjM.isPending || !newProjName || !/^[A-Z][A-Z0-9]{1,19}$/.test(newProjKey)}>
|
||
{createProjM.isPending ? (
|
||
<><Loader2 className="w-4 h-4 animate-spin" /> Creating…</>
|
||
) : (<><Plus className="w-4 h-4" /> Create project</>)}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---------- Edit Organization modal ---------- */}
|
||
{showEditOrg && (
|
||
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !editOrgM.isPending && setShowEditOrg(false)}>
|
||
<div
|
||
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
|
||
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||
<Pencil className="w-4 h-4 text-brand-600" /> Edit organization
|
||
</h3>
|
||
<button
|
||
onClick={() => !editOrgM.isPending && setShowEditOrg(false)}
|
||
disabled={editOrgM.isPending}
|
||
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
|
||
aria-label="close"
|
||
>×</button>
|
||
</div>
|
||
<form
|
||
className="p-5 space-y-3"
|
||
onSubmit={(e) => { e.preventDefault(); void editOrgM.mutateAsync(); }}
|
||
>
|
||
<Field label="Name *">
|
||
<input className="asf-input" required autoFocus
|
||
placeholder="Acme Inc." value={eoName}
|
||
onChange={(e) => setEoName(e.target.value)} />
|
||
</Field>
|
||
<Field label={<>Slug * <span className="text-[10px] text-slate-400">(URL identifier)</span></>}>
|
||
<input className="asf-input font-mono text-sm"
|
||
placeholder="acme-inc"
|
||
value={eoSlug}
|
||
onChange={(e) => setEoSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} />
|
||
<div className={cn("text-[11px] mt-1", eoSlugHint && eoSlugHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{eoSlugHint ?? "3-60 chars, lowercase letters, digits, dashes. First and last must be alphanumeric."}</div>
|
||
</Field>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Plan">
|
||
<select className="asf-input" value={eoPlan}
|
||
onChange={(e) => setEoPlan(e.target.value as typeof eoPlan)}>
|
||
{(["Free","Team","Business","Enterprise"] as const).map((p) => <option key={p}>{p}</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Status">
|
||
<Toggle label="Active" hint="Organization visible and usable." checked={eoActive} onChange={setEoActive} />
|
||
</Field>
|
||
</div>
|
||
{editOrgM.isError && (
|
||
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||
{editOrgM.error instanceof Error ? editOrgM.error.message : "Failed to update organization."}
|
||
</div>
|
||
)}
|
||
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
|
||
<Button type="button" variant="secondary" size="sm" disabled={editOrgM.isPending} onClick={() => setShowEditOrg(false)}>Cancel</Button>
|
||
<Button type="submit" variant="primary" size="sm" disabled={editOrgM.isPending || !eoName || !/^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(eoSlug)}>
|
||
{editOrgM.isPending ? (
|
||
<><Loader2 className="w-4 h-4 animate-spin" /> Saving…</>
|
||
) : (<><Save className="w-4 h-4" /> Save changes</>)}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---------- Delete Organization confirm ---------- */}
|
||
{delOrg && (
|
||
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !deleteOrgM.isPending && setDelOrg(null)}>
|
||
<div
|
||
className="w-full max-w-md rounded-xl bg-white border border-slate-200 shadow-xl overflow-hidden"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="px-5 py-4 border-b border-slate-100 flex items-start gap-3">
|
||
<div className="w-10 h-10 rounded-full bg-red-50 grid place-items-center shrink-0">
|
||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<h3 className="font-semibold text-slate-900">Delete organization?</h3>
|
||
<p className="mt-1 text-sm text-slate-500">
|
||
Permanently delete <span className="font-semibold text-slate-700">{delOrg.name}</span>{" "}
|
||
<span className="font-mono text-xs">({delOrg.slug})</span>. All projects, artifacts and relationships under this tenant will be removed. This cannot be undone.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{deleteOrgM.isError && (
|
||
<div className="mx-5 mt-4 rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||
{deleteOrgM.error instanceof Error ? deleteOrgM.error.message : "Failed to delete organization."}
|
||
</div>
|
||
)}
|
||
<div className="px-5 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-2">
|
||
<Button type="button" variant="secondary" size="sm" disabled={deleteOrgM.isPending} onClick={() => setDelOrg(null)}>Cancel</Button>
|
||
<Button
|
||
type="button"
|
||
variant="primary"
|
||
size="sm"
|
||
className="!bg-red-600 hover:!bg-red-700 !text-white"
|
||
disabled={deleteOrgM.isPending}
|
||
onClick={() => void deleteOrgM.mutateAsync(String(delOrg.id))}
|
||
>
|
||
{deleteOrgM.isPending ? (
|
||
<><Loader2 className="w-4 h-4 animate-spin" /> Deleting…</>
|
||
) : (<><Trash2 className="w-4 h-4" /> Delete forever</>)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---------- Edit Project modal ---------- */}
|
||
{showEditProj && (
|
||
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !editProjM.isPending && setShowEditProj(false)}>
|
||
<div
|
||
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
|
||
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||
<Pencil className="w-4 h-4 text-brand-600" /> Edit project
|
||
</h3>
|
||
<button
|
||
onClick={() => !editProjM.isPending && setShowEditProj(false)}
|
||
disabled={editProjM.isPending}
|
||
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
|
||
aria-label="close"
|
||
>×</button>
|
||
</div>
|
||
<form
|
||
className="p-5 space-y-3"
|
||
onSubmit={(e) => { e.preventDefault(); void editProjM.mutateAsync(); }}
|
||
>
|
||
<Field label="Name *">
|
||
<input className="asf-input" required autoFocus
|
||
placeholder="ASF Platform MVP"
|
||
value={epName}
|
||
onChange={(e) => setEpName(e.target.value)} />
|
||
</Field>
|
||
<Field label={<>Key * <span className="text-[10px] text-slate-400">(short uppercase code)</span></>}>
|
||
<input className="asf-input font-mono text-sm uppercase tracking-wide"
|
||
placeholder="ASF"
|
||
maxLength={20}
|
||
value={epKey}
|
||
onChange={(e) => setEpKey(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))} />
|
||
<div className={cn("text-[11px] mt-1", epKeyHint && epKeyHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{epKeyHint ?? "2-20 characters. Uppercase letters/digits only — starts with a letter."}</div>
|
||
</Field>
|
||
<Field label="Default branch">
|
||
<input className="asf-input font-mono text-xs"
|
||
placeholder="main"
|
||
value={epBranch}
|
||
onChange={(e) => setEpBranch(e.target.value)} />
|
||
</Field>
|
||
<Field label="Description">
|
||
<textarea className="asf-input" rows={3}
|
||
placeholder="Scope, goals and non-goals of this project."
|
||
value={epDesc}
|
||
onChange={(e) => setEpDesc(e.target.value)} />
|
||
</Field>
|
||
<Field label="Status">
|
||
<Toggle label="Active" hint="Project appears in pickers and filters." checked={epActive} onChange={setEpActive} />
|
||
</Field>
|
||
{editProjM.isError && (
|
||
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||
{editProjM.error instanceof Error ? editProjM.error.message : "Failed to update project."}
|
||
</div>
|
||
)}
|
||
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
|
||
<Button type="button" variant="secondary" size="sm" disabled={editProjM.isPending} onClick={() => setShowEditProj(false)}>Cancel</Button>
|
||
<Button type="submit" variant="primary" size="sm"
|
||
disabled={editProjM.isPending || !epName || !/^[A-Z][A-Z0-9]{1,19}$/.test(epKey)}>
|
||
{editProjM.isPending ? (
|
||
<><Loader2 className="w-4 h-4 animate-spin" /> Saving…</>
|
||
) : (<><Save className="w-4 h-4" /> Save changes</>)}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---------- Delete Project confirm ---------- */}
|
||
{delProj && (
|
||
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !deleteProjM.isPending && setDelProj(null)}>
|
||
<div
|
||
className="w-full max-w-md rounded-xl bg-white border border-slate-200 shadow-xl overflow-hidden"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="px-5 py-4 border-b border-slate-100 flex items-start gap-3">
|
||
<div className="w-10 h-10 rounded-full bg-red-50 grid place-items-center shrink-0">
|
||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<h3 className="font-semibold text-slate-900">Delete project?</h3>
|
||
<p className="mt-1 text-sm text-slate-500">
|
||
Permanently delete <span className="font-semibold text-slate-700">{delProj.name}</span>{" "}
|
||
{delProj.key && <span className="font-mono text-xs">([{delProj.key}])</span>}. All artifacts in this project will be removed. This cannot be undone.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{deleteProjM.isError && (
|
||
<div className="mx-5 mt-4 rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
|
||
{deleteProjM.error instanceof Error ? deleteProjM.error.message : "Failed to delete project."}
|
||
</div>
|
||
)}
|
||
<div className="px-5 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-2">
|
||
<Button type="button" variant="secondary" size="sm" disabled={deleteProjM.isPending} onClick={() => setDelProj(null)}>Cancel</Button>
|
||
<Button
|
||
type="button"
|
||
variant="primary"
|
||
size="sm"
|
||
className="!bg-red-600 hover:!bg-red-700 !text-white"
|
||
disabled={deleteProjM.isPending}
|
||
onClick={() => void deleteProjM.mutateAsync(String(delProj.id))}
|
||
>
|
||
{deleteProjM.isPending ? (
|
||
<><Loader2 className="w-4 h-4 animate-spin" /> Deleting…</>
|
||
) : (<><Trash2 className="w-4 h-4" /> Delete forever</>)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Field({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
|
||
return (
|
||
<label className="block">
|
||
<div className="text-xs font-medium text-slate-500 mb-1.5">{label}</div>
|
||
{children}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function Toggle({
|
||
label, hint, checked, defaultChecked, onChange,
|
||
}: {
|
||
label: string;
|
||
hint?: string;
|
||
checked?: boolean;
|
||
defaultChecked?: boolean;
|
||
onChange?: (v: boolean) => void;
|
||
}) {
|
||
const [internal, setInternal] = useState<boolean>(Boolean(defaultChecked));
|
||
const value = checked !== undefined ? checked : internal;
|
||
const toggle = () => {
|
||
const next = !value;
|
||
if (checked === undefined) setInternal(next);
|
||
onChange?.(next);
|
||
};
|
||
return (
|
||
<div className="flex items-start justify-between gap-4 p-3 rounded-lg border border-slate-100 hover:border-slate-200">
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-medium text-slate-800">{label}</div>
|
||
{hint && <div className="text-xs text-slate-500 mt-0.5">{hint}</div>}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={toggle}
|
||
role="switch"
|
||
aria-checked={value}
|
||
className={cn(
|
||
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-300",
|
||
value ? "bg-brand-600" : "bg-slate-200",
|
||
)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition-transform",
|
||
value ? "translate-x-5" : "translate-x-0",
|
||
)}
|
||
/>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|