26.2-pre2
This commit is contained in:
1 parent
ba41125eae
commit
6165f2d4f3
13 files changed
+2869
-5314
No files matched your search
Generated
-5018
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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<{
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1287
-18
File diff suppressed because it is too large.
Load diff
+323
-177
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user