From d35e5813b551e009a73c7a821683a9d45b6d07ec Mon Sep 17 00:00:00 2001 From: LandaMm Date: Sun, 20 Jul 2025 17:59:54 +0200 Subject: [PATCH] feat: beta version of role management for single user --- docker-compose.yml | 4 + internal/admin/roles.go | 95 +++++++ internal/admin/routes.go | 2 + internal/repository/models.go | 21 -- internal/repository/permissions.sql.go | 27 +- internal/repository/roles.sql.go | 89 +++++++ .../00013_add_group_role_permission.sql | 36 --- queries/permissions.sql | 27 +- queries/roles.sql | 28 +++ web/src/api/admin/roles.ts | 31 +++ web/src/feature/FoldableRolesTable/index.tsx | 115 +++++++++ web/src/feature/RoleMatrix/index.tsx | 67 +++++ web/src/index.css | 5 + web/src/pages/Admin/Users/View/index.tsx | 234 ++++++++++++------ web/src/store/admin/rolesGroups.ts | 23 +- web/src/store/admin/users.ts | 53 +++- 16 files changed, 680 insertions(+), 177 deletions(-) create mode 100644 web/src/feature/FoldableRolesTable/index.tsx create mode 100644 web/src/feature/RoleMatrix/index.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 0e15e8c..882299e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: environment: POSTGRES_USER: guard POSTGRES_PASSWORD: guard + volumes: + - postgres-data:/var/lib/postgresql/data ports: - "5432:5432" @@ -23,3 +25,5 @@ services: volumes: redis-data: driver: local + postgres-data: + driver: local diff --git a/internal/admin/roles.go b/internal/admin/roles.go index 6db3f1f..6b9cb90 100644 --- a/internal/admin/roles.go +++ b/internal/admin/roles.go @@ -7,6 +7,8 @@ import ( "gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/web" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" ) func (h *AdminHandler) GetAllRoles(w http.ResponseWriter, r *http.Request) { @@ -59,3 +61,96 @@ func (h *AdminHandler) GetAllRoles(w http.ResponseWriter, r *http.Request) { web.Error(w, "failed to encode response", http.StatusInternalServerError) } } + +func (h *AdminHandler) GetUserRoles(w http.ResponseWriter, r *http.Request) { + userId := chi.URLParam(r, "user_id") + + parsed, err := uuid.Parse(userId) + if err != nil { + log.Printf("ERR: Received invalid UUID on get user roles '%s': %v\n", userId, err) + web.Error(w, "invalid user id", http.StatusBadRequest) + return + } + + rows, err := h.repo.GetUserRoles(r.Context(), parsed) + if err != nil { + log.Println("ERR: Failed to list roles from db:", err) + web.Error(w, "failed to get user roles", http.StatusInternalServerError) + return + } + + if len(rows) == 0 { + rows = make([]repository.Role, 0) + } + + encoder := json.NewEncoder(w) + + w.Header().Set("Content-Type", "application/json") + + if err := encoder.Encode(rows); err != nil { + web.Error(w, "failed to encode response", http.StatusInternalServerError) + } +} + +type AssignRoleRequest struct { + RoleKey string `json:"role_key"` +} + +func (h *AdminHandler) AssignUserRole(w http.ResponseWriter, r *http.Request) { + userId := chi.URLParam(r, "user_id") + + var req AssignRoleRequest + + decoder := json.NewDecoder(r.Body) + + if err := decoder.Decode(&req); err != nil { + web.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + if req.RoleKey == "" { + web.Error(w, "role key is required for assign", http.StatusBadRequest) + return + } + + parsed, err := uuid.Parse(userId) + if err != nil { + log.Printf("ERR: Failed to parse provided user ID '%s': %v\n", userId, err) + web.Error(w, "invalid user id provided", http.StatusBadRequest) + return + } + + user, err := h.repo.FindUserId(r.Context(), parsed) + if err != nil { + web.Error(w, "no user found under provided id", http.StatusBadRequest) + return + } + + if _, err := h.repo.FindUserRole(r.Context(), repository.FindUserRoleParams{ + UserID: user.ID, + Key: req.RoleKey, + }); err == nil { + log.Printf("INFO: Unassigning role '%s' for user with '%s' id", req.RoleKey, user.ID.String()) + // Unassign Role + if err := h.repo.UnassignUserRole(r.Context(), repository.UnassignUserRoleParams{ + UserID: user.ID, + Key: req.RoleKey, + }); err != nil { + log.Printf("ERR: Failed to unassign role '%s' from user with '%s' id: %v\n", req.RoleKey, user.ID.String(), err) + web.Error(w, "failed to unassign role to user", http.StatusInternalServerError) + return + } + } else { + log.Printf("INFO: Assigning role '%s' for user with '%s' id", req.RoleKey, user.ID.String()) + if err := h.repo.AssignUserRole(r.Context(), repository.AssignUserRoleParams{ + UserID: user.ID, + Key: req.RoleKey, + }); err != nil { + log.Printf("ERR: Failed to assign role '%s' to user with '%s' id: %v\n", req.RoleKey, user.ID.String(), err) + web.Error(w, "failed to assign role to user", http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusOK) +} diff --git a/internal/admin/routes.go b/internal/admin/routes.go index 7fd71c3..f6a5f55 100644 --- a/internal/admin/routes.go +++ b/internal/admin/routes.go @@ -46,6 +46,8 @@ func (h *AdminHandler) RegisterRoutes(router chi.Router) { r.Get("/permissions/{user_id}", h.GetUserPermissions) r.Get("/roles", h.GetAllRoles) + r.Get("/roles/{user_id}", h.GetUserRoles) + r.Patch("/roles/{user_id}", h.AssignUserRole) }) router.Get("/api-services/client/{client_id}", h.GetApiServiceCID) diff --git a/internal/repository/models.go b/internal/repository/models.go index f8b4e90..364d63a 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -25,22 +25,6 @@ type ApiService struct { IconUrl *string `json:"icon_url"` } -type Group struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Description *string `json:"description"` -} - -type GroupPermission struct { - GroupID uuid.UUID `json:"group_id"` - PermissionID uuid.UUID `json:"permission_id"` -} - -type GroupRole struct { - GroupID uuid.UUID `json:"group_id"` - RoleID uuid.UUID `json:"role_id"` -} - type Permission struct { ID uuid.UUID `json:"id"` Name string `json:"name"` @@ -95,11 +79,6 @@ type User struct { Verified bool `json:"verified"` } -type UserGroup struct { - UserID uuid.UUID `json:"user_id"` - GroupID uuid.UUID `json:"group_id"` -} - type UserPermission struct { UserID uuid.UUID `json:"user_id"` PermissionID uuid.UUID `json:"permission_id"` diff --git a/internal/repository/permissions.sql.go b/internal/repository/permissions.sql.go index 6e1352c..96099c0 100644 --- a/internal/repository/permissions.sql.go +++ b/internal/repository/permissions.sql.go @@ -123,32 +123,13 @@ func (q *Queries) GetGroupedPermissions(ctx context.Context) ([]GetGroupedPermis const getUserPermissions = `-- name: GetUserPermissions :many SELECT DISTINCT p.id,p.name,p.scope,p.description -FROM permissions p -LEFT JOIN role_permissions rp_user - ON p.id = rp_user.permission_id -LEFT JOIN user_roles ur - ON rp_user.role_id = ur.role_id AND ur.user_id = $1 -LEFT JOIN user_groups ug - ON ug.user_id = $1 -LEFT JOIN group_roles gr - ON ug.group_id = gr.group_id -LEFT JOIN role_permissions rp_group - ON gr.role_id = rp_group.role_id AND rp_group.permission_id = p.id -LEFT JOIN user_permissions up - ON up.user_id = $1 AND up.permission_id = p.id -LEFT JOIN group_permissions gp - ON gp.group_id = ug.group_id AND gp.permission_id = p.id -WHERE ur.user_id IS NOT NULL - OR gr.group_id IS NOT NULL - OR up.user_id IS NOT NULL - OR gp.group_id IS NOT NULL +FROM user_roles ur +JOIN role_permissions rp ON ur.role_id = rp.role_id +JOIN permissions p ON rp.permission_id = p.id +WHERE ur.user_id = $1 ORDER BY p.scope ` -// From roles assigned directly to the user -// From roles assigned to user's groups -// Direct permissions to user -// Direct permissions to user's groups func (q *Queries) GetUserPermissions(ctx context.Context, userID uuid.UUID) ([]Permission, error) { rows, err := q.db.Query(ctx, getUserPermissions, userID) if err != nil { diff --git a/internal/repository/roles.sql.go b/internal/repository/roles.sql.go index 8c7b007..fa24d56 100644 --- a/internal/repository/roles.sql.go +++ b/internal/repository/roles.sql.go @@ -56,6 +56,25 @@ func (q *Queries) AssignRolePermission(ctx context.Context, arg AssignRolePermis return err } +const assignUserRole = `-- name: AssignUserRole :exec +INSERT INTO user_roles (user_id, role_id) +VALUES ($1, ( + SELECT id FROM roles r + WHERE r.scope = split_part($2, '_', 1) + AND r.name = right($2, length($2) - position('_' IN $2)) +)) +` + +type AssignUserRoleParams struct { + UserID uuid.UUID `json:"user_id"` + Key string `json:"key"` +} + +func (q *Queries) AssignUserRole(ctx context.Context, arg AssignUserRoleParams) error { + _, err := q.db.Exec(ctx, assignUserRole, arg.UserID, arg.Key) + return err +} + const createRole = `-- name: CreateRole :one INSERT INTO roles (name, scope, description) VALUES ($1, $2, $3) @@ -103,6 +122,24 @@ func (q *Queries) FindRole(ctx context.Context, arg FindRoleParams) (Role, error return i, err } +const findUserRole = `-- name: FindUserRole :one +SELECT user_id, role_id FROM user_roles +WHERE user_id = $1 AND role_id = (SELECT id FROM roles r WHERE r.scope = split_part($2, '_', 1) AND r.name = right($2, length($2) - position('_' IN $2))) +LIMIT 1 +` + +type FindUserRoleParams struct { + UserID uuid.UUID `json:"user_id"` + Key string `json:"key"` +} + +func (q *Queries) FindUserRole(ctx context.Context, arg FindUserRoleParams) (UserRole, error) { + row := q.db.QueryRow(ctx, findUserRole, arg.UserID, arg.Key) + var i UserRole + err := row.Scan(&i.UserID, &i.RoleID) + return i, err +} + const getRoleAssignment = `-- name: GetRoleAssignment :one SELECT role_id, permission_id FROM role_permissions WHERE role_id = $1 AND permission_id = (SELECT id FROM permissions p WHERE p.scope = split_part($2, '_', 1) AND p.name = right($2, length($2) - position('_' IN $2))) @@ -130,6 +167,8 @@ SELECT r.id, 'name', r.name, + 'scope', + r.scope, 'description', r.description, 'permissions', @@ -191,3 +230,53 @@ func (q *Queries) GetRolesGroupedWithPermissions(ctx context.Context) ([]GetRole } return items, nil } + +const getUserRoles = `-- name: GetUserRoles :many +SELECT r.id, r.name, r.scope, r.description FROM roles r +JOIN user_roles ur ON r.id = ur.role_id +WHERE ur.user_id = $1 +` + +func (q *Queries) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]Role, error) { + rows, err := q.db.Query(ctx, getUserRoles, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Role + for rows.Next() { + var i Role + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Scope, + &i.Description, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const unassignUserRole = `-- name: UnassignUserRole :exec +DELETE FROM user_roles +WHERE user_id = $1 AND role_id = ( + SELECT id FROM roles r + WHERE r.scope = split_part($2, '_', 1) + AND r.name = right($2, length($2) - position('_' IN $2)) +) +` + +type UnassignUserRoleParams struct { + UserID uuid.UUID `json:"user_id"` + Key string `json:"key"` +} + +func (q *Queries) UnassignUserRole(ctx context.Context, arg UnassignUserRoleParams) error { + _, err := q.db.Exec(ctx, unassignUserRole, arg.UserID, arg.Key) + return err +} diff --git a/migrations/00013_add_group_role_permission.sql b/migrations/00013_add_group_role_permission.sql index 704e678..34e85b6 100644 --- a/migrations/00013_add_group_role_permission.sql +++ b/migrations/00013_add_group_role_permission.sql @@ -1,12 +1,5 @@ -- +goose Up -- +goose StatementBegin --- GROUPS -CREATE TABLE groups ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid (), - name TEXT NOT NULL UNIQUE, - description TEXT -); - -- ROLES CREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid (), @@ -25,20 +18,6 @@ CREATE TABLE permissions ( UNIQUE (name, scope) ); --- USER-GROUPS (many-to-many) -CREATE TABLE user_groups ( - user_id UUID REFERENCES users (id) ON DELETE CASCADE, - group_id UUID REFERENCES groups (id) ON DELETE CASCADE, - PRIMARY KEY (user_id, group_id) -); - --- GROUP-ROLES (many-to-many) -CREATE TABLE group_roles ( - group_id UUID REFERENCES groups (id) ON DELETE CASCADE, - role_id UUID REFERENCES roles (id) ON DELETE CASCADE, - PRIMARY KEY (group_id, role_id) -); - -- ROLE-PERMISSIONS (many-to-many) CREATE TABLE role_permissions ( role_id UUID REFERENCES roles (id) ON DELETE CASCADE, @@ -60,32 +39,17 @@ CREATE TABLE user_permissions ( PRIMARY KEY (user_id, permission_id) ); --- GROUP-PERMISSIONS (direct on group, optional) -CREATE TABLE group_permissions ( - group_id UUID REFERENCES groups (id) ON DELETE CASCADE, - permission_id UUID REFERENCES permissions (id) ON DELETE CASCADE, - PRIMARY KEY (group_id, permission_id) -); - -- +goose StatementEnd -- +goose Down -- +goose StatementBegin -DROP TABLE IF EXISTS group_permissions; - DROP TABLE IF EXISTS user_permissions; DROP TABLE IF EXISTS user_roles; DROP TABLE IF EXISTS role_permissions; -DROP TABLE IF EXISTS group_roles; - -DROP TABLE IF EXISTS user_groups; - DROP TABLE IF EXISTS permissions; DROP TABLE IF EXISTS roles; -DROP TABLE IF EXISTS groups; - -- +goose StatementEnd diff --git a/queries/permissions.sql b/queries/permissions.sql index 8b88fcb..8ff1f22 100644 --- a/queries/permissions.sql +++ b/queries/permissions.sql @@ -22,27 +22,8 @@ WHERE name = $1 AND scope = $2; -- name: GetUserPermissions :many SELECT DISTINCT p.id,p.name,p.scope,p.description -FROM permissions p --- From roles assigned directly to the user -LEFT JOIN role_permissions rp_user - ON p.id = rp_user.permission_id -LEFT JOIN user_roles ur - ON rp_user.role_id = ur.role_id AND ur.user_id = $1 --- From roles assigned to user's groups -LEFT JOIN user_groups ug - ON ug.user_id = $1 -LEFT JOIN group_roles gr - ON ug.group_id = gr.group_id -LEFT JOIN role_permissions rp_group - ON gr.role_id = rp_group.role_id AND rp_group.permission_id = p.id --- Direct permissions to user -LEFT JOIN user_permissions up - ON up.user_id = $1 AND up.permission_id = p.id --- Direct permissions to user's groups -LEFT JOIN group_permissions gp - ON gp.group_id = ug.group_id AND gp.permission_id = p.id -WHERE ur.user_id IS NOT NULL - OR gr.group_id IS NOT NULL - OR up.user_id IS NOT NULL - OR gp.group_id IS NOT NULL +FROM user_roles ur +JOIN role_permissions rp ON ur.role_id = rp.role_id +JOIN permissions p ON rp.permission_id = p.id +WHERE ur.user_id = $1 ORDER BY p.scope; diff --git a/queries/roles.sql b/queries/roles.sql index 016a034..87b1fd4 100644 --- a/queries/roles.sql +++ b/queries/roles.sql @@ -12,6 +12,8 @@ SELECT r.id, 'name', r.name, + 'scope', + r.scope, 'description', r.description, 'permissions', @@ -79,3 +81,29 @@ FROM JOIN unnest(sqlc.arg(permission_keys)::text[]) AS key_str ON key_str = p.scope || '_' || p.name; + +-- name: GetUserRoles :many +SELECT r.* FROM roles r +JOIN user_roles ur ON r.id = ur.role_id +WHERE ur.user_id = $1; + +-- name: AssignUserRole :exec +INSERT INTO user_roles (user_id, role_id) +VALUES ($1, ( + SELECT id FROM roles r + WHERE r.scope = split_part(sqlc.arg('key'), '_', 1) + AND r.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key'))) +)); + +-- name: UnassignUserRole :exec +DELETE FROM user_roles +WHERE user_id = $1 AND role_id = ( + SELECT id FROM roles r + WHERE r.scope = split_part(sqlc.arg('key'), '_', 1) + AND r.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key'))) +); + +-- name: FindUserRole :one +SELECT * FROM user_roles +WHERE user_id = $1 AND role_id = (SELECT id FROM roles r WHERE r.scope = split_part(sqlc.arg('key'), '_', 1) AND r.name = right(sqlc.arg('key'), length(sqlc.arg('key')) - position('_' IN sqlc.arg('key')))) +LIMIT 1; diff --git a/web/src/api/admin/roles.ts b/web/src/api/admin/roles.ts index 1a8def8..e3231a7 100644 --- a/web/src/api/admin/roles.ts +++ b/web/src/api/admin/roles.ts @@ -18,3 +18,34 @@ export const getRolesApi = async (): Promise => { return response.data; }; + +export type FetchUserRolesResponse = AppRole[]; + +export const getUserRolesApi = async ( + userId: string, +): Promise => { + const response = await axios.get( + `/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; +}; diff --git a/web/src/feature/FoldableRolesTable/index.tsx b/web/src/feature/FoldableRolesTable/index.tsx new file mode 100644 index 0000000..1a2c96d --- /dev/null +++ b/web/src/feature/FoldableRolesTable/index.tsx @@ -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 = ({ userRoles, onToggleRole }) => { + const [openScopes, setOpenScopes] = useState>({}); + + 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 ( +
+
+ {Object.entries(roleMap).map(([scope, roles]) => ( +
+
toggleScope(scope)} + > +
+ + {scope.toUpperCase()}{" "} + + ( + { + roles.filter((r) => + userRoles.some((ur) => ur.id === r.id), + ).length + } + /{roles.length}) + + +
+ + + +
+ +
    + {roles.length === 0 ? ( +
  • No roles found
  • + ) : ( + roles.map((role) => ( +
  • toggleUserRole(role)} + > + 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 && ( + + )} + {role.name.toUpperCase()} + +
  • + )) + )} +
+
+ ))} +
+
+ ); +}; + +export default FoldableRolesTable; diff --git a/web/src/feature/RoleMatrix/index.tsx b/web/src/feature/RoleMatrix/index.tsx new file mode 100644 index 0000000..e22a189 --- /dev/null +++ b/web/src/feature/RoleMatrix/index.tsx @@ -0,0 +1,67 @@ +import React from "react"; + +type RoleMatrixProps = { + scopes: string[]; + roles: string[]; + userRoles: Record; // e.g. { "Project Alpha": ["admin", "viewer"] } + onToggle?: (scope: string, role: string, isAssigned: boolean) => void; +}; + +const RoleMatrix: React.FC = ({ + scopes, + roles, + userRoles, + onToggle, +}) => { + const isChecked = (scope: string, role: string) => + userRoles[scope]?.includes(role) ?? false; + + return ( +
+ + + + + {roles.map((role) => ( + + ))} + + + + {scopes.map((scope) => ( + + + {roles.map((role) => { + const checked = isChecked(scope, role); + return ( + + ); + })} + + ))} + +
+ Scope / Role + + {role} +
+ {scope} + + onToggle?.(scope, role, !checked)} + /> +
+
+ ); +}; + +export default RoleMatrix; diff --git a/web/src/index.css b/web/src/index.css index 3c5849d..a687c74 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -9,6 +9,11 @@ -ms-overflow-style: none; scrollbar-width: none; } + .transition-height { + transition-property: height; + transition-duration: 300ms; + transition-timing-function: ease; + } } html, diff --git a/web/src/pages/Admin/Users/View/index.tsx b/web/src/pages/Admin/Users/View/index.tsx index 71ce301..81183c2 100644 --- a/web/src/pages/Admin/Users/View/index.tsx +++ b/web/src/pages/Admin/Users/View/index.tsx @@ -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 { title: string; children: React.ReactNode; -}) => ( -
-
-

- {title} -

+ hasSpacing?: boolean | undefined; +} + +const InfoCard = ({ + children, + hasSpacing = true, + title, + ...props +}: InfoCardProps) => ( +
+

{title}

+
+ {/*
+

+ {title} +

+
*/} +
{children}
-
{children}
); 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 (
@@ -56,74 +82,138 @@ const AdminViewUserPage: FC = () => { />
- {/* 📋 Main Details */} - +
-
- - Avatar: - - +
+ {/* Header */} +
+ +
+

{user.full_name}

+
+

+ + + + {user.email} +

+
+

+ + + + {user.phone_number || "No Phone Number"} +

+
+
+
+ {/* TODO: */} + {/* Top Bars */} + {/*
+ somethign +
*/}
-
- - Full Name: - {" "} - {user.full_name} -
-
- - Email: - {" "} - {user.email} -
-
- - Phone Number: - {" "} - {user.phone_number || "-"}{" "} -
-
- - Is Admin: - {" "} - - {user.is_admin ? "Yes" : "No"} - -
-
- - Created At: - {" "} - {new Date(user.created_at).toLocaleString()} -
-
- - Last Login At: - {" "} - {user.last_login - ? new Date(user.last_login).toLocaleString() - : "never"} +
+
+ + Verification + {" "} +
+ {[ + ["avatar_verified", "Avatar"], + ["email_verified", "Email"], + ].map(([key, label]) => ( + + {label} + + ))} +
+
+
+ + Roles + {" "} + + {userRoles.length === 0 &&

No Roles

} + {userRoles.map((role) => ( + + {role.name} + + ))} +
+
+
+ + Created At + {" "} + + {moment(user.created_at).format("LLLL")} + +
+
+ + Last Login + {" "} + + {user.last_login + ? moment(user.last_login).format("LLLL") + : "never"} + +
- -
{JSON.stringify(userPermissions, null, 2)}
+ + + {/* + {Object.entries(roles).map(([scope, roles]) => ( +
+

+ {scope.toUpperCase()} +

+
    + {roles.map((r, index) => ( +
    +
  • + ur.id === r.id) ? "100" : "0"}`} + > + + + {r.name.toUpperCase()} +
  • + {index + 1 < roles.length && ( +
    + )} +
    + ))} +
+
+ ))} +
*/} + {/* 🚀 Actions */} -
+
diff --git a/web/src/store/admin/rolesGroups.ts b/web/src/store/admin/rolesGroups.ts index 760aa8f..1929007 100644 --- a/web/src/store/admin/rolesGroups.ts +++ b/web/src/store/admin/rolesGroups.ts @@ -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; + assign: (userId: string, role: AppRole) => Promise; } export const useRoles = create((set) => ({ roles: {}, fetching: false, + toggling: null, fetch: async () => { set({ fetching: true }); @@ -33,4 +40,18 @@ export const useRoles = create((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 }); + } + }, })); diff --git a/web/src/store/admin/users.ts b/web/src/store/admin/users.ts index ac909b8..11f7ef7 100644 --- a/web/src/store/admin/users.ts +++ b/web/src/store/admin/users.ts @@ -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; + assigningRole: boolean; + fetchUsers: () => Promise; fetchUser: (id: string) => Promise; fetchUserPermissions: () => Promise; + fetchUserRoles: () => Promise; + + assignUserRole: (roleKey: string) => Promise; } export const useUsers = create((set, get) => ({ @@ -35,9 +44,14 @@ export const useUsers = create((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((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 }); + } + }, }));