settings page modern
This commit is contained in:
parent
e54a29230d
commit
59e739768f
|
|
@ -9,9 +9,10 @@ import { useRouter } from "next/navigation";
|
|||
import { useAuth, useUser } from "@/auth/clerk";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
Bot,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Folder,
|
||||
GitBranch,
|
||||
Globe,
|
||||
|
|
@ -21,6 +22,7 @@ import {
|
|||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
ShieldAlert,
|
||||
SlidersHorizontal,
|
||||
Tags,
|
||||
Trash2,
|
||||
|
|
@ -37,11 +39,13 @@ import {
|
|||
} from "@/api/generated/users/users";
|
||||
import { ApiError, customFetch } from "@/api/mutator";
|
||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import SearchableSelect from "@/components/ui/searchable-select";
|
||||
import { getSupportedTimezones } from "@/lib/timezones";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||
|
||||
type ClerkGlobal = {
|
||||
|
|
@ -53,51 +57,255 @@ type SettingsLink = {
|
|||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
tone: SettingsTone;
|
||||
};
|
||||
|
||||
function SettingsLinkSection({
|
||||
type SettingsTone = "accent" | "success" | "warning" | "danger" | "neutral";
|
||||
|
||||
type SettingsNavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
tone: SettingsTone;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
const toneStyles: Record<
|
||||
SettingsTone,
|
||||
{
|
||||
card: string;
|
||||
icon: string;
|
||||
rail: string;
|
||||
text: string;
|
||||
}
|
||||
> = {
|
||||
accent: {
|
||||
card: "border-[color:rgba(96,165,250,0.24)] bg-[linear-gradient(145deg,rgba(96,165,250,0.12),var(--surface)_58%)]",
|
||||
icon: "border-[color:rgba(96,165,250,0.3)] bg-[color:var(--accent-soft)] text-[color:var(--accent-strong)]",
|
||||
rail: "bg-[linear-gradient(90deg,rgba(96,165,250,0),rgba(96,165,250,0.82),rgba(96,165,250,0))]",
|
||||
text: "text-[color:var(--accent-strong)]",
|
||||
},
|
||||
success: {
|
||||
card: "border-[color:rgba(52,211,153,0.22)] bg-[linear-gradient(145deg,rgba(52,211,153,0.1),var(--surface)_58%)]",
|
||||
icon: "border-[color:rgba(52,211,153,0.3)] bg-[color:rgba(52,211,153,0.14)] text-[color:var(--success)]",
|
||||
rail: "bg-[linear-gradient(90deg,rgba(52,211,153,0),rgba(52,211,153,0.78),rgba(52,211,153,0))]",
|
||||
text: "text-[color:var(--success)]",
|
||||
},
|
||||
warning: {
|
||||
card: "border-[color:rgba(251,191,36,0.24)] bg-[linear-gradient(145deg,rgba(251,191,36,0.1),var(--surface)_58%)]",
|
||||
icon: "border-[color:rgba(251,191,36,0.32)] bg-[color:rgba(251,191,36,0.14)] text-[color:var(--warning)]",
|
||||
rail: "bg-[linear-gradient(90deg,rgba(251,191,36,0),rgba(251,191,36,0.82),rgba(251,191,36,0))]",
|
||||
text: "text-[color:var(--warning)]",
|
||||
},
|
||||
danger: {
|
||||
card: "border-[color:rgba(248,113,113,0.24)] bg-[linear-gradient(145deg,rgba(248,113,113,0.1),var(--surface)_58%)]",
|
||||
icon: "border-[color:rgba(248,113,113,0.3)] bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
|
||||
rail: "bg-[linear-gradient(90deg,rgba(248,113,113,0),rgba(248,113,113,0.78),rgba(248,113,113,0))]",
|
||||
text: "text-[color:var(--danger)]",
|
||||
},
|
||||
neutral: {
|
||||
card: "border-[color:var(--border)] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0)_72%),var(--surface)]",
|
||||
icon: "border-[color:var(--border-strong)] bg-[color:var(--surface-muted)] text-muted",
|
||||
rail: "bg-[linear-gradient(90deg,rgba(96,165,250,0),rgba(96,165,250,0.28),rgba(52,211,153,0.2),rgba(96,165,250,0))]",
|
||||
text: "text-muted",
|
||||
},
|
||||
};
|
||||
|
||||
function StatusBanner({
|
||||
children,
|
||||
tone,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
tone: Exclude<SettingsTone, "neutral">;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "success"
|
||||
? "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]"
|
||||
: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]";
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border p-3 text-sm", toneClass)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPanel({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
icon: Icon,
|
||||
tone = "neutral",
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
tone?: SettingsTone;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
"relative scroll-mt-24 overflow-hidden rounded-xl border p-4 shadow-lush md:p-6",
|
||||
toneStyles[tone].card,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-4 top-0 h-px",
|
||||
toneStyles[tone].rail,
|
||||
)}
|
||||
/>
|
||||
<div className="mb-5 flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border",
|
||||
toneStyles[tone].icon,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold text-strong">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSummaryCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
icon: Icon,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
icon: LucideIcon;
|
||||
tone: SettingsTone;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border p-4 shadow-lush transition hover:-translate-y-0.5 hover:shadow-md",
|
||||
toneStyles[tone].card,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-4 top-0 h-px",
|
||||
toneStyles[tone].rail,
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-2 truncate text-lg font-semibold text-strong">
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-muted">{detail}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border",
|
||||
toneStyles[tone].icon,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsLinkSection({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
tone,
|
||||
items,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
tone: SettingsTone;
|
||||
items: SettingsLink[];
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
<SettingsPanel
|
||||
id={id}
|
||||
title={title}
|
||||
description={description}
|
||||
icon={icon}
|
||||
tone={tone}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="group flex items-center gap-4 py-4 first:pt-0 last:pb-0 focus-visible:outline-none"
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 transition",
|
||||
"hover:-translate-y-0.5 hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-strong)] hover:shadow-md focus-visible:outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]",
|
||||
)}
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground transition group-hover:border-[color:var(--accent)] group-hover:text-[color:var(--accent)] group-focus-visible:ring-2 group-focus-visible:ring-ring">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold text-foreground">
|
||||
{item.label}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-x-4 top-0 h-px opacity-60 transition group-hover:opacity-100",
|
||||
toneStyles[item.tone].rail,
|
||||
)}
|
||||
/>
|
||||
<span className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border",
|
||||
toneStyles[item.tone].icon,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="mt-1 block text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-start justify-between gap-3">
|
||||
<span className="text-sm font-semibold text-strong">
|
||||
{item.label}
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
className={cn(
|
||||
"mt-0.5 h-4 w-4 shrink-0 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5",
|
||||
toneStyles[item.tone].text,
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-1 block text-sm leading-6 text-muted">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground transition group-hover:translate-x-0.5 group-hover:text-[color:var(--accent)]" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +455,7 @@ export default function SettingsPage() {
|
|||
label: "Organization",
|
||||
description: "Manage members, invites, access, and organization details.",
|
||||
icon: Building2,
|
||||
tone: "accent",
|
||||
},
|
||||
{
|
||||
href: "/board-groups",
|
||||
|
|
@ -254,6 +463,7 @@ export default function SettingsPage() {
|
|||
description:
|
||||
"Group related boards so agents and operators share context.",
|
||||
icon: Folder,
|
||||
tone: "success",
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
|
|
@ -262,6 +472,7 @@ export default function SettingsPage() {
|
|||
label: "Tags",
|
||||
description: "Maintain reusable task labels used across boards.",
|
||||
icon: Tags,
|
||||
tone: "warning" as const,
|
||||
},
|
||||
{
|
||||
href: "/custom-fields",
|
||||
|
|
@ -269,6 +480,7 @@ export default function SettingsPage() {
|
|||
description:
|
||||
"Configure organization-level task metadata and board bindings.",
|
||||
icon: SlidersHorizontal,
|
||||
tone: "accent" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
|
@ -281,6 +493,7 @@ export default function SettingsPage() {
|
|||
description:
|
||||
"Manage model credentials, endpoints, and provider usage tracking.",
|
||||
icon: KeyRound,
|
||||
tone: "accent",
|
||||
},
|
||||
{
|
||||
href: "/settings/git-projects",
|
||||
|
|
@ -288,12 +501,14 @@ export default function SettingsPage() {
|
|||
description:
|
||||
"Review Forgejo sync health and Git Project automation settings.",
|
||||
icon: GitBranch,
|
||||
tone: "success",
|
||||
},
|
||||
{
|
||||
href: "/git-projects/connections",
|
||||
label: "Git connections",
|
||||
description: "Connect Git providers used for repository and issue sync.",
|
||||
icon: GitBranch,
|
||||
tone: "warning",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -305,16 +520,72 @@ export default function SettingsPage() {
|
|||
description:
|
||||
"Provision and monitor the agents available to this organization.",
|
||||
icon: Bot,
|
||||
tone: "accent" as const,
|
||||
},
|
||||
{
|
||||
href: "/gateways",
|
||||
label: "Gateways",
|
||||
description: "Manage gateway connections used by boards and agents.",
|
||||
icon: Network,
|
||||
tone: "success" as const,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const settingsNavItems: SettingsNavItem[] = [
|
||||
{
|
||||
href: "#profile",
|
||||
label: "Account",
|
||||
description: "Name, email, and timezone",
|
||||
icon: User,
|
||||
tone: "accent",
|
||||
},
|
||||
{
|
||||
href: "#profile-source",
|
||||
label: "Profile source",
|
||||
description: profile?.use_forgejo_profile
|
||||
? "Forgejo enabled"
|
||||
: "App profile enabled",
|
||||
icon: GitBranch,
|
||||
tone: profile?.use_forgejo_profile ? "success" : "neutral",
|
||||
},
|
||||
{
|
||||
href: "#workspace",
|
||||
label: "Workspace",
|
||||
description: "Organization structure",
|
||||
icon: Building2,
|
||||
tone: "success",
|
||||
count: workspaceItems.length,
|
||||
},
|
||||
{
|
||||
href: "#integrations",
|
||||
label: "Integrations",
|
||||
description: "Models and Git providers",
|
||||
icon: KeyRound,
|
||||
tone: "accent",
|
||||
count: integrationItems.length,
|
||||
},
|
||||
...(operationItems.length
|
||||
? [
|
||||
{
|
||||
href: "#operations",
|
||||
label: "Operations",
|
||||
description: "Runtime admin tools",
|
||||
icon: Network,
|
||||
tone: "warning" as const,
|
||||
count: operationItems.length,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
href: "#danger-zone",
|
||||
label: "Danger zone",
|
||||
description: "Permanent account actions",
|
||||
icon: ShieldAlert,
|
||||
tone: "danger",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPageLayout
|
||||
|
|
@ -325,213 +596,361 @@ export default function SettingsPage() {
|
|||
}}
|
||||
title="Settings"
|
||||
description="Manage your profile, workspace configuration, and integrations."
|
||||
stickyHeader
|
||||
headerActions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/settings/ai-providers">
|
||||
<Button variant="outline" size="sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
AI Providers
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/settings/git-projects">
|
||||
<Button variant="outline" size="sm">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Git Settings
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
mainClassName="relative bg-app"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<h2 className="text-base font-semibold text-foreground">Profile</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Keep your identity and timezone up to date.
|
||||
</p>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-[linear-gradient(135deg,rgba(96,165,250,0.16),rgba(52,211,153,0.1)_38%,rgba(251,191,36,0.08)_64%,rgba(96,165,250,0)_100%)] blur-2xl" />
|
||||
|
||||
<form onSubmit={handleSave} className="mt-6 space-y-5">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
value={resolvedName}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setNameEdited(true);
|
||||
}}
|
||||
placeholder="Your name"
|
||||
disabled={isSaving}
|
||||
className="border-input [--input-bg:var(--card)] [--input-text:var(--card-foreground)] placeholder:text-muted-foreground focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
Timezone
|
||||
</label>
|
||||
<SearchableSelect
|
||||
ariaLabel="Select timezone"
|
||||
value={resolvedTimezone}
|
||||
onValueChange={(value) => {
|
||||
setTimezone(value);
|
||||
setTimezoneEdited(true);
|
||||
}}
|
||||
options={timezoneOptions}
|
||||
placeholder="Select timezone"
|
||||
searchPlaceholder="Search timezones..."
|
||||
emptyMessage="No matching timezones."
|
||||
disabled={isSaving}
|
||||
triggerClassName="h-11 w-full px-3 py-2"
|
||||
contentClassName="shadow-lg"
|
||||
itemClassName="px-4 py-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative space-y-6">
|
||||
<section className="relative overflow-hidden rounded-xl border border-[color:rgba(96,165,250,0.28)] bg-[linear-gradient(135deg,rgba(96,165,250,0.16),rgba(52,211,153,0.09)_34%,rgba(251,191,36,0.08)_68%,var(--surface)_100%)] p-4 shadow-lush md:p-6">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,rgba(96,165,250,0),rgba(96,165,250,0.85),rgba(52,211,153,0.85),rgba(251,191,36,0))]" />
|
||||
<div className="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0">
|
||||
<Badge
|
||||
variant="accent"
|
||||
className="w-fit shadow-[0_0_24px_rgba(96,165,250,0.16)]"
|
||||
>
|
||||
Account & workspace
|
||||
</Badge>
|
||||
<h2 className="mt-3 font-heading text-2xl font-semibold text-strong">
|
||||
Pipeline settings
|
||||
</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted">
|
||||
Account identity, organization structure, provider
|
||||
connections, and runtime administration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
value={displayEmail}
|
||||
readOnly
|
||||
disabled
|
||||
className="border-border [--input-bg:var(--muted)] [--input-text:var(--muted-foreground)]"
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:min-w-[620px]">
|
||||
<SettingsSummaryCard
|
||||
label="Identity"
|
||||
value={resolvedName || "Needs name"}
|
||||
detail={displayEmail || "No email"}
|
||||
icon={User}
|
||||
tone="accent"
|
||||
/>
|
||||
<SettingsSummaryCard
|
||||
label="Workspace"
|
||||
value={`${workspaceItems.length + operationItems.length} areas`}
|
||||
detail={isAdmin ? "Admin access" : "Member access"}
|
||||
icon={Building2}
|
||||
tone="success"
|
||||
/>
|
||||
<SettingsSummaryCard
|
||||
label="Integrations"
|
||||
value={`${integrationItems.length} systems`}
|
||||
detail={
|
||||
profile?.use_forgejo_profile
|
||||
? "Forgejo profile active"
|
||||
: "App profile active"
|
||||
}
|
||||
icon={KeyRound}
|
||||
tone="warning"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{saveError ? (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
|
||||
{saveError}
|
||||
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside className="lg:sticky lg:top-28 lg:self-start">
|
||||
<nav className="relative overflow-hidden rounded-xl border border-[color:var(--border)] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0)_72%),var(--surface)] p-3 shadow-lush">
|
||||
<span className="pointer-events-none absolute inset-x-4 top-0 h-px bg-[linear-gradient(90deg,rgba(96,165,250,0),rgba(96,165,250,0.46),rgba(52,211,153,0.32),rgba(96,165,250,0))]" />
|
||||
<p className="px-2 pb-2 pt-1 text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
Settings
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{settingsNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="group relative flex items-center gap-3 rounded-lg px-2 py-2.5 text-muted transition before:absolute before:inset-y-2 before:left-0 before:w-1 before:rounded-full before:bg-transparent before:transition hover:bg-[color:var(--surface-muted)] hover:text-strong hover:before:bg-[color:var(--border-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md border transition group-hover:scale-105",
|
||||
toneStyles[item.tone].icon,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-semibold text-strong">
|
||||
{item.label}
|
||||
</span>
|
||||
{typeof item.count === "number" ? (
|
||||
<span className="rounded-full bg-[color:var(--surface-strong)] px-2 py-0.5 text-[11px] text-muted">
|
||||
{item.count}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{saveSuccess ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">
|
||||
{saveSuccess}
|
||||
</div>
|
||||
) : null}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? "Saving…" : "Save settings"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={isSaving}
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
|
||||
<SettingsPanel
|
||||
id="profile"
|
||||
title="Account"
|
||||
description="Identity and local time used across Pipeline."
|
||||
icon={User}
|
||||
tone="accent"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<form onSubmit={handleSave} className="space-y-5">
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-muted">
|
||||
<User className="h-4 w-4" />
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
value={resolvedName}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setNameEdited(true);
|
||||
}}
|
||||
placeholder="Your name"
|
||||
disabled={isSaving}
|
||||
className="border-[color:var(--border-strong)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-muted">
|
||||
<Globe className="h-4 w-4" />
|
||||
Timezone
|
||||
</label>
|
||||
<SearchableSelect
|
||||
ariaLabel="Select timezone"
|
||||
value={resolvedTimezone}
|
||||
onValueChange={(value) => {
|
||||
setTimezone(value);
|
||||
setTimezoneEdited(true);
|
||||
}}
|
||||
options={timezoneOptions}
|
||||
placeholder="Select timezone"
|
||||
searchPlaceholder="Search timezones..."
|
||||
emptyMessage="No matching timezones."
|
||||
disabled={isSaving}
|
||||
triggerClassName="h-11 w-full px-3 py-2"
|
||||
contentClassName="shadow-lg"
|
||||
itemClassName="px-4 py-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-muted">
|
||||
<Mail className="h-4 w-4" />
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
value={displayEmail}
|
||||
readOnly
|
||||
disabled
|
||||
className="border-[color:var(--border)] [--input-bg:var(--surface-muted)] [--input-text:var(--text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{saveError ? (
|
||||
<StatusBanner tone="danger">{saveError}</StatusBanner>
|
||||
) : null}
|
||||
{saveSuccess ? (
|
||||
<StatusBanner tone="success">{saveSuccess}</StatusBanner>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? "Saving…" : "Save settings"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SettingsPanel>
|
||||
|
||||
<SettingsPanel
|
||||
id="profile-source"
|
||||
title="Profile source"
|
||||
description="Forgejo identity sync for the app profile."
|
||||
icon={GitBranch}
|
||||
tone={profile?.use_forgejo_profile ? "success" : "neutral"}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{profile?.use_forgejo_profile ? (
|
||||
<div className="rounded-xl border border-[color:rgba(52,211,153,0.24)] bg-[color:rgba(52,211,153,0.06)] p-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{profile.forgejo_avatar_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={profile.forgejo_avatar_url}
|
||||
alt="Forgejo avatar"
|
||||
className="h-10 w-10 rounded-full object-cover ring-2 ring-[color:rgba(52,211,153,0.3)]"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-[color:var(--surface-strong)] text-muted">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold text-strong">
|
||||
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
|
||||
Forgejo profile active
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm text-muted">
|
||||
{profile.forgejo_display_name ??
|
||||
"No Forgejo name returned"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleForgejoSync}
|
||||
disabled={
|
||||
forgejoSyncing || updateMeMutation.isPending
|
||||
}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{forgejoSyncing ? "Syncing…" : "Re-sync"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDisableForgejoProfile}
|
||||
disabled={
|
||||
forgejoSyncing || updateMeMutation.isPending
|
||||
}
|
||||
>
|
||||
Use app profile
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4">
|
||||
<p className="text-sm font-semibold text-strong">
|
||||
App profile active
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Forgejo can supply your display name and avatar.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={handleForgejoSync}
|
||||
disabled={forgejoSyncing}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{forgejoSyncing ? "Syncing…" : "Use Forgejo profile"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{forgejoSyncError ? (
|
||||
<StatusBanner tone="danger">
|
||||
{forgejoSyncError}
|
||||
</StatusBanner>
|
||||
) : null}
|
||||
{forgejoSyncSuccess ? (
|
||||
<StatusBanner tone="success">
|
||||
{forgejoSyncSuccess}
|
||||
</StatusBanner>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
Profile Source
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Use your Forgejo account name and avatar throughout the app.
|
||||
Requires an active Forgejo connection.
|
||||
</p>
|
||||
<SettingsLinkSection
|
||||
id="workspace"
|
||||
title="Workspace"
|
||||
description="Set up the organization structure behind your boards."
|
||||
icon={Building2}
|
||||
tone="success"
|
||||
items={workspaceItems}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{profile?.use_forgejo_profile ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{profile.forgejo_avatar_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={profile.forgejo_avatar_url}
|
||||
alt="Forgejo avatar"
|
||||
className="h-9 w-9 rounded-full object-cover ring-2 ring-border"
|
||||
/>
|
||||
) : null}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Using Forgejo profile:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{profile.forgejo_display_name ?? "—"}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleForgejoSync}
|
||||
disabled={forgejoSyncing || updateMeMutation.isPending}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{forgejoSyncing ? "Syncing…" : "Re-sync"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDisableForgejoProfile}
|
||||
disabled={forgejoSyncing || updateMeMutation.isPending}
|
||||
>
|
||||
Use app profile
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleForgejoSync}
|
||||
disabled={forgejoSyncing}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{forgejoSyncing ? "Syncing…" : "Use Forgejo profile"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<SettingsLinkSection
|
||||
id="integrations"
|
||||
title="Integrations"
|
||||
description="Connect external systems and configure model providers."
|
||||
icon={KeyRound}
|
||||
tone="accent"
|
||||
items={integrationItems}
|
||||
/>
|
||||
|
||||
{forgejoSyncError ? (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
|
||||
{forgejoSyncError}
|
||||
</div>
|
||||
) : null}
|
||||
{forgejoSyncSuccess ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">
|
||||
{forgejoSyncSuccess}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<SettingsLinkSection
|
||||
id="operations"
|
||||
title="Operations"
|
||||
description="Admin tools for runtime infrastructure and agent access."
|
||||
icon={Network}
|
||||
tone="warning"
|
||||
items={operationItems}
|
||||
/>
|
||||
|
||||
<SettingsLinkSection
|
||||
title="Workspace"
|
||||
description="Set up the organization structure behind your boards."
|
||||
items={workspaceItems}
|
||||
/>
|
||||
|
||||
<SettingsLinkSection
|
||||
title="Integrations"
|
||||
description="Connect external systems and configure model providers."
|
||||
items={integrationItems}
|
||||
/>
|
||||
|
||||
<SettingsLinkSection
|
||||
title="Operations"
|
||||
description="Admin tools for runtime infrastructure and agent access."
|
||||
items={operationItems}
|
||||
/>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
Delete account
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
This permanently removes your Pipeline account and related
|
||||
personal data. This action cannot be undone.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
setDeleteError(null);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
disabled={deleteAccountMutation.isPending}
|
||||
<SettingsPanel
|
||||
id="danger-zone"
|
||||
title="Delete account"
|
||||
description="Permanently remove your Pipeline account and related personal data."
|
||||
icon={ShieldAlert}
|
||||
tone="danger"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete account
|
||||
</Button>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted">
|
||||
This action cannot be undone. Your account is removed from
|
||||
Pipeline after confirmation.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
setDeleteError(null);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
disabled={deleteAccountMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete account
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPageLayout>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue