diff --git a/artifacts/api-server/src/routes/groups.ts b/artifacts/api-server/src/routes/groups.ts index cff6991f..00b47eab 100644 --- a/artifacts/api-server/src/routes/groups.ts +++ b/artifacts/api-server/src/routes/groups.ts @@ -29,6 +29,19 @@ function serializeGroup(g: typeof groupsTable.$inferSelect) { }; } +router.get("/roles", requireAdmin, async (_req, res): Promise => { + 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 => { const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; const where = q ? ilike(groupsTable.name, `%${q}%`) : undefined; diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index 5e4be343..156fbf99 100644 Binary files a/artifacts/teaboy-os/public/opengraph.jpg and b/artifacts/teaboy-os/public/opengraph.jpg differ diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 0124a409..3aa96719 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -393,10 +393,13 @@ "rolesCount": "{{count}} دور", "appsHint": "أعضاء هذه المجموعة سيرون هذه التطبيقات.", "usersHint": "أعضاء هذه المجموعة.", + "rolesHint": "يحصل أعضاء هذه المجموعة تلقائياً على هذه الأدوار.", + "rolesEmpty": "لا توجد أدوار متاحة.", "tab": { "info": "المعلومات", "apps": "التطبيقات", - "users": "المستخدمون" + "users": "المستخدمون", + "roles": "الأدوار" } }, "dashboardSoon": "إحصائيات اللوحة قريباً.", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 64f641fb..30472b68 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -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.", diff --git a/artifacts/teaboy-os/src/pages/admin.tsx b/artifacts/teaboy-os/src/pages/admin.tsx index 2d42d15e..a6a73dc9 100644 --- a/artifacts/teaboy-os/src/pages/admin.tsx +++ b/artifacts/teaboy-os/src/pages/admin.tsx @@ -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 && ( - setEditingGroupId(null)} onSaved={() => { invalidate(); setEditingGroupId(null); }} /> + setEditingGroupId(null)} + onSaved={() => { + invalidate(); + queryClient.invalidateQueries({ queryKey: getGetGroupQueryKey(editingGroupId) }); + setEditingGroupId(null); + }} + /> )} ); @@ -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>(new Set()); const [userIds, setUserIds] = useState>(new Set()); - const [tab, setTab] = useState<"info" | "apps" | "users">("info"); + const [roleIds, setRoleIds] = useState>(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
- {(["info", "apps", "users"] as const).map((k) => ( + {(["info", "apps", "users", "roles"] as const).map((k) => (
)} + {tab === "roles" && ( +
+

{t("admin.groups.rolesHint")}

+ {roles?.map((r) => ( + + ))} + {(!roles || roles.length === 0) && ( +

{t("admin.groups.rolesEmpty")}

+ )} +
+ )} + {tab === "users" && (

{t("admin.groups.usersHint")}

@@ -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), }, }, { diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index d998e834..5947c93b 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index d7e2ab42..67af3f1a 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { + return customFetch(getListRolesUrl(), { + ...options, + method: "GET", + }); +}; + +export const getListRolesQueryKey = () => { + return [`/api/roles`] as const; +}; + +export const getListRolesQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions>, TError, TData>; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListRolesQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listRoles({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ListRolesQueryResult = NonNullable< + Awaited> +>; +export type ListRolesQueryError = ErrorType; + +/** + * @summary List roles (admin) + */ + +export function useListRoles< + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions>, TError, TData>; + request?: SecondParameter; +}): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListRolesQueryOptions(options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + /** * @summary List groups (admin) */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 67cba5b6..cc290e02 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 5c4a1e5e..959932c4 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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) */