26.2-pre2

This commit is contained in:
1tzEmerald committed 2026-08-06 17:00:12 +01:00
1 parent ba41125eae
commit 6165f2d4f3
13 files changed
+2869 -5314

No files matched your search

-5018
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -16,6 +16,7 @@ import Logs from "./pages/Logs";
import Transcripts from "./pages/Transcripts";
import Tickets from "./pages/Tickets";
import Users from "./pages/Users";
import { Groups } from "./pages/Groups";
function Protected({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
@@ -115,6 +116,14 @@ export default function App() {
</RequirePermission>
}
/>
<Route
path="groups"
element={
<RequirePermission permission="users.view">
<Groups />
</RequirePermission>
}
/>
<Route
path="addons"
element={
+118 -1
View File
@@ -1,11 +1,12 @@
import type {
DashboardGroup,
DashboardPermissions,
DashboardRole,
DashboardUser,
StoredDashboardUser,
} from "@/lib/permissions";
export type { DashboardUser, DashboardPermissions, StoredDashboardUser, DashboardRole };
export type { DashboardUser, DashboardPermissions, StoredDashboardUser, DashboardRole, DashboardGroup };
const REQUEST_TIMEOUT_MS = 25_000;
@@ -18,6 +19,11 @@ async function request<T>(
headers.set("Content-Type", "application/json");
}
const impersonatedGroup = typeof window !== "undefined" ? sessionStorage.getItem("nexus_impersonate_group") : null;
if (impersonatedGroup && !headers.has("X-Impersonate-Group")) {
headers.set("X-Impersonate-Group", impersonatedGroup);
}
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -343,6 +349,78 @@ export const api = {
}>
>("/api/system/tickets/open"),
getTicketMessages: (ticketId: string) =>
request<
ApiResponse<{
ticketId: string;
messages: {
id: string;
author: {
id: string;
username: string;
globalName: string;
avatar: string;
bot: boolean;
isDashboard?: boolean;
};
content: string;
embeds?: { title?: string; description?: string; color?: string | null }[];
attachments?: { id: string; name: string; url: string; contentType?: string }[];
createdAt: number;
}[];
}>
>(`/api/system/tickets/${encodeURIComponent(ticketId)}/messages`),
sendTicketReply: (
ticketId: string,
message: string,
staffName?: string,
attachments?: { filename: string; data: string }[],
) =>
request<
ApiResponse<{
id: string;
author: {
id: string;
username: string;
globalName: string;
avatar: string;
bot: boolean;
isDashboard?: boolean;
};
content: string;
attachments?: { id: string; name: string; url: string; contentType?: string }[];
createdAt: number;
}>
>(`/api/system/tickets/${encodeURIComponent(ticketId)}/reply`, {
method: "POST",
body: JSON.stringify({ message, staffName, attachments }),
}),
deleteTicketMessage: (ticketId: string, messageId: string) =>
request<ApiResponse<unknown>>(
`/api/system/tickets/${encodeURIComponent(ticketId)}/messages/${encodeURIComponent(messageId)}`,
{ method: "DELETE" },
),
editTicketMessage: (ticketId: string, messageId: string, content: string) =>
request<ApiResponse<{ id: string; content: string }>>(
`/api/system/tickets/${encodeURIComponent(ticketId)}/messages/${encodeURIComponent(messageId)}`,
{
method: "PATCH",
body: JSON.stringify({ content }),
},
),
closeTicket: (ticketId: string, reason?: string) =>
request<ApiResponse<unknown>>(
`/api/system/tickets/${encodeURIComponent(ticketId)}/close`,
{
method: "POST",
body: JSON.stringify({ reason }),
},
),
getTranscript: (id: string) =>
request<ApiResponse<string>>(`/api/system/transcripts/${id}`),
@@ -533,6 +611,45 @@ export const api = {
method: "DELETE",
}),
listGroups: () =>
request<
ApiResponse<{
groups: DashboardGroup[];
}>
>("/api/dashboard-groups"),
createGroup: (body: {
name: string;
description?: string;
color?: string;
discordRoleId?: string | null;
permissions?: Partial<DashboardPermissions>;
}) =>
request<ApiResponse<DashboardGroup>>("/api/dashboard-groups", {
method: "POST",
body: JSON.stringify(body),
}),
updateGroup: (
id: string,
body: {
name?: string;
description?: string;
color?: string;
discordRoleId?: string | null;
permissions?: Partial<DashboardPermissions>;
},
) =>
request<ApiResponse<DashboardGroup>>(`/api/dashboard-groups/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
deleteGroup: (id: string) =>
request<ApiResponse<unknown>>(`/api/dashboard-groups/${id}`, {
method: "DELETE",
}),
getAddonsCatalog: () =>
request<
ApiResponse<{
+3 -1
View File
@@ -11,6 +11,7 @@ import {
PlusSignIcon,
PackageIcon,
Settings02Icon,
Shield01Icon,
Ticket01Icon,
UserMultiple02Icon,
} from "@hugeicons/core-free-icons";
@@ -69,7 +70,8 @@ const sections: {
label: "Access & Logs",
items: [
{ title: "Users", url: "/users", icon: UserMultiple02Icon, permission: "users.view" },
{ title: "Tickets", url: "/tickets", icon: Ticket01Icon, permission: "transcripts" },
{ title: "Groups", url: "/groups", icon: Shield01Icon, permission: "users.view" },
{ title: "Tickets", url: "/tickets", icon: Ticket01Icon, permission: "overview" },
{ title: "Transcripts", url: "/transcripts", icon: File02Icon, permission: "transcripts" },
],
},
+152 -71
View File
@@ -17,11 +17,29 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarTrigger } from "@/components/ui/sidebar";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { displayDiscordName, useAuth } from "@/context/AuthContext";
export function DashboardHeader() {
const { stats } = useBotStats();
const { user, logout } = useAuth();
const {
user,
logout,
availableGroups,
isImpersonating,
impersonatedGroup,
impersonateGroup,
stopImpersonation,
hasPermission,
} = useAuth();
const canImpersonate = user?.isOwner || hasPermission("users.manage");
const botName = stats?.bot.username ?? "SupportBot";
const accountName = user ? displayDiscordName(user) : botName;
const accountAvatar = user?.avatar ?? stats?.bot.avatar;
@@ -34,84 +52,147 @@ export function DashboardHeader() {
}).format(new Date());
return (
<header className="relative z-40 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card px-4 md:px-6">
<SidebarTrigger className="-ml-1 text-muted-foreground hover:text-foreground md:hidden" />
<>
{/* Impersonation Preview Mode Banner */}
{isImpersonating && impersonatedGroup && (
<div className="relative z-50 flex items-center justify-between gap-3 bg-gradient-to-r from-amber-500/10 via-amber-500/15 to-amber-500/10 border-b border-amber-500/30 px-4 py-2 text-xs backdrop-blur-md shadow-sm">
<div className="flex items-center gap-2.5">
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 text-[11px] font-bold tracking-wide uppercase border border-amber-500/30">
<span className="size-1.5 rounded-full bg-amber-400 animate-pulse" />
Preview Mode
</span>
<div className="flex items-center gap-2 text-foreground font-medium">
<span className="text-muted-foreground">Viewing dashboard as:</span>
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-secondary/80 border border-border/80">
<span
className="size-2 rounded-full shrink-0 shadow-sm"
style={{ backgroundColor: impersonatedGroup.color || "#10B981" }}
/>
<span className="font-semibold text-xs text-foreground">
{impersonatedGroup.name}
</span>
</div>
</div>
</div>
<p className="hidden shrink-0 text-sm font-medium text-muted-foreground lg:block">
{dateLabel}
</p>
<div className="flex items-center gap-2.5">
{availableGroups.length > 1 && (
<Select
value={impersonatedGroup.id}
onValueChange={(val) => impersonateGroup(val)}
>
<SelectTrigger className="h-7 text-xs bg-background/80 border-amber-500/30 text-amber-200 focus:ring-amber-500/40 w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-border bg-card">
{availableGroups.map((g) => (
<SelectItem key={g.id} value={g.id}>
<div className="flex items-center gap-2">
<span
className="size-2 rounded-full shrink-0"
style={{ backgroundColor: g.color || "#10B981" }}
/>
<span>{g.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<DashboardSearch className="min-w-0 flex-1" />
<div className="ml-auto flex items-center gap-2">
{inviteUrl ? (
<Button
variant="ghost"
size="icon"
className="hidden size-9 text-muted-foreground sm:inline-flex"
asChild
>
<a
href={inviteUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="Invite bot to a server"
title="Invite bot to a server"
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-xs bg-card border-amber-500/40 text-amber-300 hover:bg-amber-500/20 hover:text-white transition-all shadow-sm"
onClick={stopImpersonation}
>
<Icon icon={PlusSignIcon} size={18} />
</a>
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="hidden size-9 text-muted-foreground sm:inline-flex"
disabled
aria-label="Invite bot to a server"
title="Invite link available when the bot is online"
>
<Icon icon={PlusSignIcon} size={18} />
</Button>
)}
<NotificationBell />
Exit Preview Mode
</Button>
</div>
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<header className="relative z-40 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card px-4 md:px-6">
<SidebarTrigger className="-ml-1 text-muted-foreground hover:text-foreground md:hidden" />
<p className="hidden shrink-0 text-sm font-medium text-muted-foreground lg:block">
{dateLabel}
</p>
<DashboardSearch className="min-w-0 flex-1" />
<div className="ml-auto flex items-center gap-2">
{inviteUrl ? (
<Button
variant="ghost"
className="h-9 gap-2 rounded-full px-2 hover:bg-secondary"
size="icon"
className="hidden size-9 text-muted-foreground sm:inline-flex"
asChild
>
<Avatar className="size-7">
<AvatarImage src={accountAvatar} alt={accountName} />
<AvatarFallback className="bg-primary/20 text-xs text-primary">
{accountName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="hidden max-w-[100px] truncate text-sm font-medium sm:inline">
{accountName}
</span>
<Icon icon={ArrowDown01Icon} size={16} className="text-muted-foreground" />
<a
href={inviteUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="Invite bot to a server"
title="Invite bot to a server"
>
<Icon icon={PlusSignIcon} size={18} />
</a>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Account</DropdownMenuLabel>
<DropdownMenuSeparator />
{user ? (
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
{user.username}
{user.id ? ` · ${user.id}` : ""}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={() => {
void logout();
}}
) : (
<Button
variant="ghost"
size="icon"
className="hidden size-9 text-muted-foreground sm:inline-flex"
disabled
aria-label="Invite bot to a server"
title="Invite link available when the bot is online"
>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<Icon icon={PlusSignIcon} size={18} />
</Button>
)}
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-9 gap-2 rounded-full px-2 hover:bg-secondary"
>
<Avatar className="size-7">
<AvatarImage src={accountAvatar} alt={accountName} />
<AvatarFallback className="bg-primary/20 text-xs text-primary">
{accountName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="hidden max-w-[100px] truncate text-sm font-medium sm:inline">
{accountName}
</span>
<Icon icon={ArrowDown01Icon} size={16} className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Account</DropdownMenuLabel>
<DropdownMenuSeparator />
{user ? (
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
{user.username}
{user.id ? ` · ${user.id}` : ""}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
void logout();
}}
>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
</>
);
}
+66 -8
View File
@@ -13,6 +13,7 @@ import {
canViewConfig,
displayDiscordName,
hasPermission,
type DashboardGroup,
type DashboardPermissions,
type DashboardUser,
} from "../lib/permissions";
@@ -22,6 +23,11 @@ interface AuthContextValue {
permissions: DashboardPermissions | null;
isAuthenticated: boolean;
loading: boolean;
availableGroups: DashboardGroup[];
isImpersonating: boolean;
impersonatedGroup: DashboardGroup | null;
impersonateGroup: (groupId: string) => void;
stopImpersonation: () => void;
loginWithDiscord: () => void;
logout: () => Promise<void>;
refreshSession: () => Promise<boolean>;
@@ -32,15 +38,28 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | null>(null);
const STORAGE_KEY = "nexus_impersonate_group";
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<DashboardUser | null>(null);
const [groups, setGroups] = useState<DashboardGroup[]>([]);
const [loading, setLoading] = useState(true);
const [impersonatedGroupId, setImpersonatedGroupId] = useState<string | null>(
() => (typeof window !== "undefined" ? sessionStorage.getItem(STORAGE_KEY) : null)
);
const refreshSession = useCallback(async () => {
try {
const res = await api.me();
if (res.data) {
setUser(res.data);
const [userRes, groupsRes] = await Promise.all([
api.me(),
api.listGroups().catch(() => ({ data: { groups: [] } })),
]);
if (userRes.data) {
setUser(userRes.data);
if (groupsRes.data?.groups) {
setGroups(groupsRes.data.groups);
}
return true;
}
setUser(null);
@@ -61,27 +80,56 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const logout = useCallback(async () => {
try {
if (typeof window !== "undefined") sessionStorage.removeItem(STORAGE_KEY);
setImpersonatedGroupId(null);
await api.logout();
} finally {
setUser(null);
}
}, []);
const permissions = user?.permissions ?? null;
const impersonateGroup = useCallback((groupId: string) => {
if (typeof window !== "undefined") {
sessionStorage.setItem(STORAGE_KEY, groupId);
}
setImpersonatedGroupId(groupId);
}, []);
const stopImpersonation = useCallback(() => {
if (typeof window !== "undefined") {
sessionStorage.removeItem(STORAGE_KEY);
}
setImpersonatedGroupId(null);
}, []);
const impersonatedGroup = useMemo(
() => groups.find((g) => g.id === impersonatedGroupId) || null,
[groups, impersonatedGroupId]
);
const isImpersonating = Boolean(impersonatedGroupId && impersonatedGroup);
// Effective permissions: use impersonated group's permissions if active, else real user permissions
const permissions = useMemo(() => {
if (isImpersonating && impersonatedGroup) {
return impersonatedGroup.permissions;
}
return user?.permissions ?? null;
}, [isImpersonating, impersonatedGroup, user]);
const checkPermission = useCallback(
(key: string) => hasPermission(permissions, key),
[permissions],
[permissions]
);
const checkViewConfig = useCallback(
(file: string) => canViewConfig(permissions, file),
[permissions],
[permissions]
);
const checkEditConfig = useCallback(
(file: string) => canEditConfig(permissions, file),
[permissions],
[permissions]
);
const value = useMemo(
@@ -90,6 +138,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
permissions,
isAuthenticated: !!user,
loading,
availableGroups: groups,
isImpersonating,
impersonatedGroup,
impersonateGroup,
stopImpersonation,
loginWithDiscord,
logout,
refreshSession,
@@ -101,13 +154,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
user,
permissions,
loading,
groups,
isImpersonating,
impersonatedGroup,
impersonateGroup,
stopImpersonation,
loginWithDiscord,
logout,
refreshSession,
checkPermission,
checkViewConfig,
checkEditConfig,
],
]
);
return (
+172
View File
@@ -0,0 +1,172 @@
import { useEffect, useRef, useState } from "react";
export interface StreamMetrics {
timestamp: number;
ping: number;
ram_percent: number;
ram_used_mb: number;
ram_total_mb: number;
cpu_load: number;
uptime: number;
}
export interface StreamLogEntry {
type: "Output" | "Warn" | "Error";
timestamp: string;
message: string;
}
export interface StreamTicketMessage {
ticketId: string;
message: {
id: string;
author: {
id: string;
username: string;
globalName: string;
avatar: string;
bot: boolean;
isDashboard?: boolean;
};
content: string;
embeds?: { title?: string; description?: string; color?: string | null }[];
attachments?: { id: string; name: string; url: string; contentType?: string }[];
createdAt: number;
};
}
export interface StreamTicketMessageDeleted {
ticketId: string;
messageId: string;
}
export interface StreamTicketMessageUpdated {
ticketId: string;
messageId: string;
content: string;
}
export interface StreamTicketCreated {
ticket_id: string;
user_id: string;
subject: string;
description: string;
department: string;
priority: string;
created_at: number;
}
export interface StreamTicketClosed {
ticket_id: string;
}
export interface StreamHandlers {
onMetrics?: (metrics: StreamMetrics) => void;
onLog?: (log: StreamLogEntry) => void;
onTicketMessage?: (msg: StreamTicketMessage) => void;
onTicketMessageDeleted?: (data: StreamTicketMessageDeleted) => void;
onTicketMessageUpdated?: (data: StreamTicketMessageUpdated) => void;
onTicketCreated?: (ticket: StreamTicketCreated) => void;
onTicketClosed?: (ticket: StreamTicketClosed) => void;
}
export function useRealtimeStream(handlers: StreamHandlers) {
const [connected, setConnected] = useState(false);
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
useEffect(() => {
let eventSource: EventSource | null = null;
let timerId: number | null = null;
const connect = () => {
eventSource = new EventSource("/api/stream");
eventSource.onopen = () => {
setConnected(true);
};
eventSource.addEventListener("connected", () => {
setConnected(true);
});
eventSource.addEventListener("metrics", (ev) => {
try {
const data: StreamMetrics = JSON.parse(ev.data);
handlersRef.current.onMetrics?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.addEventListener("log", (ev) => {
try {
const data: StreamLogEntry = JSON.parse(ev.data);
handlersRef.current.onLog?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.addEventListener("ticket_message", (ev) => {
try {
const data: StreamTicketMessage = JSON.parse(ev.data);
handlersRef.current.onTicketMessage?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.addEventListener("ticket_message_deleted", (ev) => {
try {
const data: StreamTicketMessageDeleted = JSON.parse(ev.data);
handlersRef.current.onTicketMessageDeleted?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.addEventListener("ticket_message_updated", (ev) => {
try {
const data: StreamTicketMessageUpdated = JSON.parse(ev.data);
handlersRef.current.onTicketMessageUpdated?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.addEventListener("ticket_created", (ev) => {
try {
const data: StreamTicketCreated = JSON.parse(ev.data);
handlersRef.current.onTicketCreated?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.addEventListener("ticket_closed", (ev) => {
try {
const data: StreamTicketClosed = JSON.parse(ev.data);
handlersRef.current.onTicketClosed?.(data);
} catch {
// Ignore JSON parse errors
}
});
eventSource.onerror = () => {
setConnected(false);
eventSource?.close();
timerId = window.setTimeout(connect, 4000);
};
};
connect();
return () => {
if (timerId) window.clearTimeout(timerId);
if (eventSource) eventSource.close();
};
}, []);
return { connected };
}
+20 -1
View File
@@ -23,6 +23,7 @@ export interface ConfigFilePermission {
export interface DashboardPermissions {
overview: boolean;
tickets: boolean;
logs: boolean;
transcripts: boolean;
settings: { view: boolean; update: boolean };
@@ -35,7 +36,8 @@ export interface DashboardUser {
username: string;
globalName: string | null;
avatar: string;
role: DashboardRole;
role: string;
groupId?: string;
isOwner: boolean;
permissions: DashboardPermissions;
}
@@ -47,6 +49,18 @@ export interface StoredDashboardUser extends DashboardUser {
updatedAt: number;
}
export interface DashboardGroup {
id: string;
name: string;
description: string;
color: string;
discordRoleId: string | null;
isSystem: boolean;
permissions: DashboardPermissions;
createdAt?: number;
updatedAt?: number;
}
export const ROLE_LABELS: Record<DashboardRole, string> = {
owner: "Owner",
admin: "Admin",
@@ -80,6 +94,7 @@ export function hasPermission(
if (!permissions) return false;
if (key === "overview") return permissions.overview;
if (key === "tickets") return permissions.tickets ?? permissions.overview;
if (key === "logs") return permissions.logs;
if (key === "transcripts") return permissions.transcripts;
if (key === "settings.view") return permissions.settings.view;
@@ -135,6 +150,7 @@ function configMap(view: boolean, edit: boolean): Record<ConfigFile, ConfigFileP
export function fullPermissions(): DashboardPermissions {
return {
overview: true,
tickets: true,
logs: true,
transcripts: true,
settings: { view: true, update: true },
@@ -154,6 +170,7 @@ export function defaultPermissionsForRole(
case "moderator":
return {
overview: true,
tickets: true,
logs: true,
transcripts: true,
settings: { view: true, update: false },
@@ -163,6 +180,7 @@ export function defaultPermissionsForRole(
case "editor":
return {
overview: true,
tickets: true,
logs: true,
transcripts: false,
settings: { view: true, update: false },
@@ -172,6 +190,7 @@ export function defaultPermissionsForRole(
case "viewer":
return {
overview: true,
tickets: true,
logs: true,
transcripts: true,
settings: { view: true, update: false },
+639
View File
@@ -0,0 +1,639 @@
import React, { useEffect, useState } from "react";
import {
Add01Icon,
Delete02Icon,
PencilEdit01Icon,
Shield01Icon,
UserMultiple02Icon,
Tick01Icon,
} from "@hugeicons/core-free-icons";
import { api } from "@/api/client";
import { useAuth } from "@/context/AuthContext";
import type { DashboardGroup, DashboardPermissions } from "@/lib/permissions";
import { defaultPermissionsForRole } from "@/lib/permissions";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { ColorPicker } from "@/components/ui/color-picker";
const COLOR_PRESETS = [
{ name: "Emerald", hex: "#10B981" },
{ name: "Blue", hex: "#3B82F6" },
{ name: "Purple", hex: "#8B5CF6" },
{ name: "Amber", hex: "#F59E0B" },
{ name: "Red", hex: "#EF4444" },
{ name: "Cyan", hex: "#06B6D4" },
{ name: "Gray", hex: "#6B7280" },
];
function PermissionEditor({
permissions,
onChange,
disabled,
}: {
permissions: DashboardPermissions;
onChange: (next: DashboardPermissions) => void;
disabled?: boolean;
}) {
const set = (patch: Partial<DashboardPermissions>) =>
onChange({ ...permissions, ...patch });
const PERM_ITEMS: {
key: "overview" | "tickets" | "logs" | "transcripts";
title: string;
description: string;
}[] = [
{
key: "overview",
title: "Dashboard Overview",
description: "Access to system metrics, bot health status, and main dashboard stats.",
},
{
key: "tickets",
title: "Tickets",
description: "View open support tickets, live web chat, send staff replies, and close tickets.",
},
{
key: "transcripts",
title: "Transcripts",
description: "Access saved ticket HTML transcripts and configure public transcript options.",
},
{
key: "logs",
title: "Console Logs",
description: "View real-time bot terminal output, system logs, and runtime error traces.",
},
];
const CONFIG_ITEMS: [string, string, string][] = [
["supportbot", "Bot config", "Main bot token, prefix, channels, categories, and core settings."],
["ticket-panel", "Ticket panel", "Ticket home embed, button labels, dropdown options, and panel styling."],
["commands", "Commands", "Enable/disable slash commands, descriptions, and custom names."],
["messages", "Messages", "Custom bot response messages, welcome embeds, and embed colors."],
["supportbot-ai", "AI assistant", "Configure AI support rules, model API keys, and auto-response channels."],
];
return (
<div className="space-y-6">
<div className="space-y-2.5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
General Capabilities
</h4>
<div className="grid gap-2.5">
{PERM_ITEMS.map(({ key, title, description }) => (
<label
key={key}
className="flex items-center justify-between gap-4 rounded-xl border border-border bg-secondary/20 p-3 transition-colors hover:bg-secondary/40"
>
<div className="space-y-0.5 min-w-0">
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-xs text-muted-foreground leading-snug">{description}</p>
</div>
<Switch
checked={permissions[key] ?? (key === "tickets" ? permissions.overview : false)}
disabled={disabled}
onCheckedChange={(checked) => set({ [key]: checked })}
className="shrink-0"
/>
</label>
))}
{/* Settings View */}
<label className="flex items-center justify-between gap-4 rounded-xl border border-border bg-secondary/20 p-3 transition-colors hover:bg-secondary/40">
<div className="space-y-0.5 min-w-0">
<p className="text-sm font-semibold text-foreground">Settings (View)</p>
<p className="text-xs text-muted-foreground leading-snug">
View system branding, OAuth configurations, and system health details.
</p>
</div>
<Switch
checked={permissions.settings.view}
disabled={disabled}
onCheckedChange={(view) => set({ settings: { ...permissions.settings, view } })}
className="shrink-0"
/>
</label>
{/* Settings Install Updates */}
<label className="flex items-center justify-between gap-4 rounded-xl border border-border bg-secondary/20 p-3 transition-colors hover:bg-secondary/40">
<div className="space-y-0.5 min-w-0">
<p className="text-sm font-semibold text-foreground">Settings (Install Updates)</p>
<p className="text-xs text-muted-foreground leading-snug">
Perform 1-click bot system updates and trigger bot server restarts.
</p>
</div>
<Switch
checked={permissions.settings.update}
disabled={disabled}
onCheckedChange={(update) => set({ settings: { ...permissions.settings, update } })}
className="shrink-0"
/>
</label>
{/* Users View */}
<label className="flex items-center justify-between gap-4 rounded-xl border border-border bg-secondary/20 p-3 transition-colors hover:bg-secondary/40">
<div className="space-y-0.5 min-w-0">
<p className="text-sm font-semibold text-foreground">Users & Groups (View)</p>
<p className="text-xs text-muted-foreground leading-snug">
View dashboard staff accounts and permission group listings.
</p>
</div>
<Switch
checked={permissions.users.view}
disabled={disabled}
onCheckedChange={(view) => set({ users: { ...permissions.users, view } })}
className="shrink-0"
/>
</label>
{/* Users Manage */}
<label className="flex items-center justify-between gap-4 rounded-xl border border-border bg-secondary/20 p-3 transition-colors hover:bg-secondary/40">
<div className="space-y-0.5 min-w-0">
<p className="text-sm font-semibold text-foreground">Users & Groups (Manage)</p>
<p className="text-xs text-muted-foreground leading-snug">
Create, edit, assign, and delete staff accounts and permission groups.
</p>
</div>
<Switch
checked={permissions.users.manage}
disabled={disabled}
onCheckedChange={(manage) => set({ users: { ...permissions.users, manage } })}
className="shrink-0"
/>
</label>
</div>
</div>
<div className="space-y-2.5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Configuration Files Access
</h4>
<div className="grid gap-2.5">
{CONFIG_ITEMS.map(([file, title, description]) => {
const cur = permissions.configs?.[file as keyof typeof permissions.configs] ?? {
view: false,
edit: false,
};
return (
<div
key={file}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-border bg-secondary/20 p-3 transition-colors hover:bg-secondary/40"
>
<div className="space-y-0.5 min-w-0">
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-xs text-muted-foreground leading-snug">{description}</p>
</div>
<div className="flex items-center gap-4 shrink-0">
<label className="flex items-center gap-2 text-xs font-medium text-muted-foreground cursor-pointer">
<span>View</span>
<Switch
checked={cur.view}
disabled={disabled}
onCheckedChange={(view) =>
set({
configs: {
...permissions.configs,
[file]: { ...cur, view },
},
})
}
/>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-muted-foreground cursor-pointer">
<span>Edit</span>
<Switch
checked={cur.edit}
disabled={disabled}
onCheckedChange={(edit) =>
set({
configs: {
...permissions.configs,
[file]: { ...cur, edit, view: edit ? true : cur.view },
},
})
}
/>
</label>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
export function Groups() {
const { impersonateGroup, impersonatedGroup, stopImpersonation } = useAuth();
const [groups, setGroups] = useState<DashboardGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Dialog State
const [dialogOpen, setDialogOpen] = useState(false);
const [editMode, setEditMode] = useState<"add" | "edit">("add");
const [saving, setSaving] = useState(false);
// Form State
const [groupId, setGroupId] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState("#10B981");
const [discordRoleId, setDiscordRoleId] = useState("");
const [permissions, setPermissions] = useState<DashboardPermissions>(
defaultPermissionsForRole("viewer")
);
const loadGroups = async () => {
setLoading(true);
setError(null);
try {
const res = await api.listGroups();
setGroups(res.data?.groups || []);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load groups");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadGroups();
}, []);
const openAddDialog = () => {
setEditMode("add");
setGroupId("");
setName("");
setDescription("");
setColor("#10B981");
setDiscordRoleId("");
setPermissions(defaultPermissionsForRole("viewer"));
setDialogOpen(true);
};
const openEditDialog = (group: DashboardGroup) => {
setEditMode("edit");
setGroupId(group.id);
setName(group.name);
setDescription(group.description || "");
setColor(group.color || "#10B981");
setDiscordRoleId(group.discordRoleId || "");
setPermissions(group.permissions || defaultPermissionsForRole("viewer"));
setDialogOpen(true);
};
const handleSaveGroup = async () => {
if (!name.trim()) {
setError("Group name is required");
return;
}
setSaving(true);
setError(null);
try {
if (editMode === "add") {
await api.createGroup({
name: name.trim(),
description: description.trim(),
color,
discordRoleId: discordRoleId.trim() || null,
permissions,
});
setSuccessMsg(`Group "${name}" created successfully.`);
} else {
await api.updateGroup(groupId, {
name: name.trim(),
description: description.trim(),
color,
discordRoleId: discordRoleId.trim() || null,
permissions,
});
setSuccessMsg(`Group "${name}" updated successfully.`);
}
setDialogOpen(false);
await loadGroups();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save group");
} finally {
setSaving(false);
}
};
const handleDeleteGroup = async (group: DashboardGroup) => {
if (group.isSystem) return;
if (!confirm(`Are you sure you want to delete the group "${group.name}"?`)) {
return;
}
try {
await api.deleteGroup(group.id);
setSuccessMsg(`Group "${group.name}" deleted.`);
await loadGroups();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete group");
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Permission Groups</h1>
<p className="text-sm text-muted-foreground mt-1">
Create custom permission groups, assign staff members, and optionally sync with Discord Role IDs.
</p>
</div>
<Button onClick={openAddDialog} className="shrink-0 gap-2 bg-primary">
<Icon icon={Add01Icon} size={18} />
Create Group
</Button>
</div>
{/* Alerts */}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{successMsg && (
<Alert className="border-emerald-500/30 bg-emerald-500/10 text-emerald-500">
<AlertDescription className="flex items-center justify-between">
<span>{successMsg}</span>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
onClick={() => setSuccessMsg(null)}
>
Dismiss
</Button>
</AlertDescription>
</Alert>
)}
{/* Group Table View */}
<Card className="border-border bg-card overflow-hidden">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-2">
<Skeleton className="h-8 w-full rounded-md" />
<Skeleton className="h-8 w-full rounded-md" />
</div>
) : groups.length === 0 ? (
<div className="p-6 text-center text-xs text-muted-foreground">
No permission groups found. Click "Create Group" to add one.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-border/80 bg-secondary/30 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<th className="px-4 py-2.5">Group</th>
<th className="px-4 py-2.5">Description</th>
<th className="px-4 py-2.5">Discord Role ID</th>
<th className="px-4 py-2.5">Enabled Permissions</th>
<th className="px-4 py-2.5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{groups.map((group) => (
<tr
key={group.id}
className="transition-colors hover:bg-secondary/25"
>
<td className="px-4 py-2.5 font-medium whitespace-nowrap">
<div className="flex items-center gap-2">
<div
className="size-2.5 rounded-full shrink-0 shadow-sm"
style={{ backgroundColor: group.color || "#10B981" }}
/>
<span className="text-foreground font-semibold text-xs">{group.name}</span>
{group.isSystem && (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-secondary text-secondary-foreground border border-border/80">
<Icon icon={Shield01Icon} size={10} />
System
</span>
)}
</div>
</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground max-w-xs truncate">
{group.description || "—"}
</td>
<td className="px-4 py-2.5 whitespace-nowrap">
{group.discordRoleId ? (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground font-mono bg-secondary/50 px-2 py-0.5 rounded border border-border/50">
<Icon icon={Shield01Icon} size={12} className="text-primary" />
{group.discordRoleId}
</span>
) : (
<span className="text-xs text-muted-foreground/60">—</span>
)}
</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1 max-w-md">
{group.permissions?.overview && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/10 text-primary border border-primary/20">
Overview
</span>
)}
{(group.permissions?.tickets ?? group.permissions?.overview) && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
Tickets
</span>
)}
{group.permissions?.transcripts && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-400 border border-purple-500/20">
Transcripts
</span>
)}
{group.permissions?.logs && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 border border-blue-500/20">
Logs
</span>
)}
{group.permissions?.users?.manage && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400 border border-amber-500/20">
User Mgmt
</span>
)}
</div>
</td>
<td className="px-4 py-2.5 text-right whitespace-nowrap">
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
variant="ghost"
className={`h-7 px-2 text-xs gap-1 ${
impersonatedGroup?.id === group.id
? "text-amber-400 font-bold bg-amber-500/15 border border-amber-500/30"
: "text-amber-400 hover:text-amber-300 hover:bg-amber-500/10"
}`}
onClick={() =>
impersonatedGroup?.id === group.id
? stopImpersonation()
: impersonateGroup(group.id)
}
>
<Icon icon={Shield01Icon} size={13} />
{impersonatedGroup?.id === group.id ? "Active Preview" : "View as Group"}
</Button>
{!group.isSystem && (
<>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs gap-1 text-muted-foreground hover:text-foreground"
onClick={() => openEditDialog(group)}
>
<Icon icon={PencilEdit01Icon} size={13} />
Edit
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs gap-1 text-muted-foreground hover:text-destructive"
onClick={() => handleDeleteGroup(group)}
>
<Icon icon={Delete02Icon} size={13} />
Delete
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Create / Edit Group Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editMode === "add" ? "Create Permission Group" : `Edit Group: ${name}`}
</DialogTitle>
<DialogDescription>
Configure group metadata, custom color badge, Discord role sync, and permission rules.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="group-name">Group Name</Label>
<Input
id="group-name"
placeholder="e.g. Support Staff"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="group-color">Color Badge</Label>
<div className="flex items-center gap-3">
<ColorPicker value={color} onChange={setColor} />
<Input
value={color}
onChange={(e) => setColor(e.target.value)}
placeholder="#10B981"
className="w-28 font-mono text-sm uppercase"
/>
<div className="flex flex-wrap gap-1 ml-auto">
{COLOR_PRESETS.map((preset) => (
<button
key={preset.hex}
type="button"
className="size-6 rounded-full border border-white/20 transition-transform hover:scale-110"
style={{ backgroundColor: preset.hex }}
onClick={() => setColor(preset.hex)}
title={preset.name}
/>
))}
</div>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="group-desc">Description</Label>
<Input
id="group-desc"
placeholder="Brief description of what members in this group can access..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="discord-role-id">Discord Role ID (Optional Auto-Sync)</Label>
<Input
id="discord-role-id"
placeholder="e.g. 123456789012345678"
value={discordRoleId}
onChange={(e) => setDiscordRoleId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
If provided, Discord users with this Role ID will automatically inherit this group upon login.
</p>
</div>
<div className="pt-2">
<h4 className="text-sm font-semibold mb-3">Group Permission Matrix</h4>
<PermissionEditor
permissions={permissions}
onChange={setPermissions}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={saving}
>
Cancel
</Button>
<Button onClick={handleSaveGroup} disabled={saving} className="bg-primary">
{saving ? "Saving..." : editMode === "add" ? "Create Group" : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+19
View File
@@ -16,6 +16,7 @@ import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { formatLogTime } from "@/lib/format-log-time";
import { cn } from "@/lib/utils";
import { useRealtimeStream, type StreamLogEntry } from "@/hooks/useRealtimeStream";
const LOG_FILTERS: { id: LogType | "all"; label: string }[] = [
{ id: "all", label: "All" },
@@ -84,6 +85,24 @@ export default function Logs() {
[filter],
);
const handleLogStream = useCallback(
(log: StreamLogEntry) => {
if (pausedRef.current) return;
if (filter !== "all" && log.type !== filter) return;
setEntries((prev) => {
const next = [...prev, log];
return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next;
});
setLive(true);
},
[filter]
);
useRealtimeStream({
onLog: handleLogStream,
});
const fetchBatch = useCallback(
async (reset: boolean) => {
const res = await api.getLogs({
+61 -19
View File
@@ -26,6 +26,9 @@ import {
STATUS_BADGE,
} from "@/lib/module-status-labels";
import { useRealtimeStream, type StreamMetrics } from "@/hooks/useRealtimeStream";
import { Badge } from "@/components/ui/badge";
function DetailRow({
label,
value,
@@ -71,6 +74,13 @@ export default function SystemDetails() {
setupComplete?: boolean;
} | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [liveMetrics, setLiveMetrics] = useState<StreamMetrics | null>(null);
const { connected } = useRealtimeStream({
onMetrics: (metrics) => {
setLiveMetrics(metrics);
},
});
const loadExtra = useCallback(async () => {
const [guildRes, healthRes] = await Promise.all([
@@ -78,12 +88,12 @@ export default function SystemDetails() {
api.health().catch(() => null),
]);
if (guildRes?.data) setGuildHealth(guildRes.data);
if (healthRes?.success) {
if (healthRes?.success && healthRes.data) {
setApiHealth({
dashboard: healthRes.dashboard,
botReady: healthRes.botReady,
oauthEnabled: healthRes.oauthEnabled,
setupComplete: healthRes.setupComplete,
dashboard: healthRes.data.dashboard,
botReady: healthRes.data.botReady,
oauthEnabled: healthRes.data.oauthEnabled,
setupComplete: (healthRes.data as { setupComplete?: boolean }).setupComplete,
});
}
}, []);
@@ -210,31 +220,63 @@ export default function SystemDetails() {
</Card>
<Card className="border-border bg-card">
<CardHeader>
<CardTitle className="text-lg">Host machine</CardTitle>
<CardDescription>Server resources running this instance</CardDescription>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg">Host machine</CardTitle>
<CardDescription>Server resources running this instance</CardDescription>
</div>
<Badge variant={connected ? "default" : "outline"} className="text-xs">
<span
className={`mr-1.5 inline-block h-2 w-2 rounded-full ${
connected ? "bg-emerald-500 animate-pulse" : "bg-muted"
}`}
/>
{connected ? "Live Stream" : "Polling"}
</Badge>
</CardHeader>
<CardContent>
<dl>
<DetailRow
label="Memory used"
value={`${stats.hosting.ram_used} / ${stats.hosting.ram_total}`}
value={
liveMetrics
? `${(liveMetrics.ram_used_mb / 1024).toFixed(2)} GB / ${(
liveMetrics.ram_total_mb / 1024
).toFixed(2)} GB`
: `${stats.hosting.ram_used} / ${stats.hosting.ram_total}`
}
/>
<DetailRow
label="Memory usage"
value={
<span
className={
stats.hosting.ram_percent > 85
? "text-amber-500"
: "text-foreground"
}
>
{stats.hosting.ram_percent}%
</span>
<div className="flex items-center gap-2">
<span
className={
(liveMetrics ? liveMetrics.ram_percent : stats.hosting.ram_percent) > 85
? "text-amber-500 font-bold"
: "text-foreground font-medium"
}
>
{liveMetrics ? liveMetrics.ram_percent : stats.hosting.ram_percent}%
</span>
<div className="w-24 h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{
width: `${
liveMetrics ? liveMetrics.ram_percent : stats.hosting.ram_percent
}%`,
}}
/>
</div>
</div>
}
/>
<DetailRow label="CPU load (1 min avg)" value={stats.hosting.cpu_load} mono />
<DetailRow
label="CPU load (1 min avg)"
value={liveMetrics ? String(liveMetrics.cpu_load) : stats.hosting.cpu_load}
mono
/>
<DetailRow label="Host uptime" value={stats.hosting.uptime} />
</dl>
</CardContent>
File diff suppressed because it is too large. Load diff
+323 -177
View File
@@ -35,6 +35,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/context/AuthContext";
import {
CONFIG_FILE_LABELS,
@@ -43,6 +44,7 @@ import {
displayDiscordName,
ROLE_HINTS,
ROLE_LABELS,
type DashboardGroup,
type DashboardPermissions,
type DashboardRole,
} from "@/lib/permissions";
@@ -91,119 +93,186 @@ function PermissionEditor({
const set = (patch: Partial<DashboardPermissions>) =>
onChange({ ...permissions, ...patch });
const PERM_ITEMS: {
key: "overview" | "tickets" | "logs" | "transcripts";
title: string;
description: string;
}[] = [
{
key: "overview",
title: "Dashboard Overview",
description: "Access to system metrics, bot health status, and main dashboard stats.",
},
{
key: "tickets",
title: "Tickets",
description: "View open support tickets, live web chat, send staff replies, and close tickets.",
},
{
key: "transcripts",
title: "Transcripts",
description: "Access saved ticket HTML transcripts and configure public transcript options.",
},
{
key: "logs",
title: "Console Logs",
description: "View real-time bot terminal output, system logs, and runtime error traces.",
},
];
const CONFIG_ITEMS: [string, string, string][] = [
["supportbot", "Bot config", "Main bot token, prefix, channels, categories, and core settings."],
["ticket-panel", "Ticket panel", "Ticket home embed, button labels, dropdown options, and panel styling."],
["commands", "Commands", "Enable/disable slash commands, descriptions, and custom names."],
["messages", "Messages", "Custom bot response messages, welcome embeds, and embed colors."],
["supportbot-ai", "AI assistant", "Configure AI support rules, model API keys, and auto-response channels."],
];
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
{(
[
["Dashboard overview", "overview"],
["Logs", "logs"],
["Transcripts", "transcripts"],
] as const
).map(([label, key]) => (
<label
key={key}
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2"
>
<span className="text-sm">{label}</span>
<div className="space-y-2.5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
General Capabilities
</h4>
<div className="grid gap-2.5">
{PERM_ITEMS.map(({ key, title, description }) => (
<label
key={key}
className="flex items-center justify-between gap-4 rounded-xl border border-border bg-secondary/20 p-3 transition-colors hover:bg-secondary/40"
>
<div className="space-y-0.5 min-w-0">
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-xs text-muted-foreground leading-snug">{description}</p>
</div>
<Switch
checked={permissions[key] ?? (key === "tickets" ? permissions.overview : false)}
disabled={disabled}
onCheckedChange={(checked) => set({ [key]: checked })}
className="shrink-0"
/>
</label>
))}
{/* Settings View */}
<label className="flex items-center justify-between gap-4 rounded-xl border border-border bg-secondary/20 p-3 transition-colors hover:bg-secondary/40">
<div className="space-y-0.5 min-w-0">
<p className="text-sm font-semibold text-foreground">Settings (View)</p>
<p className="text-xs text-muted-foreground leading-snug">
View system branding, OAuth configurations, and system health details.
</p>
</div>
<Switch
checked={permissions[key]}
checked={permissions.settings.view}
disabled={disabled}
onCheckedChange={(checked) => set({ [key]: checked })}
onCheckedChange={(view) => set({ settings: { ...permissions.settings, view } })}
className="shrink-0"
/>
</label>
))}
<label className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<span className="text-sm">Settings — view</span>
<Switch
checked={permissions.settings.view}
disabled={disabled}
onCheckedChange={(view) =>
set({ settings: { ...permissions.settings, view } })
}
/>
</label>
<label className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<span className="text-sm">Settings — install updates</span>
<Switch
checked={permissions.settings.update}
disabled={disabled}
onCheckedChange={(update) =>
set({ settings: { ...permissions.settings, update } })
}
/>
</label>
<label className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<span className="text-sm">Users — view</span>
<Switch
checked={permissions.users.view}
disabled={disabled}
onCheckedChange={(view) =>
set({ users: { ...permissions.users, view } })
}
/>
</label>
<label className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<span className="text-sm">Users — manage</span>
<Switch
checked={permissions.users.manage}
disabled={disabled}
onCheckedChange={(manage) =>
set({ users: { ...permissions.users, manage } })
}
/>
</label>
{/* Settings Install Updates */}
<label className="flex items-center justify-between gap-4 rounded-xl border border-border bg-secondary/20 p-3 transition-colors hover:bg-secondary/40">
<div className="space-y-0.5 min-w-0">
<p className="text-sm font-semibold text-foreground">Settings (Install Updates)</p>
<p className="text-xs text-muted-foreground leading-snug">
Perform 1-click bot system updates and trigger bot server restarts.
</p>
</div>
<Switch
checked={permissions.settings.update}
disabled={disabled}
onCheckedChange={(update) => set({ settings: { ...permissions.settings, update } })}
className="shrink-0"
/>
</label>
{/* Users View */}
<label className="flex items-center justify-between gap-4 rounded-xl border border-border bg-secondary/20 p-3 transition-colors hover:bg-secondary/40">
<div className="space-y-0.5 min-w-0">
<p className="text-sm font-semibold text-foreground">Users & Groups (View)</p>
<p className="text-xs text-muted-foreground leading-snug">
View dashboard staff accounts and permission group listings.
</p>
</div>
<Switch
checked={permissions.users.view}
disabled={disabled}
onCheckedChange={(view) => set({ users: { ...permissions.users, view } })}
className="shrink-0"
/>
</label>
{/* Users Manage */}
<label className="flex items-center justify-between gap-4 rounded-xl border border-border bg-secondary/20 p-3 transition-colors hover:bg-secondary/40">
<div className="space-y-0.5 min-w-0">
<p className="text-sm font-semibold text-foreground">Users & Groups (Manage)</p>
<p className="text-xs text-muted-foreground leading-snug">
Create, edit, assign, and delete staff accounts and permission groups.
</p>
</div>
<Switch
checked={permissions.users.manage}
disabled={disabled}
onCheckedChange={(manage) => set({ users: { ...permissions.users, manage } })}
className="shrink-0"
/>
</label>
</div>
</div>
<div className="space-y-3">
<p className="text-sm font-medium">Configuration files</p>
<div className="space-y-2">
{CONFIG_FILES.map((file) => (
<div
key={file}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border px-3 py-2"
>
<span className="text-sm">{CONFIG_FILE_LABELS[file]}</span>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
View
<Switch
checked={permissions.configs[file].view}
disabled={disabled}
onCheckedChange={(view) =>
onChange({
...permissions,
configs: {
...permissions.configs,
[file]: { ...permissions.configs[file], view },
},
})
}
/>
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
Edit
<Switch
checked={permissions.configs[file].edit}
disabled={disabled}
onCheckedChange={(edit) =>
onChange({
...permissions,
configs: {
...permissions.configs,
[file]: {
...permissions.configs[file],
edit,
view: edit ? true : permissions.configs[file].view,
<div className="space-y-2.5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Configuration Files Access
</h4>
<div className="grid gap-2.5">
{CONFIG_ITEMS.map(([file, title, description]) => {
const cur = permissions.configs?.[file as keyof typeof permissions.configs] ?? {
view: false,
edit: false,
};
return (
<div
key={file}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-border bg-secondary/20 p-3 transition-colors hover:bg-secondary/40"
>
<div className="space-y-0.5 min-w-0">
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-xs text-muted-foreground leading-snug">{description}</p>
</div>
<div className="flex items-center gap-4 shrink-0">
<label className="flex items-center gap-2 text-xs font-medium text-muted-foreground cursor-pointer">
<span>View</span>
<Switch
checked={cur.view}
disabled={disabled}
onCheckedChange={(view) =>
set({
configs: {
...permissions.configs,
[file]: { ...cur, view },
},
},
})
}
/>
</label>
})
}
/>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-muted-foreground cursor-pointer">
<span>Edit</span>
<Switch
checked={cur.edit}
disabled={disabled}
onCheckedChange={(edit) =>
set({
configs: {
...permissions.configs,
[file]: { ...cur, edit, view: edit ? true : cur.view },
},
})
}
/>
</label>
</div>
</div>
</div>
))}
);
})}
</div>
</div>
</div>
@@ -224,6 +293,7 @@ export default function Users() {
const canManage = hasPermission("users.manage");
const [users, setUsers] = useState<StoredDashboardUser[]>([]);
const [groups, setGroups] = useState<DashboardGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [editor, setEditor] = useState<EditorState | null>(null);
@@ -233,8 +303,12 @@ export default function Users() {
setLoading(true);
setError("");
try {
const res = await api.listDashboardUsers();
if (res.data) setUsers(res.data.users);
const [uRes, gRes] = await Promise.all([
api.listDashboardUsers(),
api.listGroups().catch(() => ({ data: { groups: [] } })),
]);
if (uRes.data) setUsers(uRes.data.users);
if (gRes.data) setGroups(gRes.data.groups);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load users");
} finally {
@@ -357,79 +431,128 @@ export default function Users() {
</Alert>
) : null}
<Card className="border-border bg-card">
<CardHeader>
<CardTitle className="text-base">Dashboard access</CardTitle>
<CardDescription>
{sortedUsers.length} user{sortedUsers.length === 1 ? "" : "s"}
{canManage ? " — use edit to change permissions" : ""}
</CardDescription>
</CardHeader>
<CardContent>
<Card className="border-border bg-card overflow-hidden">
<CardContent className="p-0">
{loading ? (
<p className="text-sm text-muted-foreground">Loading users…</p>
<div className="p-4 space-y-2">
<Skeleton className="h-8 w-full rounded-md" />
<Skeleton className="h-8 w-full rounded-md" />
</div>
) : sortedUsers.length === 0 ? (
<div className="p-6 text-center text-xs text-muted-foreground">
No users found.
</div>
) : (
<div className="divide-y divide-border/60">
{sortedUsers.map((u) => (
<div
key={u.id}
className="flex flex-wrap items-center gap-4 py-4 first:pt-0 last:pb-0"
>
<div className="flex min-w-[200px] flex-1 items-center gap-3">
<Avatar className="size-9">
<AvatarImage src={u.avatar} alt={u.username} />
<AvatarFallback>
{(u.username || u.id).slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-medium">
{displayDiscordName(u)}
{u.id === currentUser?.id ? " (you)" : ""}
</p>
<p className="truncate text-xs text-muted-foreground">
{u.id}
</p>
</div>
</div>
<Badge variant="secondary">
{ROLE_LABELS[u.yamlOwner ? "owner" : u.role]}
</Badge>
<p className="max-w-[200px] text-xs text-muted-foreground">
{accessSummary(u)}
</p>
{u.enabled ? (
<Badge className="bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/15">
Active
</Badge>
) : (
<Badge variant="outline">Disabled</Badge>
)}
{canManage ? (
<div className="ml-auto flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(u)}
aria-label="Edit user"
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-border/80 bg-secondary/30 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<th className="px-4 py-2.5">User</th>
<th className="px-4 py-2.5">Group</th>
<th className="px-4 py-2.5">Access Summary</th>
<th className="px-4 py-2.5">Status</th>
{canManage && <th className="px-4 py-2.5 text-right">Actions</th>}
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{sortedUsers.map((u) => {
const matchedGroup = groups.find(
(g) => g.id === u.groupId || g.id === u.role
);
const groupColor = matchedGroup?.color || "#10B981";
const groupName = matchedGroup?.name || ROLE_LABELS[u.yamlOwner ? "owner" : (u.role as keyof typeof ROLE_LABELS)] || u.role;
return (
<tr
key={u.id}
className="transition-colors hover:bg-secondary/25"
>
<Icon icon={Edit02Icon} size={16} />
</Button>
{!u.yamlOwner ? (
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => removeUser(u.id)}
aria-label="Remove user"
>
<Icon icon={Delete02Icon} size={16} />
</Button>
) : null}
</div>
) : null}
</div>
))}
<td className="px-4 py-2.5 font-medium whitespace-nowrap">
<div className="flex items-center gap-2.5">
<Avatar className="size-7">
<AvatarImage src={u.avatar} alt={u.username} />
<AvatarFallback className="text-[10px]">
{(u.username || u.id).slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 leading-tight">
<p className="truncate font-semibold text-foreground text-xs">
{displayDiscordName(u)}
{u.id === currentUser?.id ? (
<span className="text-[11px] text-primary font-normal ml-1">(you)</span>
) : ""}
</p>
<p className="truncate text-[10px] text-muted-foreground font-mono">
{u.id}
</p>
</div>
</div>
</td>
<td className="px-4 py-2.5 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<span
className="size-2 rounded-full shrink-0 shadow-sm"
style={{ backgroundColor: groupColor }}
/>
<span className="font-medium text-xs text-foreground">
{groupName}
</span>
{u.yamlOwner && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-primary/10 text-primary border border-primary/20">
Config Owner
</span>
)}
</div>
</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground max-w-xs truncate">
{accessSummary(u)}
</td>
<td className="px-4 py-2.5 whitespace-nowrap">
{u.enabled ? (
<Badge className="text-[10px] px-2 py-0.5 bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/15 border-emerald-500/20">
Active
</Badge>
) : (
<Badge variant="outline" className="text-[10px] px-2 py-0.5">
Disabled
</Badge>
)}
</td>
{canManage && (
<td className="px-4 py-2.5 text-right whitespace-nowrap">
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs gap-1 text-muted-foreground hover:text-foreground"
onClick={() => openEdit(u)}
>
<Icon icon={Edit02Icon} size={13} />
Edit
</Button>
{!u.yamlOwner && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs gap-1 text-muted-foreground hover:text-destructive"
onClick={() => removeUser(u.id)}
>
<Icon icon={Delete02Icon} size={13} />
Remove
</Button>
)}
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
@@ -489,11 +612,22 @@ export default function Users() {
</div>
<div className="space-y-2">
<Label>Role</Label>
<Label>Permission Group</Label>
<Select
value={editor.role}
disabled={editor.yamlOwner}
onValueChange={(v) => setRole(v as DashboardRole)}
onValueChange={(v) => {
const matchedGroup = groups.find((g) => g.id === v);
if (matchedGroup) {
setEditor({
...editor,
role: v,
permissions: matchedGroup.permissions,
});
} else {
setRole(v as DashboardRole);
}
}}
>
<SelectTrigger>
<SelectValue />
@@ -501,6 +635,18 @@ export default function Users() {
<SelectContent>
{editor.yamlOwner ? (
<SelectItem value="owner">Owner</SelectItem>
) : groups.length > 0 ? (
groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
<div className="flex items-center gap-2">
<span
className="size-2.5 rounded-full shrink-0"
style={{ backgroundColor: group.color || "#10B981" }}
/>
<span>{group.name}</span>
</div>
</SelectItem>
))
) : (
ASSIGNABLE_ROLES.map((role) => (
<SelectItem key={role} value={role}>