This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
+906
View File
@@ -0,0 +1,906 @@
"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>
);
}