diff --git a/backend/go.mod b/backend/go.mod
index 1c479c9..4fa5890 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -5,7 +5,7 @@ go 1.22.2
require (
entgo.io/ent v0.12.5
github.com/99designs/gqlgen v0.17.44
- github.com/cble-platform/cble-provider-grpc v0.2.1
+ github.com/cble-platform/cble-provider-grpc v0.2.2
github.com/docker/docker v25.0.3+incompatible
github.com/fatih/color v1.16.0
github.com/gin-contrib/cors v1.4.0
diff --git a/backend/go.sum b/backend/go.sum
index f2df96a..5c4f290 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -43,8 +43,8 @@ github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
-github.com/cble-platform/cble-provider-grpc v0.2.1 h1:ZxL+YPhD+B8jRe4YnIaOC6lhjpWS0iAM/h+tg/Ahfy4=
-github.com/cble-platform/cble-provider-grpc v0.2.1/go.mod h1:udlx6gdnTEX2dDU1PvPEOwDnAJKUogbtHqRe3Fg3RZI=
+github.com/cble-platform/cble-provider-grpc v0.2.2 h1:Xvl4cnu9vdzx6zbbZ1jJkb72Wtu/9cDp1nu9OGa4X7c=
+github.com/cble-platform/cble-provider-grpc v0.2.2/go.mod h1:udlx6gdnTEX2dDU1PvPEOwDnAJKUogbtHqRe3Fg3RZI=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
diff --git a/backend/graph/generated/generated.go b/backend/graph/generated/generated.go
index dc84d61..421bc92 100644
--- a/backend/graph/generated/generated.go
+++ b/backend/graph/generated/generated.go
@@ -2505,6 +2505,7 @@ input ProviderInput {
input UserInput {
username: String!
+ password: String
email: String!
firstName: String!
lastName: String!
@@ -15489,7 +15490,7 @@ func (ec *executionContext) unmarshalInputUserInput(ctx context.Context, obj int
asMap[k] = v
}
- fieldsInOrder := [...]string{"username", "email", "firstName", "lastName"}
+ fieldsInOrder := [...]string{"username", "password", "email", "firstName", "lastName"}
for _, k := range fieldsInOrder {
v, ok := asMap[k]
if !ok {
@@ -15503,6 +15504,13 @@ func (ec *executionContext) unmarshalInputUserInput(ctx context.Context, obj int
return it, err
}
it.Username = data
+ case "password":
+ ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("password"))
+ data, err := ec.unmarshalOString2áš–string(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ it.Password = data
case "email":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email"))
data, err := ec.unmarshalNString2string(ctx, v)
diff --git a/backend/graph/model/models_gen.go b/backend/graph/model/models_gen.go
index 38f0b58..c68fa39 100644
--- a/backend/graph/model/models_gen.go
+++ b/backend/graph/model/models_gen.go
@@ -93,10 +93,11 @@ type ProviderPage struct {
}
type UserInput struct {
- Username string `json:"username"`
- Email string `json:"email"`
- FirstName string `json:"firstName"`
- LastName string `json:"lastName"`
+ Username string `json:"username"`
+ Password *string `json:"password,omitempty"`
+ Email string `json:"email"`
+ FirstName string `json:"firstName"`
+ LastName string `json:"lastName"`
}
type UserPage struct {
diff --git a/backend/graph/schema.graphqls b/backend/graph/schema.graphqls
index 245ae1a..365ee12 100644
--- a/backend/graph/schema.graphqls
+++ b/backend/graph/schema.graphqls
@@ -422,6 +422,7 @@ input ProviderInput {
input UserInput {
username: String!
+ password: String
email: String!
firstName: String!
lastName: String!
diff --git a/backend/graph/schema.resolvers.go b/backend/graph/schema.resolvers.go
index dcb82b2..d86feb2 100644
--- a/backend/graph/schema.resolvers.go
+++ b/backend/graph/schema.resolvers.go
@@ -33,6 +33,7 @@ import (
"github.com/cble-platform/cble/backend/permission/actions"
"github.com/google/uuid"
"github.com/vektah/gqlparser/v2/gqlerror"
+ "golang.org/x/crypto/bcrypt"
yaml "gopkg.in/yaml.v3"
)
@@ -167,12 +168,24 @@ func (r *mutationResolver) CreateUser(ctx context.Context, input model.UserInput
return nil, auth.PERMISSION_DENIED_GQL_ERROR
}
+ // Make sure password is included
+ if input.Password == nil {
+ return nil, gqlerror.Errorf("must provide password")
+ }
+
+ // Hash the password
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*input.Password), 8)
+ if err != nil {
+ return nil, gqlerror.Errorf("failed to hash user password: %v", err)
+ }
+
// Create the user
entUser, err := r.ent.User.Create().
SetEmail(input.Email).
SetFirstName(input.FirstName).
SetLastName(input.LastName).
SetUsername(input.Username).
+ SetPassword(string(hashedPassword)).
Save(ctx)
if err != nil {
return nil, gqlerror.Errorf("failed to create user: %v", err)
diff --git a/frontend/src/components/navbar.tsx b/frontend/src/components/navbar.tsx
index 5b4af88..f321ca6 100644
--- a/frontend/src/components/navbar.tsx
+++ b/frontend/src/components/navbar.tsx
@@ -132,6 +132,19 @@ export default function Navbar({
Permissions
)}
+ {navPermissions?.listUsers && (
+
+ )}
;
username: Scalars['String']['input'];
};
@@ -880,7 +881,7 @@ export type ListPermissionsQuery = { __typename?: 'Query', permissions: { __type
export type NavPermissionsQueryVariables = Exact<{ [key: string]: never; }>;
-export type NavPermissionsQuery = { __typename?: 'Query', listProviders: boolean, listPermissions: boolean };
+export type NavPermissionsQuery = { __typename?: 'Query', listProviders: boolean, listPermissions: boolean, listUsers: boolean };
export type GrantPermissionMutationVariables = Exact<{
subjectType: SubjectType;
@@ -1018,7 +1019,10 @@ export type MeQueryVariables = Exact<{ [key: string]: never; }>;
export type MeQuery = { __typename?: 'Query', me: { __typename?: 'User', id: string, createdAt: any, updatedAt: any, username: string, email: string, firstName: string, lastName: string } };
-export type ListUsersQueryVariables = Exact<{ [key: string]: never; }>;
+export type ListUsersQueryVariables = Exact<{
+ count?: Scalars['Int']['input'];
+ offset?: InputMaybe;
+}>;
export type ListUsersQuery = { __typename?: 'Query', users: { __typename?: 'UserPage', total: number, users: Array<{ __typename?: 'User', id: string, createdAt: any, updatedAt: any, username: string, email: string, firstName: string, lastName: string }> } };
@@ -1762,6 +1766,7 @@ export const NavPermissionsDocument = gql`
objectID: null
action: permission_list
)
+ listUsers: meHasPermission(objectType: user, objectID: null, action: user_list)
}
`;
@@ -2431,8 +2436,8 @@ export type MeLazyQueryHookResult = ReturnType;
export type MeSuspenseQueryHookResult = ReturnType;
export type MeQueryResult = Apollo.QueryResult;
export const ListUsersDocument = gql`
- query ListUsers {
- users {
+ query ListUsers($count: Int! = 10, $offset: Int) {
+ users(count: $count, offset: $offset) {
users {
...UserFragment
}
@@ -2453,6 +2458,8 @@ export const ListUsersDocument = gql`
* @example
* const { data, loading, error } = useListUsersQuery({
* variables: {
+ * count: // value for 'count'
+ * offset: // value for 'offset'
* },
* });
*/
diff --git a/frontend/src/lib/api/graphql/permission.gql b/frontend/src/lib/api/graphql/permission.gql
index b3b8421..3aab51f 100644
--- a/frontend/src/lib/api/graphql/permission.gql
+++ b/frontend/src/lib/api/graphql/permission.gql
@@ -30,6 +30,11 @@ query NavPermissions {
objectID: null
action: permission_list
)
+ listUsers: meHasPermission(
+ objectType: user
+ objectID: null
+ action: user_list
+ )
}
mutation GrantPermission(
diff --git a/frontend/src/lib/api/graphql/user.gql b/frontend/src/lib/api/graphql/user.gql
index b74efd9..49ab0f9 100644
--- a/frontend/src/lib/api/graphql/user.gql
+++ b/frontend/src/lib/api/graphql/user.gql
@@ -14,8 +14,8 @@ query Me {
}
}
-query ListUsers {
- users {
+query ListUsers($count: Int! = 10, $offset: Int) {
+ users(count: $count, offset: $offset) {
users {
...UserFragment
}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index a347fbe..9182c63 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -17,10 +17,11 @@ import { SnackbarProvider } from 'notistack'
import { ReactFlowProvider } from 'reactflow'
import { Box, CircularProgress } from '@mui/material'
import Logo from './components/logo'
+import Users from './routes/users'
// Pages
const Root = lazy(() => import('./routes/root'))
-const ErrorPage = lazy(() => import('./error-page'))
+const ErrorPage = lazy(() => import('./routes/error-page'))
const Login = lazy(() => import('./routes/auth/login'))
const Blueprints = lazy(() => import('./routes/blueprints'))
const RequestBlueprint = lazy(() => import('./routes/blueprints/request'))
@@ -168,6 +169,12 @@ const router = createBrowserRouter([
{ index: true, element: } /> },
],
},
+ {
+ path: 'users',
+ children: [
+ { index: true, element: } /> },
+ ],
+ },
],
errorElement: } />,
},
diff --git a/frontend/src/error-page.tsx b/frontend/src/routes/error-page.tsx
similarity index 74%
rename from frontend/src/error-page.tsx
rename to frontend/src/routes/error-page.tsx
index 17d6499..704ce1e 100644
--- a/frontend/src/error-page.tsx
+++ b/frontend/src/routes/error-page.tsx
@@ -1,7 +1,11 @@
import { Container, Typography } from '@mui/material'
import { useRouteError } from 'react-router-dom'
+import Navbar from '../components/navbar'
+import { ThemeContext } from '../theme'
+import { useContext } from 'react'
export default function ErrorPage() {
+ const { themePreference, setThemePreference } = useContext(ThemeContext)
const error = useRouteError() as {
statusText?: string
error?: Error
@@ -18,6 +22,7 @@ export default function ErrorPage() {
minHeight: '100dvh',
}}
>
+
Oops!
Sorry, an unexpected error has occurred.
diff --git a/frontend/src/routes/users/index.tsx b/frontend/src/routes/users/index.tsx
new file mode 100644
index 0000000..6761f40
--- /dev/null
+++ b/frontend/src/routes/users/index.tsx
@@ -0,0 +1,168 @@
+import {
+ Box,
+ Button,
+ ButtonGroup,
+ Container,
+ Divider,
+ Paper,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableFooter,
+ TableHead,
+ TablePagination,
+ TableRow,
+ Typography,
+} from '@mui/material'
+import { useSnackbar } from 'notistack'
+import {
+ Action,
+ ListProvidersQuery,
+ ObjectType,
+ useListUsersQuery,
+ useMeHasPermissionQuery,
+} from '../../lib/api/generated'
+import { useEffect, useMemo, useState } from 'react'
+import { Add, Delete, Edit } from '@mui/icons-material'
+import { useNavigate } from 'react-router-dom'
+
+export default function Users() {
+ const { enqueueSnackbar } = useSnackbar()
+ const navigate = useNavigate()
+ const { data: createUsersData } = useMeHasPermissionQuery({
+ variables: {
+ objectID: null,
+ objectType: ObjectType.User,
+ action: Action.UserCreate,
+ },
+ })
+ const {
+ data: listUsersData,
+ error: listUsersError,
+ loading: listUsersLoading,
+ refetch: refetchListUsers,
+ } = useListUsersQuery()
+ const [moreMenuEl, setMoreMenuEl] = useState(null)
+ const [moreMenuProvider, setMoreMenuProvider] =
+ useState()
+ const [page, setPage] = useState(0)
+ const [rowsPerPage, setRowsPerPage] = useState(10)
+
+ const emptyRows = useMemo(
+ () =>
+ page > 0
+ ? Math.max(0, rowsPerPage - (listUsersData?.users.users.length ?? 0))
+ : 0,
+ [page, rowsPerPage, listUsersData]
+ )
+
+ useEffect(() => {
+ refetchListUsers({
+ count: rowsPerPage,
+ offset: rowsPerPage * page,
+ })
+ }, [page, rowsPerPage])
+
+ return (
+
+
+ Users
+ {createUsersData?.meHasPermission && (
+ }
+ >
+ Create
+
+ )}
+
+
+
+
+
+
+
+ Username
+ First Name
+ Last Name
+ Email
+
+
+
+
+ {listUsersData?.users.users.map((row) => (
+
+
+ {row.username}
+
+ {row.firstName}
+ {row.lastName}
+ {row.email}
+
+
+ }
+ color="warning"
+ href={`/users/edit/${row.id}`}
+ >
+ Edit
+
+ }
+ color="error"
+ onClick={() => {
+ // setRevokeModalData(row)
+ // setRevokeModalOpen(true)
+ }}
+ >
+ Delete
+
+
+
+
+ ))}
+ {emptyRows > 0 && (
+
+
+
+ )}
+
+
+
+ {
+ setPage(0)
+ setRowsPerPage(parseInt(e.target.value))
+ }}
+ page={page}
+ onPageChange={(_e, value) => setPage(value)}
+ count={listUsersData?.users.total ?? 0}
+ disabled={listUsersLoading}
+ />
+
+
+
+
+
+ )
+}