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:
@@ -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 |
@@ -393,10 +393,13 @@
|
||||
"rolesCount": "{{count}} دور",
|
||||
"appsHint": "أعضاء هذه المجموعة سيرون هذه التطبيقات.",
|
||||
"usersHint": "أعضاء هذه المجموعة.",
|
||||
"rolesHint": "يحصل أعضاء هذه المجموعة تلقائياً على هذه الأدوار.",
|
||||
"rolesEmpty": "لا توجد أدوار متاحة.",
|
||||
"tab": {
|
||||
"info": "المعلومات",
|
||||
"apps": "التطبيقات",
|
||||
"users": "المستخدمون"
|
||||
"users": "المستخدمون",
|
||||
"roles": "الأدوار"
|
||||
}
|
||||
},
|
||||
"dashboardSoon": "إحصائيات اللوحة قريباً.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user