Files
stack/apps/web/src/app/(authenticated)/settings/page.tsx
Jason Woltje 6521f655a8
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
feat(web): add teams page and RBAC navigation/route gating (MS21-UI-005, RBAC-001, RBAC-002) (#595)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 04:54:25 +00:00

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 &rarr;
</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>
);
}