Files
2026-09-08 07:21:34 -04:00

907 lines
46 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}