Skip to content

Commit

Permalink
chore: add user and roles type refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
brunotot committed Oct 10, 2024
1 parent 86fc7b4 commit 3db8df4
Show file tree
Hide file tree
Showing 24 changed files with 168 additions and 119 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import type { TODO } from "@org/lib-commons";
import { contract } from "@org/app-node-express/infrastructure/decorators";
import { withRouteSecured } from "@org/app-node-express/infrastructure/middleware/withRouteSecured";
import { autowired, inject } from "@org/app-node-express/ioc";
import { contracts } from "@org/lib-api-client";
import { contracts, KcUserRole } from "@org/lib-api-client";

@inject("UserController")
export class UserController {
@autowired() private userService: UserService;

@contract(contracts.User.findOneByUsername, withRouteSecured("admin"))
@contract(contracts.User.findOneByUsername, withRouteSecured(KcUserRole.Enum["avr-admin"]))
async findOneByUsername(
payload: RouteInput<typeof contracts.User.findOneByUsername>,
): RouteOutput<typeof contracts.User.findOneByUsername> {
Expand All @@ -21,15 +21,15 @@ export class UserController {
};
}

@contract(contracts.User.findAll, withRouteSecured("admin"))
@contract(contracts.User.findAll, withRouteSecured(KcUserRole.Enum["avr-admin"]))
async findAll(): RouteOutput<typeof contracts.User.findAll> {
return {
status: 200,
body: await this.userService.findAll(),
};
}

@contract(contracts.User.findAllPaginated, withRouteSecured("admin"))
@contract(contracts.User.findAllPaginated, withRouteSecured(KcUserRole.Enum["avr-admin"]))
async findAllPaginated(
payload: RouteInput<typeof contracts.User.findAllPaginated>,
): RouteOutput<typeof contracts.User.findAllPaginated> {
Expand All @@ -40,7 +40,7 @@ export class UserController {
};
}

@contract(contracts.User.createUser, withRouteSecured("admin"))
@contract(contracts.User.createUser, withRouteSecured(KcUserRole.Enum["avr-admin"]))
async createUser(
payload: RouteInput<typeof contracts.User.createUser>,
): RouteOutput<typeof contracts.User.createUser> {
Expand All @@ -50,7 +50,7 @@ export class UserController {
};
}

@contract(contracts.User.deleteUser, withRouteSecured("admin"))
@contract(contracts.User.deleteUser, withRouteSecured(KcUserRole.Enum["avr-admin"]))
async deleteUser(
payload: RouteInput<typeof contracts.User.deleteUser>,
): RouteOutput<typeof contracts.User.deleteUser> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { AuthorizationMiddleware } from "@org/app-node-express/infrastructure/middleware/withAuthorization";
import type { UserService } from "@org/app-node-express/infrastructure/service/UserService";
import type { RouteMiddlewareFactory } from "@org/app-node-express/lib/@ts-rest";
import type { Role } from "@org/lib-api-client";
import type { KcUserRole } from "@org/lib-api-client";
import type { RequestHandler } from "express";

import { IocRegistry, autowired, inject } from "@org/app-node-express/ioc";
import { RestError, getTypedError } from "@org/lib-api-client";
import jwt from "jsonwebtoken";

export interface RouteSecuredMiddleware {
middleware(...roles: Role[]): RequestHandler[];
middleware(...roles: KcUserRole[]): RequestHandler[];
}

export type TokenData = {
Expand All @@ -26,7 +26,7 @@ export class WithRouteSecured implements RouteSecuredMiddleware {
@autowired() private userService: UserService;
@autowired() private authorizationMiddleware: AuthorizationMiddleware;

public middleware(...roles: Role[]): RequestHandler[] {
public middleware(...roles: KcUserRole[]): RequestHandler[] {
const protect: RequestHandler = async (req, res, next) => {
try {
const handler = this.authorizationMiddleware.protect();
Expand All @@ -44,7 +44,7 @@ export class WithRouteSecured implements RouteSecuredMiddleware {

const tokenData = this.decodeToken(token);

const { roles: userRoles } = await this.userService.findOneByUsername(
const { realmRoles: userRoles } = await this.userService.findOneByUsername(
tokenData.preferred_username,
);

Expand Down Expand Up @@ -81,7 +81,7 @@ export class WithRouteSecured implements RouteSecuredMiddleware {
}
}

export function withRouteSecured(...roles: Role[]): RouteMiddlewareFactory {
export function withRouteSecured(...roles: KcUserRole[]): RouteMiddlewareFactory {
return () =>
IocRegistry.getInstance()
.inject<RouteSecuredMiddleware>(IOC_KEY)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import type {
ApiKeycloakRoles,
ApiKeycloakUser /*KcUserRepresentation*/,
KcUserRepresentation,
} from "@org/lib-api-client";
import type { KcUserRepresentation } from "@org/lib-api-client";

import { inject } from "@org/app-node-express/ioc";
import { KeycloakDao } from "@org/app-node-express/lib/keycloak";

export interface AuthorizationRepository {
findAllUsers(): Promise<ApiKeycloakUser[]>;
findUserByUsername(username: string): Promise<ApiKeycloakUser | null>;
findAllUsers(): Promise<KcUserRepresentation[]>;
findUserByUsername(username: string): Promise<KcUserRepresentation | null>;
findRolesByUserId(userId: string): Promise<string[]>;
createUser(model: KcUserRepresentation): Promise<KcUserRepresentation>;
deleteUser(id: string): Promise<void>;
Expand All @@ -20,21 +16,46 @@ export interface AuthorizationRepository {
*/
@inject("AuthorizationRepository")
export class UserRepository extends KeycloakDao implements AuthorizationRepository {
public async findUserByUsername(username: string): Promise<ApiKeycloakUser | null> {
const users = await this.get<ApiKeycloakUser[]>(`/users?username=${username}`);
public async findUserByUsername(username: string): Promise<KcUserRepresentation | null> {
const users = await this.get<KcUserRepresentation[]>(`/users?username=${username}`);
if (users.length === 0) return null;
const user = users.filter(user => user.username === username)[0];
return user;
}

public async findAllUsers(): Promise<ApiKeycloakUser[]> {
const users = await this.get<ApiKeycloakUser[]>(`/users`);
return users;
public async findAllUsers(): Promise<KcUserRepresentation[]> {
// Fetch users without realmRoles
const users = await this.get<Omit<KcUserRepresentation, "realmRoles">[]>(`/users`);

// Map the users array to include realmRoles, awaiting each role fetch using findRolesByUserId
const usersWithRealmRoles = await Promise.all(
users.map(async user => {
const realmRoles = await this.findRolesByUserId(user.id!); // Use existing method to fetch realm roles
return {
...user,
realmRoles, // Add realmRoles to the user object
};
}),
);

return usersWithRealmRoles;
}

public async findRolesByUserId(userId: string): Promise<string[]> {
const res = await this.get<ApiKeycloakRoles>(`/users/${userId}/role-mappings/realm`);
return res.map(({ name }: { name: string }) => name);
type ApiKeycloakRoles = { name: string }[];
const res = await this.get<{
realmMappings?: ApiKeycloakRoles;
clientMappings?: Record<string, { mappings: ApiKeycloakRoles }>;
}>(`/users/${userId}/role-mappings`);

const mapped = [
...(res.realmMappings || []).map(({ name }: { name: string }) => name),
...Object.values(res.clientMappings || {})
.map(v => v.mappings.map(({ name }: { name: string }) => name))
.flat(),
];

return mapped;
}

public async createUser(model: KcUserRepresentation): Promise<KcUserRepresentation> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { AuthorizationRepository } from "../repository/UserRepository";
import type {
ApiKeycloakUser,
KcUserRepresentation,
Role,
KcUserRole,
TypedPaginationResponse,
User,
} from "@org/lib-api-client";
Expand Down Expand Up @@ -35,24 +34,19 @@ export class UserService {

async createUser(model: KcUserRepresentation): Promise<User> {
const user = await this.authorizationRepository.createUser(model);
return {
_id: user.id,
username: user.username,
roles: model.realmRoles,
};
return this.userMapper(user);
}

async deleteUser(id: string): Promise<void> {
await this.authorizationRepository.deleteUser(id);
}

private async userMapper(model: ApiKeycloakUser): Promise<User> {
const roles = await this.authorizationRepository.findRolesByUserId(model.id);
private async userMapper(model: KcUserRepresentation): Promise<User> {
const roles = await this.authorizationRepository.findRolesByUserId(model.id!);
return {
_id: model.id,
username: model.username,
roles: roles.filter(
(role: string): role is Role => !!ROLE_LIST.find((r: string) => r === role),
...model,
realmRoles: roles.filter(
(role: string): role is KcUserRole => !!ROLE_LIST.find((r: string) => r === role),
),
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,48 @@
import type { AuthorizationRepository } from "../../../dist/infrastructure/repository/UserRepository";
import type { ApiKeycloakUser } from "@org/lib-api-client";
import type { KcUserRepresentation, User } from "@org/lib-api-client";

export class AuthorizationRepositoryMock implements AuthorizationRepository {
static roles: Record<string, string[]> = {
admin: ["admin", "user"],
user: ["user"],
admin: ["avr-admin", "avr-user"],
user: ["avr-user"],
};

static users: ApiKeycloakUser[] = [
static users: User[] = [
{
id: "1",
username: "admin",
enabled: true,
realmRoles: ["avr-admin"],
},
{
id: "2",
username: "user",
enabled: true,
realmRoles: ["avr-user"],
},
];

async findRolesByUserId(userId: string): Promise<string[]> {
return await Promise.resolve(AuthorizationRepositoryMock.roles[userId] ?? []);
}

async findUserByUsername(username: string): Promise<ApiKeycloakUser | null> {
async findUserByUsername(username: string): Promise<User | null> {
return await Promise.resolve(
AuthorizationRepositoryMock.users.find(user => user.username === username) ?? null,
);
}

async findAllUsers(): Promise<ApiKeycloakUser[]> {
async findAllUsers(): Promise<User[]> {
return await Promise.resolve(AuthorizationRepositoryMock.users);
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
async createUser(_model: KcUserRepresentation): Promise<KcUserRepresentation> {
throw new Error("Method not implemented.");
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
async deleteUser(_id: string): Promise<void> {
throw new Error("Method not implemented.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,6 @@ export function UserMenuButton() {

<mui.Divider sx={{ marginBlock: "0.5rem" }} />

{/*<mui.Typography variant="body2">Roles:</mui.Typography>
<mui.List dense>
<mui.ListItem disablePadding>
<mui.ListItemText primary="Admin" />
</mui.ListItem>
<mui.ListItem disablePadding>
<mui.ListItemText primary="Editor" />
</mui.ListItem>
</mui.List>*/}

<mui.MenuItem sx={{ paddingInline: "0.5rem" }} onClick={handleClose}>
<mui.ListItemIcon>
<icons.Settings />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { Role } from "@org/lib-api-client";
import type { KcUserRole } from "@org/lib-api-client";
import type { ReactNode } from "react";

import * as mui from "@mui/material";
import { sigUser } from "@org/app-vite-react/signals/sigUser";

export type ProtectProps = {
children: ReactNode;
roles: Role[];
roles: KcUserRole[];
};

export function Protect({ children, roles }: ProtectProps) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { KcUserRepresentation } from "@org/lib-api-client";

import { Add } from "@mui/icons-material";
import { Button, Dialog, DialogContent } from "@mui/material";
import { UserForm } from "@org/app-vite-react/app/pages/admin-settings/manage-users/UserForm";
import { UserForm } from "@org/app-vite-react/app/pages/admin-settings/manage-users/components";
import { tsrClient } from "@org/app-vite-react/lib/@ts-rest";
import { useState } from "react";

Expand All @@ -13,7 +13,8 @@ export type UserCreateFormButtonProps = {
const DEFAULT_FORM_STATE: KcUserRepresentation = {
id: "",
username: "",
realmRoles: ["user"],
enabled: true,
realmRoles: ["avr-user"],
};

export function UserCreateFormButton({ afterUpdate }: UserCreateFormButtonProps) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TextField, Button, Box, Autocomplete, MenuItem, Chip } from "@mui/material";
import { type User, type Role, ROLE_LIST, type KcUserRepresentation } from "@org/lib-api-client";
import { type KcUserRole, ROLE_LIST, type KcUserRepresentation } from "@org/lib-api-client";
import React from "react";

export type UserFormProps = {
Expand All @@ -9,10 +9,11 @@ export type UserFormProps = {
};

export function UserForm({ value, onChange, onSubmit }: UserFormProps) {
const mutate = (diff: Partial<User>) => {
const mutate = (diff: Partial<KcUserRepresentation>) => {
onChange({
...value,
...diff,
enabled: true,
});
};

Expand All @@ -24,17 +25,34 @@ export function UserForm({ value, onChange, onSubmit }: UserFormProps) {
>
<TextField
label="Username"
name="username"
value={value.username}
onChange={e => mutate({ username: e.target.value })}
required
/>
<TextField
label="First name"
value={value.firstName}
onChange={e => mutate({ firstName: e.target.value })}
required
/>
<TextField
label="Last name"
value={value.lastName}
onChange={e => mutate({ lastName: e.target.value })}
required
/>
<TextField
label="Email"
value={value.email}
onChange={e => mutate({ email: e.target.value })}
required
/>
<Autocomplete
multiple
id="tags-outlined"
options={ROLE_LIST}
getOptionLabel={option => option}
onChange={(_, newValue) => mutate({ roles: newValue as Role[] })}
onChange={(_, newValue) => mutate({ realmRoles: newValue as KcUserRole[] })}
value={value.realmRoles}
disableCloseOnSelect
filterSelectedOptions
Expand All @@ -43,7 +61,7 @@ export function UserForm({ value, onChange, onSubmit }: UserFormProps) {
{option}
</MenuItem>
)}
renderInput={params => <TextField {...params} label="Roles" placeholder="Roles" />}
renderInput={params => <TextField {...params} label="Realm roles" placeholder="Roles" />}
renderTags={(tagValue, getTagProps) =>
tagValue.map((option, index) => (
<Chip {...getTagProps({ index })} key={option} label={option} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./FixedBadge";
export * from "./UserCreateFormButton";
export * from "./UserForm";
Loading

0 comments on commit 3db8df4

Please sign in to comment.