feat: beta version of role management for single user

This commit is contained in:
2025-07-20 17:59:54 +02:00
parent 533e6ea6af
commit d35e5813b5
16 changed files with 680 additions and 177 deletions

View File

@ -18,3 +18,34 @@ export const getRolesApi = async (): Promise<FetchGroupedRolesResponse> => {
return response.data;
};
export type FetchUserRolesResponse = AppRole[];
export const getUserRolesApi = async (
userId: string,
): Promise<FetchUserRolesResponse> => {
const response = await axios.get<FetchUserRolesResponse>(
`/api/v1/admin/roles/${userId}`,
);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};
export interface AssignUserRoleRequest {
role_key: string;
}
export const assignUserRoleApi = async (
userId: string,
params: AssignUserRoleRequest,
) => {
const response = await axios.patch(`/api/v1/admin/roles/${userId}`, params);
if (response.status !== 200 && response.status !== 201)
throw await handleApiError(response);
return response.data;
};

View File

@ -0,0 +1,115 @@
import { Button } from "@/components/ui/button";
import { useRoles } from "@/store/admin/rolesGroups";
import { useUsers } from "@/store/admin/users";
import type { AppRole } from "@/types";
import { ChevronDown, LoaderCircle, Plus } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
type Props = {
userRoles: AppRole[];
onToggleRole?: (scope: string, role: string, isAssigned: boolean) => void;
};
const FoldableRolesTable: React.FC<Props> = ({ userRoles, onToggleRole }) => {
const [openScopes, setOpenScopes] = useState<Record<string, boolean>>({});
const roleMap = useRoles((s) => s.roles);
const loadRoles = useRoles((s) => s.fetch);
const loadUserRoles = useUsers((state) => state.fetchUserRoles);
const user = useUsers((s) => s.current);
const togglingRole = useRoles((s) => s.toggling);
const toggleRole = useRoles((s) => s.assign);
const toggleUserRole = useCallback(
async (role: AppRole) => {
if (togglingRole === role.id) return;
if (user) {
await toggleRole(user.id, role);
loadRoles();
loadUserRoles();
}
},
[loadRoles, loadUserRoles, toggleRole, togglingRole, user],
);
const toggleScope = (scope: string) => {
setOpenScopes((prev) => ({
...prev,
[scope]: !prev[scope],
}));
};
useEffect(() => {
loadRoles();
}, [loadRoles]);
return (
<div className="w-full mx-auto shadow-md overflow-hidden">
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{Object.entries(roleMap).map(([scope, roles]) => (
<div key={`${scope}`}>
<div
className="w-full text-left px-4 py-3 flex items-center justify-between cursor-pointer"
onClick={() => toggleScope(scope)}
>
<div className="flex items-center">
<span className="font-semibold text-gray-800 dark:text-gray-200">
{scope.toUpperCase()}{" "}
<span className="text-sm opacity-50 font-light">
(
{
roles.filter((r) =>
userRoles.some((ur) => ur.id === r.id),
).length
}
/{roles.length})
</span>
</span>
</div>
<span className="text-sm text-gray-500">
<ChevronDown
className={`${openScopes[scope] ? "rotate-180" : ""} transition`}
/>
</span>
</div>
<ul
className={`px-5 py-1 pb-4 transition-height ${openScopes[scope] ? "h-auto" : "h-0 py-0!"} overflow-hidden flex items-center flex-wrap gap-2`}
>
{roles.length === 0 ? (
<li className="text-gray-500 italic">No roles found</li>
) : (
roles.map((role) => (
<li
key={role.id}
className="flex items-center justify-between"
onClick={() => toggleUserRole(role)}
>
<span
className={`px-2 py-1 flex items-center gap-1 cursor-pointer select-none rounded text-xs ${
userRoles.some((ur) => ur.id === role.id)
? "bg-green-200 text-green-800 dark:bg-green-700/20 dark:text-green-300"
: "bg-red-200 text-red-800 dark:bg-red-700/20 dark:text-red-300"
} ${togglingRole === role.id ? "opacity-50" : ""}`}
>
{togglingRole === role.id && (
<LoaderCircle className="animate-spin" size={12} />
)}
{role.name.toUpperCase()}
</span>
</li>
))
)}
</ul>
</div>
))}
</div>
</div>
);
};
export default FoldableRolesTable;

View File

@ -0,0 +1,67 @@
import React from "react";
type RoleMatrixProps = {
scopes: string[];
roles: string[];
userRoles: Record<string, string[]>; // e.g. { "Project Alpha": ["admin", "viewer"] }
onToggle?: (scope: string, role: string, isAssigned: boolean) => void;
};
const RoleMatrix: React.FC<RoleMatrixProps> = ({
scopes,
roles,
userRoles,
onToggle,
}) => {
const isChecked = (scope: string, role: string) =>
userRoles[scope]?.includes(role) ?? false;
return (
<div className="overflow-x-auto">
<table className="min-w-full table-auto border border-gray-300">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-300 px-4 py-2 text-left">
Scope / Role
</th>
{roles.map((role) => (
<th
key={role}
className="border border-gray-300 px-4 py-2 text-center capitalize"
>
{role}
</th>
))}
</tr>
</thead>
<tbody>
{scopes.map((scope) => (
<tr key={scope} className="hover:bg-gray-50">
<td className="border border-gray-300 px-4 py-2 font-medium">
{scope}
</td>
{roles.map((role) => {
const checked = isChecked(scope, role);
return (
<td
key={`${scope}-${role}`}
className="border border-gray-300 px-4 py-2 text-center"
>
<input
type="checkbox"
className="form-checkbox h-4 w-4 text-indigo-600 transition duration-150"
checked={checked}
onChange={() => onToggle?.(scope, role, !checked)}
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
};
export default RoleMatrix;

View File

@ -9,6 +9,11 @@
-ms-overflow-style: none;
scrollbar-width: none;
}
.transition-height {
transition-property: height;
transition-duration: 300ms;
transition-timing-function: ease;
}
}
html,

View File

@ -1,39 +1,65 @@
import Breadcrumbs from "@/components/ui/breadcrumbs";
import { Button } from "@/components/ui/button";
import Avatar from "@/feature/Avatar";
import FoldableRolesTable from "@/feature/FoldableRolesTable";
import RoleMatrix from "@/feature/RoleMatrix";
import { useRoles } from "@/store/admin/rolesGroups";
import { useUsers } from "@/store/admin/users";
import { useEffect, type FC } from "react";
import type { UserProfile } from "@/types";
import { Check, Mail, Phone } from "lucide-react";
import moment from "moment";
import { useEffect, type FC, type HTMLAttributes } from "react";
import { Link, useParams } from "react-router";
const InfoCard = ({
title,
children,
}: {
interface InfoCardProps extends HTMLAttributes<HTMLDivElement> {
title: string;
children: React.ReactNode;
}) => (
<div className="border dark:border-gray-800 border-gray-300 rounded mb-4">
<div className="p-4 border-b dark:border-gray-800 border-gray-300">
<h2 className="text-gray-800 dark:text-gray-200 font-semibold text-lg">
{title}
</h2>
hasSpacing?: boolean | undefined;
}
const InfoCard = ({
children,
hasSpacing = true,
title,
...props
}: InfoCardProps) => (
<div className="mb-6">
<h2 className="mb-4 text-xl">{title}</h2>
<div
{...props}
className={`border dark:border-gray-800 border-gray-300 rounded mb-4 ${props.className}`}
>
{/* <div className="p-4 border-b dark:border-gray-800 border-gray-300">
<h2 className="text-gray-800 dark:text-gray-200 font-semibold text-lg">
{title}
</h2>
</div> */}
<div className={hasSpacing ? "p-4" : ""}>{children}</div>
</div>
<div className="p-4">{children}</div>
</div>
);
const AdminViewUserPage: FC = () => {
const { userId } = useParams();
const user = useUsers((state) => state.current);
const userPermissions = useUsers((s) => s.userPermissions);
const userRoles = useUsers((s) => s.userRoles);
// const loading = useApiServices((state) => state.fetchingApiService);
const loadUser = useUsers((state) => state.fetchUser);
const loadUserRoles = useUsers((state) => state.fetchUserRoles);
useEffect(() => {
if (typeof userId === "string") loadUser(userId);
}, [loadUser, userId]);
useEffect(() => {
if (user) loadUserRoles();
}, [loadUserRoles, user]);
console.log({ userRoles });
if (!user) {
return (
<div className="p-4 flex items-center justify-center h-[60vh]">
@ -56,74 +82,138 @@ const AdminViewUserPage: FC = () => {
/>
<div className="sm:p-4 pt-4">
{/* 📋 Main Details */}
<InfoCard title="Personal Info">
<InfoCard title="Profile" hasSpacing={false}>
<div className="flex flex-col gap-4 text-sm">
<div className="flex flex-col gap-4">
<span className="font-medium text-gray-900 dark:text-white">
Avatar:
</span>
<Avatar
avatarId={user.profile_picture ?? null}
className="w-16 h-16"
iconSize={28}
/>
<div className="bg-black/15 shadow/20">
{/* Header */}
<div className="flex flex-row gap-4 items-center p-4">
<Avatar
avatarId={user.profile_picture ?? null}
className="w-28 h-28"
iconSize={48}
/>
<div className="flex flex-col gap-1">
<h2 className="text-lg font-medium">{user.full_name}</h2>
<div className="flex flex-row items-center gap-2">
<h3 className="text-base opacity-50 flex flex-row items-center gap-2">
<span>
<Mail size={18} />
</span>
{user.email}
</h3>
<div className="w-1 h-1 rounded-full bg-gray-600" />
<h3 className="text-base opacity-50 flex flex-row items-center gap-2">
<span>
<Phone size={18} />
</span>
{user.phone_number || "No Phone Number"}
</h3>
</div>
</div>
</div>
{/* TODO: */}
{/* Top Bars */}
{/* <div className="w-full border-b border-b-gray-600 p-4">
somethign
</div> */}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Full Name:
</span>{" "}
{user.full_name}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Email:
</span>{" "}
{user.email}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Phone Number:
</span>{" "}
{user.phone_number || "-"}{" "}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Is Admin:
</span>{" "}
<span
className={`font-semibold px-2 py-1 rounded ${
user.is_admin
? "bg-green-200 text-green-800 dark:bg-green-700/20 dark:text-green-300"
: "bg-red-200 text-red-800 dark:bg-red-700/20 dark:text-red-300"
}`}
>
{user.is_admin ? "Yes" : "No"}
</span>
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Created At:
</span>{" "}
{new Date(user.created_at).toLocaleString()}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
Last Login At:
</span>{" "}
{user.last_login
? new Date(user.last_login).toLocaleString()
: "never"}
<div className="grid grid-cols-2 gap-2 gap-y-4 p-4">
<div className="flex flex-col gap-2 items-start">
<span className="font-medium text-gray-400 dark:text-gray-500">
Verification
</span>{" "}
<div className="flex items-center gap-2">
{[
["avatar_verified", "Avatar"],
["email_verified", "Email"],
].map(([key, label]) => (
<span
key={key}
className={`px-2 py-1 rounded ${
user[key as keyof UserProfile] === true
? "bg-green-200 text-green-800 dark:bg-green-700/20 dark:text-green-300"
: "bg-red-200 text-red-800 dark:bg-red-700/20 dark:text-red-300"
}`}
>
{label}
</span>
))}
</div>
</div>
<div className="flex flex-col gap-2 items-start">
<span className="font-medium text-gray-400 dark:text-gray-500">
Roles
</span>{" "}
<span className={`font-semibold py-1 rounded`}>
{userRoles.length === 0 && <p>No Roles</p>}
{userRoles.map((role) => (
<span
key={role.id}
className="px-2 py-1 rounded bg-blue-200 text-blue-800 dark:bg-blue-700/20 dark:text-blue-300"
>
{role.name}
</span>
))}
</span>
</div>
<div className="flex flex-col gap-2 items-start">
<span className="font-medium text-gray-400 dark:text-gray-500">
Created At
</span>{" "}
<span className={`font-semibold py-1 rounded`}>
{moment(user.created_at).format("LLLL")}
</span>
</div>
<div className="flex flex-col gap-2 items-start">
<span className="font-medium text-gray-400 dark:text-gray-500">
Last Login
</span>{" "}
<span className={`font-semibold py-1 rounded`}>
{user.last_login
? moment(user.last_login).format("LLLL")
: "never"}
</span>
</div>
</div>
</div>
</InfoCard>
<InfoCard title="Roles & Permissions">
<pre>{JSON.stringify(userPermissions, null, 2)}</pre>
<InfoCard title="Roles Management" hasSpacing={false}>
<FoldableRolesTable userRoles={userRoles} />
</InfoCard>
{/* <InfoCard title="Roles" hasSpacing={false}>
{Object.entries(roles).map(([scope, roles]) => (
<div className="w-full">
<h4 className="bg-black/40 p-3 text-xs opacity-50">
{scope.toUpperCase()}
</h4>
<ul className="bg-black/20">
{roles.map((r, index) => (
<div key={r.id}>
<li
key={r.id}
className="p-2 px-4 flex flex-row items-center gap-2"
>
<span
className={`opacity-${userRoles.some((ur) => ur.id === r.id) ? "100" : "0"}`}
>
<Check size={16} />
</span>
{r.name.toUpperCase()}
</li>
{index + 1 < roles.length && (
<div className="h-[1px] bg-gray-800 w-[95%] mx-auto"></div>
)}
</div>
))}
</ul>
</div>
))}
</InfoCard> */}
{/* 🚀 Actions */}
<div className="flex flex-wrap gap-4 mt-6 justify-between items-center">
<div className="flex flex-wrap gap-4 mt-6 justify-between items-center col-span-3">
<Link to="/admin/users">
<Button variant="outlined">Back</Button>
</Link>

View File

@ -1,4 +1,8 @@
import { getRolesApi } from "@/api/admin/roles";
import {
assignUserRoleApi,
getRolesApi,
type AssignUserRoleRequest,
} from "@/api/admin/roles";
import type { AppPermission, AppRole } from "@/types";
import { create } from "zustand";
@ -11,13 +15,16 @@ export interface IRolesGroups {
roles: RolesMap;
fetching: boolean;
toggling: string | null;
fetch: () => Promise<void>;
assign: (userId: string, role: AppRole) => Promise<void>;
}
export const useRoles = create<IRolesGroups>((set) => ({
roles: {},
fetching: false,
toggling: null,
fetch: async () => {
set({ fetching: true });
@ -33,4 +40,18 @@ export const useRoles = create<IRolesGroups>((set) => ({
set({ fetching: false });
}
},
assign: async (userId, role) => {
set({ toggling: role.id });
try {
await assignUserRoleApi(userId, {
role_key: `${role.scope}_${role.name}`,
});
} catch (err) {
console.log("ERR: Failed to assign user role:", err);
} finally {
set({ toggling: null });
}
},
}));

View File

@ -1,11 +1,12 @@
import { getUserPermissionsApi } from "@/api/admin/permissions";
import { assignUserRoleApi, getUserRolesApi } from "@/api/admin/roles";
import {
adminGetUserApi,
adminGetUsersApi,
postUser,
type CreateUserRequest,
} from "@/api/admin/users";
import type { AppPermission, UserProfile } from "@/types";
import type { AppPermission, AppRole, UserProfile } from "@/types";
import { create } from "zustand";
export interface IUsersState {
@ -18,12 +19,20 @@ export interface IUsersState {
userPermissions: AppPermission[];
fetchingPermissions: boolean;
userRoles: AppRole[];
fetchingRoles: boolean;
creating: boolean;
createUser: (req: CreateUserRequest) => Promise<boolean>;
assigningRole: boolean;
fetchUsers: () => Promise<void>;
fetchUser: (id: string) => Promise<void>;
fetchUserPermissions: () => Promise<void>;
fetchUserRoles: () => Promise<void>;
assignUserRole: (roleKey: string) => Promise<void>;
}
export const useUsers = create<IUsersState>((set, get) => ({
@ -35,9 +44,14 @@ export const useUsers = create<IUsersState>((set, get) => ({
current: null,
fetchingCurrent: false,
userRoles: [],
fetchingRoles: false,
userPermissions: [],
fetchingPermissions: false,
assigningRole: false,
createUser: async (req: CreateUserRequest) => {
set({ creating: true });
@ -97,4 +111,41 @@ export const useUsers = create<IUsersState>((set, get) => ({
set({ fetchingPermissions: false });
}
},
fetchUserRoles: async () => {
const user = get().current;
if (!user) {
console.warn("Trying to fetch user permissions without selected user");
return;
}
set({ fetchingRoles: true });
try {
const response = await getUserRolesApi(user.id);
set({ userRoles: response ?? [] });
} catch (err) {
console.log("ERR: Failed to fetch single user for admin:", err);
} finally {
set({ fetchingRoles: false });
}
},
assignUserRole: async (roleKey: string) => {
const user = get().current;
if (!user) {
console.warn("Trying to fetch user permissions without selected user");
return;
}
set({ assigningRole: true });
try {
await assignUserRoleApi(user.id, { roleKey });
} catch (err) {
console.log("ERR: Failed to assign user role:", err);
} finally {
set({ assigningRole: false });
}
},
}));