Task #83: Refresh role and permission changes live across user sessions

## Socket.IO broadcasts (core task):
- POST /users/:id/roles: uses .returning() to emit apps_changed only when row inserted
- DELETE /users/:id/roles/:roleName: uses .returning() to emit only when row deleted
- Added emitAppsChangedToRoleHolders(roleId) helper to realtime.ts
- New role-permission endpoints in roles.ts, emit only on effective changes:
  - GET /roles/:id/permissions — list permissions for a role
  - POST /roles/:id/permissions — assign; emits to role holders on actual insert
  - DELETE /roles/:id/permissions/:permId — remove; emits only if row was deleted

## Bug fix: admin panel not showing disabled apps
- Added GET /api/admin/apps (requireAdmin) returning ALL apps from DB
- Updated admin.tsx: useQuery → /api/admin/apps with ADMIN_APPS_KEY constant
- All admin.tsx mutation handlers invalidate ADMIN_APPS_KEY

## UI fix: disabled app cards nearly invisible (opacity-60 on glass)
- Gray background + grayscale icon + muted text + stronger Disabled badge


## DB: removed 11 duplicate rows from app_permissions table
This commit is contained in:
Riyadh
2026-04-27 11:11:43 +00:00
parent 1dd4245945
commit 0243f1026b
5 changed files with 147 additions and 17 deletions
+19 -13
View File
@@ -2,8 +2,6 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import {
useListApps,
getListAppsQueryKey,
useCreateApp,
useUpdateApp,
useDeleteApp,
@@ -60,7 +58,7 @@ import type {
ListUsersParams,
} from "@workspace/api-client-react";
import { ListUsersStatus } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useQueryClient, useQuery } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { resolveServiceImageUrl } from "@/lib/image-url";
@@ -75,6 +73,14 @@ import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format";
const ADMIN_APPS_KEY = ["/api/admin/apps"] as const;
async function fetchAdminApps(): Promise<App[]> {
const res = await fetch("/api/admin/apps", { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch admin apps");
return res.json();
}
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "settings";
function LeaderboardAvatar({
@@ -145,7 +151,7 @@ export default function AdminPage() {
}
}, [user, isAdmin, setLocation]);
const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } });
const { data: apps } = useQuery({ queryKey: ADMIN_APPS_KEY, queryFn: fetchAdminApps, enabled: isAdmin });
const { data: services } = useListServices({ query: { queryKey: getListServicesQueryKey() } });
const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } });
const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
@@ -375,7 +381,7 @@ export default function AdminPage() {
{ id: editingApp.id, data: editingApp.form },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() });
queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY });
setEditingApp(null);
toast({ title: t("common.success") });
},
@@ -386,7 +392,7 @@ export default function AdminPage() {
{ data: editingApp.form },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() });
queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY });
setEditingApp(null);
toast({ title: t("common.success") });
},
@@ -642,19 +648,19 @@ export default function AdminPage() {
</Button>
</div>
{apps?.map((app) => (
<div key={app.id} className={cn("glass-panel rounded-2xl p-4 flex items-center justify-between gap-3", !app.isActive && "opacity-60")}>
<div key={app.id} className={cn("rounded-2xl p-4 flex items-center justify-between gap-3 border", app.isActive ? "glass-panel border-transparent" : "bg-slate-100/80 border-slate-200")}>
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-lg shrink-0"
className={cn("w-8 h-8 rounded-lg shrink-0", !app.isActive && "grayscale")}
style={{ backgroundColor: `${app.color}40` }}
/>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium text-sm text-foreground truncate">
<div className={cn("font-medium text-sm truncate", app.isActive ? "text-foreground" : "text-slate-400")}>
{lang === "ar" ? app.nameAr : app.nameEn}
</div>
{!app.isActive && (
<span className="text-[10px] font-medium bg-slate-200 text-slate-500 rounded px-1.5 py-0.5 shrink-0">
<span className="text-[10px] font-medium bg-slate-300 text-slate-600 rounded px-1.5 py-0.5 shrink-0">
{t("admin.appDisabled")}
</span>
)}
@@ -668,7 +674,7 @@ export default function AdminPage() {
onCheckedChange={(v) => {
updateApp.mutate(
{ id: app.id, data: { isActive: v } },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() }) },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }) },
);
}}
/>
@@ -694,7 +700,7 @@ export default function AdminPage() {
if (confirm(t("admin.deleteConfirm"))) {
deleteApp.mutate(
{ id: app.id },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() }) },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }) },
);
}
}}
@@ -2671,7 +2677,7 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
const lang = i18n.language;
const { toast } = useToast();
const { data: group } = useGetGroup(groupId, { query: { queryKey: getGetGroupQueryKey(groupId) } });
const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } });
const { data: apps } = useQuery({ queryKey: ADMIN_APPS_KEY, queryFn: fetchAdminApps });
const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } });
const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } });
const updateGroup = useUpdateGroup();