ui improvements

This commit is contained in:
miloschwartz
2026-03-26 16:37:31 -07:00
parent 0fecbe704b
commit e13a076939
33 changed files with 533 additions and 205 deletions

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Изтрийте потребител",
"actionListUsers": "Изброяване на потребители",
"actionAddUserRole": "Добавяне на роля на потребител",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Генериране на токен за достъп",
"actionDeleteAccessToken": "Изтриване на токен за достъп",
"actionListAccessTokens": "Изброяване на токени за достъп",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Odstranit uživatele",
"actionListUsers": "Seznam uživatelů",
"actionAddUserRole": "Přidat uživatelskou roli",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Generovat přístupový token",
"actionDeleteAccessToken": "Odstranit přístupový token",
"actionListAccessTokens": "Seznam přístupových tokenů",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Benutzer entfernen",
"actionListUsers": "Benutzer auflisten",
"actionAddUserRole": "Benutzerrolle hinzufügen",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Zugriffstoken generieren",
"actionDeleteAccessToken": "Zugriffstoken löschen",
"actionListAccessTokens": "Zugriffstoken auflisten",

View File

@@ -1150,6 +1150,7 @@
"actionRemoveUser": "Remove User",
"actionListUsers": "List Users",
"actionAddUserRole": "Add User Role",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Generate Access Token",
"actionDeleteAccessToken": "Delete Access Token",
"actionListAccessTokens": "List Access Tokens",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Eliminar usuario",
"actionListUsers": "Listar usuarios",
"actionAddUserRole": "Añadir rol de usuario",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Generar token de acceso",
"actionDeleteAccessToken": "Eliminar token de acceso",
"actionListAccessTokens": "Lista de Tokens de Acceso",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Supprimer un utilisateur",
"actionListUsers": "Lister les utilisateurs",
"actionAddUserRole": "Ajouter un rôle utilisateur",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Générer un jeton d'accès",
"actionDeleteAccessToken": "Supprimer un jeton d'accès",
"actionListAccessTokens": "Lister les jetons d'accès",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Rimuovi Utente",
"actionListUsers": "Elenca Utenti",
"actionAddUserRole": "Aggiungi Ruolo Utente",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Genera Token di Accesso",
"actionDeleteAccessToken": "Elimina Token di Accesso",
"actionListAccessTokens": "Elenca Token di Accesso",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "사용자 제거",
"actionListUsers": "사용자 목록",
"actionAddUserRole": "사용자 역할 추가",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "액세스 토큰 생성",
"actionDeleteAccessToken": "액세스 토큰 삭제",
"actionListAccessTokens": "액세스 토큰 목록",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Fjern bruker",
"actionListUsers": "List opp brukere",
"actionAddUserRole": "Legg til brukerrolle",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Generer tilgangstoken",
"actionDeleteAccessToken": "Slett tilgangstoken",
"actionListAccessTokens": "List opp tilgangstokener",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Gebruiker verwijderen",
"actionListUsers": "Gebruikers weergeven",
"actionAddUserRole": "Gebruikersrol toevoegen",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Genereer Toegangstoken",
"actionDeleteAccessToken": "Verwijder toegangstoken",
"actionListAccessTokens": "Lijst toegangstokens",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Usuń użytkownika",
"actionListUsers": "Lista użytkowników",
"actionAddUserRole": "Dodaj rolę użytkownika",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Wygeneruj token dostępu",
"actionDeleteAccessToken": "Usuń token dostępu",
"actionListAccessTokens": "Lista tokenów dostępu",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Remover Utilizador",
"actionListUsers": "Listar Utilizadores",
"actionAddUserRole": "Adicionar Função ao Utilizador",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Gerar Token de Acesso",
"actionDeleteAccessToken": "Eliminar Token de Acesso",
"actionListAccessTokens": "Listar Tokens de Acesso",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Удалить пользователя",
"actionListUsers": "Список пользователей",
"actionAddUserRole": "Добавить роль пользователя",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Сгенерировать токен доступа",
"actionDeleteAccessToken": "Удалить токен доступа",
"actionListAccessTokens": "Список токенов доступа",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Kullanıcıyı Kaldır",
"actionListUsers": "Kullanıcıları Listele",
"actionAddUserRole": "Kullanıcı Rolü Ekle",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Erişim Jetonu Oluştur",
"actionDeleteAccessToken": "Erişim Jetonunu Sil",
"actionListAccessTokens": "Erişim Jetonlarını Listele",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "删除用户",
"actionListUsers": "列出用户",
"actionAddUserRole": "添加用户角色",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "生成访问令牌",
"actionDeleteAccessToken": "删除访问令牌",
"actionListAccessTokens": "访问令牌",

View File

@@ -1091,6 +1091,7 @@
"actionRemoveUser": "刪除用戶",
"actionListUsers": "列出用戶",
"actionAddUserRole": "添加用戶角色",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "生成訪問令牌",
"actionDeleteAccessToken": "刪除訪問令牌",
"actionListAccessTokens": "訪問令牌",

View File

@@ -4,6 +4,7 @@ import { userActions, roleActions } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export enum ActionsEnum {
createOrgUser = "createOrgUser",
@@ -54,6 +55,7 @@ export enum ActionsEnum {
// listRoleActions = "listRoleActions",
addUserRole = "addUserRole",
removeUserRole = "removeUserRole",
setUserOrgRoles = "setUserOrgRoles",
// addUserSite = "addUserSite",
// addUserAction = "addUserAction",
// removeUserAction = "removeUserAction",
@@ -158,9 +160,6 @@ export async function checkUserActionPermission(
let userOrgRoleIds = req.userOrgRoleIds;
if (userOrgRoleIds === undefined) {
const { getUserOrgRoleIds } = await import(
"@server/lib/userOrgRoles"
);
userOrgRoleIds = await getUserOrgRoleIds(userId, req.userOrgId!);
if (userOrgRoleIds.length === 0) {
throw createHttpError(

View File

@@ -104,48 +104,6 @@ export async function getUserSessionWithUser(
};
}
/**
* Get user organization role (single role; prefer getUserOrgRoleIds + roles for multi-role).
* @deprecated Use userOrgRoles table and getUserOrgRoleIds for multi-role support.
*/
export async function getUserOrgRole(userId: string, orgId: string) {
const userOrg = await db
.select({
userId: userOrgs.userId,
orgId: userOrgs.orgId,
isOwner: userOrgs.isOwner,
autoProvisioned: userOrgs.autoProvisioned
})
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
if (userOrg.length === 0) return null;
const [firstRole] = await db
.select({
roleId: userOrgRoles.roleId,
roleName: roles.name
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
)
.limit(1);
return firstRole
? {
...userOrg[0],
roleId: firstRole.roleId,
roleName: firstRole.roleName
}
: { ...userOrg[0], roleId: null, roleName: null };
}
/**
* Get role name by role ID (for display).
*/

View File

@@ -17,6 +17,7 @@ export * from "./verifyAccessTokenAccess";
export * from "./requestTimeout";
export * from "./verifyClientAccess";
export * from "./verifyUserHasAction";
export * from "./verifyUserCanSetUserOrgRoles";
export * from "./verifyUserIsServerAdmin";
export * from "./verifyIsLoggedInUser";
export * from "./verifyIsLoggedInUser";

View File

@@ -1,6 +1,7 @@
export * from "./verifyApiKey";
export * from "./verifyApiKeyOrgAccess";
export * from "./verifyApiKeyHasAction";
export * from "./verifyApiKeyCanSetUserOrgRoles";
export * from "./verifyApiKeySiteAccess";
export * from "./verifyApiKeyResourceAccess";
export * from "./verifyApiKeyTargetAccess";

View File

@@ -0,0 +1,74 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
import { ActionsEnum } from "@server/auth/actions";
import { db } from "@server/db";
import { apiKeyActions } from "@server/db";
import { and, eq } from "drizzle-orm";
async function apiKeyHasAction(apiKeyId: string, actionId: ActionsEnum) {
const [row] = await db
.select()
.from(apiKeyActions)
.where(
and(
eq(apiKeyActions.apiKeyId, apiKeyId),
eq(apiKeyActions.actionId, actionId)
)
);
return !!row;
}
/**
* Allows setUserOrgRoles on the key, or both addUserRole and removeUserRole.
*/
export function verifyApiKeyCanSetUserOrgRoles() {
return async function (
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
if (!req.apiKey) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"API Key not authenticated"
)
);
}
const keyId = req.apiKey.apiKeyId;
if (await apiKeyHasAction(keyId, ActionsEnum.setUserOrgRoles)) {
return next();
}
const hasAdd = await apiKeyHasAction(keyId, ActionsEnum.addUserRole);
const hasRemove = await apiKeyHasAction(
keyId,
ActionsEnum.removeUserRole
);
if (hasAdd && hasRemove) {
return next();
}
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have permission perform this action"
)
);
} catch (error) {
logger.error("Error verifying API key set user org roles:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying key action access"
)
);
}
};
}

View File

@@ -0,0 +1,54 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
/**
* Allows the new setUserOrgRoles action, or legacy permission pair addUserRole + removeUserRole.
*/
export function verifyUserCanSetUserOrgRoles() {
return async function (
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const canSet = await checkUserActionPermission(
ActionsEnum.setUserOrgRoles,
req
);
if (canSet) {
return next();
}
const canAdd = await checkUserActionPermission(
ActionsEnum.addUserRole,
req
);
const canRemove = await checkUserActionPermission(
ActionsEnum.removeUserRole,
req
);
if (canAdd && canRemove) {
return next();
}
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
} catch (error) {
logger.error("Error verifying set user org roles access:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying role access"
)
);
}
};
}

View File

@@ -39,6 +39,7 @@ import {
verifyApiKeyAccess,
verifyDomainAccess,
verifyUserHasAction,
verifyUserCanSetUserOrgRoles,
verifyUserIsOrgOwner,
verifySiteResourceAccess,
verifyOlmAccess,
@@ -837,6 +838,16 @@ authenticated.post(
user.updateOrgUser
);
authenticated.post(
"/org/:orgId/user/:userId/roles",
verifyOrgAccess,
verifyUserAccess,
verifyLimits,
verifyUserCanSetUserOrgRoles(),
logActionAudit(ActionsEnum.setUserOrgRoles),
user.setUserOrgRoles
);
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess);

View File

@@ -16,6 +16,7 @@ import {
verifyApiKey,
verifyApiKeyOrgAccess,
verifyApiKeyHasAction,
verifyApiKeyCanSetUserOrgRoles,
verifyApiKeySiteAccess,
verifyApiKeyResourceAccess,
verifyApiKeyTargetAccess,
@@ -814,6 +815,16 @@ authenticated.post(
user.updateOrgUser
);
authenticated.post(
"/org/:orgId/user/:userId/roles",
verifyApiKeyOrgAccess,
verifyApiKeyUserAccess,
verifyLimits,
verifyApiKeyCanSetUserOrgRoles(),
logActionAudit(ActionsEnum.setUserOrgRoles),
user.setUserOrgRoles
);
authenticated.delete(
"/org/:orgId/user/:userId",
verifyApiKeyOrgAccess,

View File

@@ -3,6 +3,7 @@ export * from "./removeUserOrg";
export * from "./listUsers";
export * from "./addUserRole";
export * from "./removeUserRole";
export * from "./setUserOrgRoles";
export * from "./inviteUser";
export * from "./acceptInvite";
export * from "./getOrgUser";

View File

@@ -5,11 +5,10 @@ import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
const listUsersParamsSchema = z.strictObject({
orgId: z.string()
@@ -56,15 +55,24 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
.limit(limit)
.offset(offset);
const roleRows = await db
.select({
userId: userOrgRoles.userId,
roleId: userOrgRoles.roleId,
roleName: roles.name
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgRoles.orgId, orgId));
const userIds = rows.map((r) => r.id);
const roleRows =
userIds.length === 0
? []
: await db
.select({
userId: userOrgRoles.userId,
roleId: userOrgRoles.roleId,
roleName: roles.name
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.orgId, orgId),
inArray(userOrgRoles.userId, userIds)
)
);
const rolesByUser = new Map<
string,

View File

@@ -0,0 +1,151 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { clients, db } from "@server/db";
import { userOrgRoles, userOrgs, roles } from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
const setUserOrgRolesParamsSchema = z.strictObject({
orgId: z.string(),
userId: z.string()
});
const setUserOrgRolesBodySchema = z.strictObject({
roleIds: z.array(z.int().positive()).min(1)
});
export async function setUserOrgRoles(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = setUserOrgRolesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = setUserOrgRolesBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId, userId } = parsedParams.data;
const { roleIds } = parsedBody.data;
if (req.user && !req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You do not have access to this organization"
)
);
}
const uniqueRoleIds = [...new Set(roleIds)];
const [existingUser] = await db
.select()
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
if (!existingUser) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found in this organization"
)
);
}
if (existingUser.isOwner) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Cannot change the roles of the owner of the organization"
)
);
}
const orgRoles = await db
.select({ roleId: roles.roleId })
.from(roles)
.where(
and(
eq(roles.orgId, orgId),
inArray(roles.roleId, uniqueRoleIds)
)
);
if (orgRoles.length !== uniqueRoleIds.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"One or more role IDs are invalid for this organization"
)
);
}
await db.transaction(async (trx) => {
await trx
.delete(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
);
if (uniqueRoleIds.length > 0) {
await trx.insert(userOrgRoles).values(
uniqueRoleIds.map((roleId) => ({
userId,
orgId,
roleId
}))
);
}
const orgClients = await trx
.select()
.from(clients)
.where(
and(eq(clients.userId, userId), eq(clients.orgId, orgId))
);
for (const orgClient of orgClients) {
await rebuildClientAssociationsFromClient(orgClient, trx);
}
});
return response(res, {
data: { userId, orgId, roleIds: uniqueRoleIds },
success: true,
error: false,
message: "User roles set successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -3,23 +3,18 @@
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Checkbox } from "@app/components/ui/checkbox";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ListRolesResponse } from "@server/routers/role";
@@ -42,12 +37,20 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { UserType } from "@server/types/UserTypes";
import { Badge } from "@app/components/ui/badge";
type UserRole = { roleId: number; name: string };
const accessControlsFormSchema = z.object({
username: z.string(),
autoProvisioned: z.boolean(),
roles: z.array(
z.object({
id: z.string(),
text: z.string()
})
)
});
export default function AccessControlsPage() {
const { orgUser: user } = userOrgUserContext();
const { orgUser: user, updateOrgUser } = userOrgUserContext();
const api = createApiClient(useEnvContext());
@@ -55,28 +58,34 @@ export default function AccessControlsPage() {
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
null
);
const t = useTranslations();
const formSchema = z.object({
username: z.string(),
autoProvisioned: z.boolean()
});
const form = useForm({
resolver: zodResolver(formSchema),
resolver: zodResolver(accessControlsFormSchema),
defaultValues: {
username: user.username!,
autoProvisioned: user.autoProvisioned || false
autoProvisioned: user.autoProvisioned || false,
roles: (user.roles ?? []).map((r) => ({
id: r.roleId.toString(),
text: r.name
}))
}
});
const currentRoleIds = user.roleIds ?? [];
const currentRoles: UserRole[] = user.roles ?? [];
useEffect(() => {
setUserRoles(currentRoles);
form.setValue(
"roles",
(user.roles ?? []).map((r) => ({
id: r.roleId.toString(),
text: r.name
}))
);
}, [user.userId, currentRoleIds.join(",")]);
useEffect(() => {
@@ -104,59 +113,67 @@ export default function AccessControlsPage() {
form.setValue("autoProvisioned", user.autoProvisioned || false);
}, []);
async function handleAddRole(roleId: number) {
setLoading(true);
try {
await api.post(`/role/${roleId}/add/${user.userId}`);
toast({
variant: "default",
title: t("userSaved"),
description: t("userSavedDescription")
});
const role = roles.find((r) => r.roleId === roleId);
if (role) setUserRoles((prev) => [...prev, role]);
} catch (e) {
const allRoleOptions = useMemo(
() =>
roles
.map((role) => ({
id: role.roleId.toString(),
text: role.name
}))
.filter((role) => role.text !== "Admin"),
[roles]
);
function setRoleTags(
updater: Tag[] | ((prev: Tag[]) => Tag[])
) {
const prev = form.getValues("roles");
const next = typeof updater === "function" ? updater(prev) : updater;
if (next.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: formatAxiosError(
e,
t("accessRoleErrorAddDescription")
)
description: t("accessRoleSelectPlease")
});
return;
}
setLoading(false);
form.setValue("roles", next, { shouldDirty: true });
}
async function handleRemoveRole(roleId: number) {
setLoading(true);
try {
await api.delete(`/role/${roleId}/remove/${user.userId}`);
toast({
variant: "default",
title: t("userSaved"),
description: t("userSavedDescription")
});
setUserRoles((prev) => prev.filter((r) => r.roleId !== roleId));
} catch (e) {
async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) {
if (values.roles.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: formatAxiosError(
e,
t("accessRoleErrorAddDescription")
)
description: t("accessRoleSelectPlease")
});
return;
}
setLoading(false);
}
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
try {
await api.post(`/org/${orgId}/user/${user.userId}`, {
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
await Promise.all([
api.post(`/org/${orgId}/user/${user.userId}/roles`, {
roleIds
}),
api.post(`/org/${orgId}/user/${user.userId}`, {
autoProvisioned: values.autoProvisioned
})
]);
updateOrgUser({
roleIds,
roles: values.roles.map((r) => ({
roleId: parseInt(r.id, 10),
name: r.text
})),
autoProvisioned: values.autoProvisioned
});
toast({
variant: "default",
title: t("userSaved"),
@@ -175,11 +192,6 @@ export default function AccessControlsPage() {
setLoading(false);
}
const availableRolesToAdd = roles.filter(
(r) => !userRoles.some((ur) => ur.roleId === r.roleId)
);
const canRemoveRole = userRoles.length > 1;
return (
<SettingsContainer>
<SettingsSection>
@@ -216,72 +228,43 @@ export default function AccessControlsPage() {
</div>
)}
<FormItem>
<FormLabel>{t("role")}</FormLabel>
<div className="flex flex-wrap gap-2 items-center">
{userRoles.map((r) => (
<Badge
key={r.roleId}
variant="secondary"
className="flex items-center gap-1"
>
{r.name}
{canRemoveRole && (
<button
type="button"
onClick={() =>
handleRemoveRole(
r.roleId
)
}
disabled={loading}
className="ml-1 rounded hover:bg-muted"
aria-label={`Remove ${r.name}`}
>
×
</button>
)}
</Badge>
))}
{availableRolesToAdd.length > 0 && (
<Select
onValueChange={(value) => {
handleAddRole(
parseInt(value, 10)
);
}}
disabled={loading}
>
<SelectTrigger className="w-[180px]">
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
<SelectContent>
{availableRolesToAdd.map(
(role) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
)
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("role")}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeRoleTagIndex
}
setActiveTagIndex={
setActiveRoleTagIndex
}
placeholder={t(
"accessRoleSelect2"
)}
</SelectContent>
</Select>
)}
</div>
{userRoles.length === 0 && (
<p className="text-sm text-muted-foreground">
{t("accessRoleSelectPlease")}
</p>
size="sm"
tags={field.value}
setTags={setRoleTags}
enableAutocomplete={true}
autocompleteOptions={
allRoleOptions
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
disabled={loading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
</FormItem>
/>
{user.idpAutoProvision && (
<FormField
@@ -299,9 +282,7 @@ export default function AccessControlsPage() {
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t(
"autoProvisioned"
)}
{t("autoProvisioned")}
</FormLabel>
<p className="text-sm text-muted-foreground">
{t(

View File

@@ -86,11 +86,14 @@ export default async function UsersPage(props: UsersPageProps) {
idpId: user.idpId,
idpName: user.idpName || t("idpNameInternal"),
status: t("userConfirmed"),
role: user.isOwner
? t("accessRoleOwner")
: user.roles?.length
? user.roles.map((r) => r.roleName).join(", ")
: t("accessRoleMember"),
roleLabels: user.isOwner
? [t("accessRoleOwner")]
: (() => {
const names = (user.roles ?? [])
.map((r) => r.roleName)
.filter((n): n is string => Boolean(n?.length));
return names.length ? names : [t("accessRoleMember")];
})(),
isOwner: user.isOwner || false
};
});

View File

@@ -129,12 +129,13 @@ export default function ResourceAuthenticationPage() {
orgId: org.org.orgId
})
);
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
orgQueries.identityProviders({
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery({
...orgQueries.identityProviders({
orgId: org.org.orgId,
useOrgOnlyIdp: env.app.identityProviderMode === "org"
})
);
}),
enabled: isPaidUser(tierMatrix.orgOidc)
});
const pageLoading =
isLoadingOrgRoles ||

View File

@@ -95,7 +95,8 @@ function getActionsCategories(root: boolean) {
[t("actionListRole")]: "listRoles",
[t("actionUpdateRole")]: "updateRole",
[t("actionListAllowedRoleResources")]: "listRoleResources",
[t("actionAddUserRole")]: "addUserRole"
[t("actionAddUserRole")]: "addUserRole",
[t("actionSetUserOrgRoles")]: "setUserOrgRoles"
},
"Access Token": {
[t("actionGenerateAccessToken")]: "generateAccessToken",

View File

@@ -12,6 +12,13 @@ import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "@app/components/UsersDataTable";
import { useState, useEffect } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Badge, badgeVariants } from "@app/components/ui/badge";
import { cn } from "@app/lib/cn";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
@@ -36,10 +43,65 @@ export type UserRow = {
type: string;
idpVariant: string | null;
status: string;
role: string;
roleLabels: string[];
isOwner: boolean;
};
const MAX_ROLE_BADGES = 3;
function UserRoleBadges({ roleLabels }: { roleLabels: string[] }) {
const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
const overflow = roleLabels.slice(MAX_ROLE_BADGES);
return (
<div className="flex flex-wrap items-center gap-1">
{visible.map((label, i) => (
<Badge key={`${label}-${i}`} variant="secondary">
{label}
</Badge>
))}
{overflow.length > 0 && (
<OverflowRolesPopover labels={overflow} />
)}
</div>
);
}
function OverflowRolesPopover({ labels }: { labels: string[] }) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
badgeVariants({ variant: "secondary" }),
"border-dashed"
)}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
+{labels.length}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="w-auto max-w-xs p-2"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<ul className="space-y-1 text-sm">
{labels.map((label, i) => (
<li key={`${label}-${i}`}>{label}</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
type UsersTableProps = {
users: UserRow[];
};
@@ -124,7 +186,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
}
},
{
accessorKey: "role",
id: "role",
accessorFn: (row) => row.roleLabels.join(", "),
friendlyName: t("role"),
header: ({ column }) => {
return (
@@ -140,13 +203,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
);
},
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-2">
<span>{userRow.role}</span>
</div>
);
return <UserRoleBadges roleLabels={row.original.roleLabels} />;
}
},
{

View File

@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0",
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0",
{
variants: {
variant: {