All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
344 lines
9.3 KiB
TypeScript
344 lines
9.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import type { ReactElement, ReactNode } from "react";
|
|
import { WorkspaceMemberRole } from "@mosaic/shared";
|
|
import { useAuth } from "@/lib/auth/auth-context";
|
|
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
|
|
import Link from "next/link";
|
|
|
|
interface CategoryConfig {
|
|
title: string;
|
|
description: string;
|
|
href: string;
|
|
accent: string;
|
|
iconBg: string;
|
|
icon: ReactNode;
|
|
adminOnly?: boolean;
|
|
}
|
|
|
|
interface SettingsCategoryCardProps {
|
|
category: CategoryConfig;
|
|
}
|
|
|
|
function SettingsCategoryCard({ category }: SettingsCategoryCardProps): ReactElement {
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
return (
|
|
<Link href={category.href} style={{ textDecoration: "none" }}>
|
|
<div
|
|
onMouseEnter={(): void => {
|
|
setHovered(true);
|
|
}}
|
|
onMouseLeave={(): void => {
|
|
setHovered(false);
|
|
}}
|
|
style={{
|
|
background: hovered ? "var(--surface-2)" : "var(--surface)",
|
|
border: `1px solid ${hovered ? category.accent : "var(--border)"}`,
|
|
borderRadius: "var(--r-lg)",
|
|
padding: 20,
|
|
transition: "background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease",
|
|
boxShadow: hovered ? "0 4px 16px rgba(0,0,0,0.2)" : "none",
|
|
cursor: "pointer",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 12,
|
|
height: "100%",
|
|
}}
|
|
>
|
|
{/* Icon well */}
|
|
<div
|
|
style={{
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: "var(--r)",
|
|
background: category.iconBg,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: category.accent,
|
|
transition: "transform 0.15s ease",
|
|
transform: hovered ? "scale(1.05)" : "scale(1)",
|
|
}}
|
|
>
|
|
{category.icon}
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<div style={{ fontSize: "1rem", fontWeight: 700, color: "var(--text)" }}>
|
|
{category.title}
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div
|
|
style={{
|
|
fontSize: "0.83rem",
|
|
color: "var(--muted)",
|
|
lineHeight: 1.55,
|
|
}}
|
|
>
|
|
{category.description}
|
|
</div>
|
|
|
|
{/* CTA */}
|
|
<div
|
|
style={{
|
|
fontSize: "0.83rem",
|
|
color: hovered ? category.accent : "var(--muted)",
|
|
fontWeight: 500,
|
|
marginTop: "auto",
|
|
transition: "color 0.15s ease",
|
|
}}
|
|
>
|
|
Manage →
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
const categories: CategoryConfig[] = [
|
|
{
|
|
title: "Appearance",
|
|
description:
|
|
"Choose a theme for the interface. Switch between Dark, Light, Nord, Dracula, and more.",
|
|
href: "/settings/appearance",
|
|
accent: "var(--ms-pink-500)",
|
|
iconBg: "rgba(236, 72, 153, 0.12)",
|
|
icon: (
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 20 20"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="10" cy="10" r="7.5" />
|
|
<path d="M10 2.5v15" />
|
|
<path d="M10 2.5a7.5 7.5 0 0 1 0 15" fill="currentColor" opacity="0.15" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
title: "Credentials",
|
|
description:
|
|
"Securely store and manage API keys, tokens, and passwords used by agents and integrations.",
|
|
href: "/settings/credentials",
|
|
accent: "var(--ms-blue-400)",
|
|
iconBg: "rgba(47, 128, 255, 0.12)",
|
|
icon: (
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 20 20"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<rect x="5" y="9" width="10" height="8" rx="1.5" />
|
|
<path d="M7 9V6a3 3 0 0 1 6 0v3" />
|
|
<circle cx="10" cy="13" r="1" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
title: "Domains",
|
|
description:
|
|
"Organize tasks and projects by life areas or functional domains within your workspace.",
|
|
href: "/settings/domains",
|
|
accent: "var(--ms-teal-400)",
|
|
iconBg: "rgba(20, 184, 166, 0.12)",
|
|
icon: (
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 20 20"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="10" cy="10" r="7.5" />
|
|
<line x1="2.5" y1="10" x2="17.5" y2="10" />
|
|
<path d="M10 2.5c2 2.5 3 5 3 7.5s-1 5-3 7.5" />
|
|
<path d="M10 2.5c-2 2.5-3 5-3 7.5s1 5 3 7.5" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
title: "AI Personalities",
|
|
description:
|
|
"Customize how the AI assistant communicates \u2014 tone, formality, and response style.",
|
|
href: "/settings/personalities",
|
|
accent: "var(--ms-purple-400)",
|
|
iconBg: "rgba(139, 92, 246, 0.12)",
|
|
icon: (
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 20 20"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="10" cy="6" r="3" />
|
|
<path d="M4 17c0-3.3 2.7-6 6-6s6 2.7 6 6" />
|
|
<path d="M14 10l1.5 1.5 3-3" stroke="currentColor" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
title: "Users",
|
|
description: "Invite, manage roles, and deactivate users across your workspaces.",
|
|
href: "/settings/users",
|
|
adminOnly: true,
|
|
accent: "var(--ms-green-400)",
|
|
iconBg: "rgba(34, 197, 94, 0.12)",
|
|
icon: (
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 20 20"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="8" cy="7" r="2.5" />
|
|
<circle cx="13.5" cy="8.5" r="2" />
|
|
<path d="M3.5 16c0-2.5 2-4.5 4.5-4.5s4.5 2 4.5 4.5" />
|
|
<path d="M12 13.8c.5-.8 1.4-1.3 2.5-1.3 1.7 0 3 1.3 3 3" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
title: "Teams",
|
|
description: "Create and manage teams within your active workspace.",
|
|
href: "/settings/teams",
|
|
adminOnly: true,
|
|
accent: "var(--ms-blue-400)",
|
|
iconBg: "rgba(47, 128, 255, 0.12)",
|
|
icon: (
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 20 20"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="7" cy="7" r="2.25" />
|
|
<circle cx="13" cy="7" r="2.25" />
|
|
<path d="M3 15c0-2.2 1.8-4 4-4s4 1.8 4 4" />
|
|
<path d="M9 15c0-2.2 1.8-4 4-4s4 1.8 4 4" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
title: "Workspaces",
|
|
description:
|
|
"Create and manage workspaces to organize projects and collaborate with your team.",
|
|
href: "/settings/workspaces",
|
|
accent: "var(--ms-amber-400)",
|
|
iconBg: "rgba(245, 158, 11, 0.12)",
|
|
icon: (
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 20 20"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="10" cy="10" r="2" />
|
|
<circle cx="4" cy="5" r="1.5" />
|
|
<circle cx="16" cy="5" r="1.5" />
|
|
<circle cx="16" cy="15" r="1.5" />
|
|
<line x1="8.3" y1="8.7" x2="5.3" y2="6.2" />
|
|
<line x1="11.7" y1="8.7" x2="14.7" y2="6.2" />
|
|
<line x1="11.7" y1="11.3" x2="14.7" y2="13.8" />
|
|
</svg>
|
|
),
|
|
},
|
|
];
|
|
|
|
const ADMIN_ROLES: WorkspaceMemberRole[] = [WorkspaceMemberRole.OWNER, WorkspaceMemberRole.ADMIN];
|
|
|
|
export default function SettingsPage(): ReactElement {
|
|
const { user } = useAuth();
|
|
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
|
|
|
const checkRole = useCallback(async (): Promise<void> => {
|
|
if (user === null) return;
|
|
try {
|
|
const workspaces = await fetchUserWorkspaces();
|
|
const hasAdminRole = workspaces.some((ws) => ADMIN_ROLES.includes(ws.role));
|
|
setIsAdmin(hasAdminRole);
|
|
} catch {
|
|
// Fail open — show all items if we can't determine role
|
|
setIsAdmin(true);
|
|
}
|
|
}, [user]);
|
|
|
|
useEffect(() => {
|
|
void checkRole();
|
|
}, [checkRole]);
|
|
|
|
const visibleCategories = categories.filter((c) => c.adminOnly !== true || isAdmin);
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto p-6">
|
|
{/* Page header */}
|
|
<div style={{ marginBottom: 24 }}>
|
|
<h1
|
|
style={{
|
|
fontSize: "1.875rem",
|
|
fontWeight: 700,
|
|
color: "var(--text)",
|
|
margin: 0,
|
|
}}
|
|
>
|
|
Settings
|
|
</h1>
|
|
<p
|
|
style={{
|
|
fontSize: "0.9rem",
|
|
color: "var(--muted)",
|
|
margin: "8px 0 0 0",
|
|
}}
|
|
>
|
|
Configure your workspace, credentials, and preferences
|
|
</p>
|
|
</div>
|
|
|
|
{/* Category grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{visibleCategories.map((category) => (
|
|
<SettingsCategoryCard key={category.href} category={category} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|