Task #76: Show roles management inside the Groups editor

Adds a "Roles" tab to the GroupDetailEditor in the admin Groups screen so
admins can manage which roles are auto-granted to members of a group.

Changes:
- OpenAPI: added `GET /roles` (operationId `listRoles`) and a `Role` schema
  in `lib/api-spec/openapi.yaml`. Ran `pnpm --filter @workspace/api-spec
  run codegen` to regenerate `lib/api-client-react` and `lib/api-zod`.
- API server: added `GET /api/roles` (admin-only) in
  `artifacts/api-server/src/routes/groups.ts`, returning all roles
  ordered by name.
- Frontend (`artifacts/teaboy-os/src/pages/admin.tsx`):
  - Imported `useListRoles` / `getListRolesQueryKey`.
  - Added `roleIds` state and a fourth `"roles"` tab to GroupDetailEditor
    with a checkbox list backed by the new endpoint, hydrated from
    `group.roleIds`.
  - Save now sends `roleIds: Array.from(roleIds)` via the existing
    `PATCH /api/groups/:id` mutation.
  - On save, also invalidate `getGetGroupQueryKey(editingGroupId)` so the
    detail cache (30s staleTime) reflects the new role list immediately
    when the editor is reopened.
- Translations: added `admin.groups.tab.roles`, `admin.groups.rolesHint`,
  and `admin.groups.rolesEmpty` in both `en.json` and `ar.json`.

Verification:
- `pnpm -w run typecheck` passes.
- `pnpm --filter @workspace/api-server test` — 42/42 pass.
- e2e (testing skill): logged in as admin, opened TeaBoy group, toggled
  the `user` role on the new Roles tab, saved, reopened — toggle
  persisted.

No deviations from the task description.

Replit-Task-Id: 42808400-9209-469b-b526-37c776ffb25d
This commit is contained in:
riyadhafraa
2026-04-22 10:13:17 +00:00
parent 7d29d63d2d
commit bc0737aed2
9 changed files with 187 additions and 5 deletions
+13
View File
@@ -29,6 +29,19 @@ function serializeGroup(g: typeof groupsTable.$inferSelect) {
};
}
router.get("/roles", requireAdmin, async (_req, res): Promise<void> => {
const rows = await db.select().from(rolesTable).orderBy(rolesTable.name);
res.json(
rows.map((r) => ({
id: r.id,
name: r.name,
descriptionAr: r.descriptionAr,
descriptionEn: r.descriptionEn,
createdAt: r.createdAt,
})),
);
});
router.get("/groups", requireAdmin, async (req, res): Promise<void> => {
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
const where = q ? ilike(groupsTable.name, `%${q}%`) : undefined;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+4 -1
View File
@@ -393,10 +393,13 @@
"rolesCount": "{{count}} دور",
"appsHint": "أعضاء هذه المجموعة سيرون هذه التطبيقات.",
"usersHint": "أعضاء هذه المجموعة.",
"rolesHint": "يحصل أعضاء هذه المجموعة تلقائياً على هذه الأدوار.",
"rolesEmpty": "لا توجد أدوار متاحة.",
"tab": {
"info": "المعلومات",
"apps": "التطبيقات",
"users": "المستخدمون"
"users": "المستخدمون",
"roles": "الأدوار"
}
},
"dashboardSoon": "إحصائيات اللوحة قريباً.",
+4 -1
View File
@@ -390,10 +390,13 @@
"rolesCount": "{{count}} roles",
"appsHint": "Members of this group will see these apps.",
"usersHint": "Members of this group.",
"rolesHint": "Members of this group are automatically granted these roles.",
"rolesEmpty": "No roles available.",
"tab": {
"info": "Info",
"apps": "Apps",
"users": "Users"
"users": "Users",
"roles": "Roles"
}
},
"dashboardSoon": "Dashboard widgets coming soon.",
+43 -3
View File
@@ -38,6 +38,8 @@ import {
useCreateGroup,
useUpdateGroup,
useDeleteGroup,
useListRoles,
getListRolesQueryKey,
} from "@workspace/api-client-react";
import type {
App,
@@ -2547,7 +2549,15 @@ function GroupsPanel() {
)}
{editingGroupId != null && (
<GroupDetailEditor groupId={editingGroupId} onClose={() => setEditingGroupId(null)} onSaved={() => { invalidate(); setEditingGroupId(null); }} />
<GroupDetailEditor
groupId={editingGroupId}
onClose={() => setEditingGroupId(null)}
onSaved={() => {
invalidate();
queryClient.invalidateQueries({ queryKey: getGetGroupQueryKey(editingGroupId) });
setEditingGroupId(null);
}}
/>
)}
</div>
);
@@ -2560,6 +2570,7 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
const { data: group } = useGetGroup(groupId, { query: { queryKey: getGetGroupQueryKey(groupId) } });
const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } });
const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } });
const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } });
const updateGroup = useUpdateGroup();
const [name, setName] = useState("");
@@ -2567,7 +2578,8 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
const [descriptionEn, setDescriptionEn] = useState("");
const [appIds, setAppIds] = useState<Set<number>>(new Set());
const [userIds, setUserIds] = useState<Set<number>>(new Set());
const [tab, setTab] = useState<"info" | "apps" | "users">("info");
const [roleIds, setRoleIds] = useState<Set<number>>(new Set());
const [tab, setTab] = useState<"info" | "apps" | "users" | "roles">("info");
const [userSearch, setUserSearch] = useState("");
const visibleUsers = (users ?? []).filter((u) => {
@@ -2588,6 +2600,7 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
setDescriptionEn(group.descriptionEn ?? "");
setAppIds(new Set(group.appIds));
setUserIds(new Set(group.userIds));
setRoleIds(new Set(group.roleIds));
}
}, [group]);
@@ -2624,7 +2637,7 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
</div>
<div className="flex border-b border-slate-200/70">
{(["info", "apps", "users"] as const).map((k) => (
{(["info", "apps", "users", "roles"] as const).map((k) => (
<button
key={k}
onClick={() => setTab(k)}
@@ -2669,6 +2682,32 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
</div>
)}
{tab === "roles" && (
<div className="space-y-1 max-h-72 overflow-y-auto">
<p className="text-xs text-muted-foreground px-1 pb-2">{t("admin.groups.rolesHint")}</p>
{roles?.map((r) => (
<label
key={r.id}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 cursor-pointer"
data-testid={`group-role-${r.id}`}
>
<input
type="checkbox"
checked={roleIds.has(r.id)}
onChange={() => toggle(roleIds, r.id, setRoleIds)}
/>
<span className="text-sm flex-1">
{(lang === "ar" ? r.descriptionAr : r.descriptionEn) || r.name}
</span>
<span className="text-[10px] text-muted-foreground">{r.name}</span>
</label>
))}
{(!roles || roles.length === 0) && (
<p className="text-sm text-muted-foreground p-2">{t("admin.groups.rolesEmpty")}</p>
)}
</div>
)}
{tab === "users" && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground px-1">{t("admin.groups.usersHint")}</p>
@@ -2708,6 +2747,7 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
descriptionEn: descriptionEn || null,
appIds: Array.from(appIds),
userIds: Array.from(userIds),
roleIds: Array.from(roleIds),
},
},
{
@@ -169,6 +169,16 @@ export interface AddUserRoleBody {
roleName: string;
}
export interface Role {
id: number;
name: string;
/** @nullable */
descriptionAr?: string | null;
/** @nullable */
descriptionEn?: string | null;
createdAt: string;
}
export interface Group {
id: number;
name: string;
+66
View File
@@ -53,6 +53,7 @@ import type {
RequestUploadUrlBody,
RequestUploadUrlResponse,
ResetPasswordBody,
Role,
SendMessageBody,
Service,
ServiceCategory,
@@ -4673,6 +4674,71 @@ export const useRemoveUserRole = <
return useMutation(getRemoveUserRoleMutationOptions(options));
};
/**
* @summary List roles (admin)
*/
export const getListRolesUrl = () => {
return `/api/roles`;
};
export const listRoles = async (options?: RequestInit): Promise<Role[]> => {
return customFetch<Role[]>(getListRolesUrl(), {
...options,
method: "GET",
});
};
export const getListRolesQueryKey = () => {
return [`/api/roles`] as const;
};
export const getListRolesQueryOptions = <
TData = Awaited<ReturnType<typeof listRoles>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof listRoles>>, TError, TData>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRolesQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRoles>>> = ({
signal,
}) => listRoles({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listRoles>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListRolesQueryResult = NonNullable<
Awaited<ReturnType<typeof listRoles>>
>;
export type ListRolesQueryError = ErrorType<unknown>;
/**
* @summary List roles (admin)
*/
export function useListRoles<
TData = Awaited<ReturnType<typeof listRoles>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof listRoles>>, TError, TData>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListRolesQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List groups (admin)
*/
+35
View File
@@ -1243,6 +1243,22 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
# Roles (admin)
/roles:
get:
operationId: listRoles
tags: [users]
summary: List roles (admin)
responses:
"200":
description: Roles
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Role"
# Groups (admin)
/groups:
get:
@@ -1829,6 +1845,25 @@ components:
required:
- roleName
Role:
type: object
properties:
id:
type: integer
name:
type: string
descriptionAr:
type: ["string", "null"]
descriptionEn:
type: ["string", "null"]
createdAt:
type: string
format: date-time
required:
- id
- name
- createdAt
GroupSummary:
type: object
properties:
+12
View File
@@ -1590,6 +1590,18 @@ export const RemoveUserRoleResponse = zod.object({
createdAt: zod.coerce.date(),
});
/**
* @summary List roles (admin)
*/
export const ListRolesResponseItem = zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullish(),
descriptionEn: zod.string().nullish(),
createdAt: zod.coerce.date(),
});
export const ListRolesResponse = zod.array(ListRolesResponseItem);
/**
* @summary List groups (admin)
*/