diff --git a/backend/internal/api/handler/access_lists.go b/backend/internal/api/handler/access_lists.go new file mode 100644 index 0000000..61c037d --- /dev/null +++ b/backend/internal/api/handler/access_lists.go @@ -0,0 +1,129 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + + c "npm/internal/api/context" + h "npm/internal/api/http" + "npm/internal/api/middleware" + "npm/internal/entity/accesslist" +) + +// GetAccessLists will return a list of Access Lists +// Route: GET /access-lists +func GetAccessLists() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + pageInfo, err := getPageInfoFromRequest(r) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + items, err := accesslist.List(pageInfo, middleware.GetFiltersFromContext(r)) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + } else { + h.ResultResponseJSON(w, r, http.StatusOK, items) + } + } +} + +// GetAccessList will return a single access list +// Route: GET /access-lists/{accessListID} +func GetAccessList() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var err error + var accessListID int + if accessListID, err = getURLParamInt(r, "accessListID"); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + item, err := accesslist.GetByID(accessListID) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + } else { + h.ResultResponseJSON(w, r, http.StatusOK, item) + } + } +} + +// CreateAccessList will create an access list +// Route: POST /access-lists +func CreateAccessList() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) + + var newItem accesslist.Model + err := json.Unmarshal(bodyBytes, &newItem) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil) + return + } + + // Get userID from token + userID, _ := r.Context().Value(c.UserIDCtxKey).(int) + newItem.UserID = userID + + if err = newItem.Save(); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, fmt.Sprintf("Unable to save Access List: %s", err.Error()), nil) + return + } + + h.ResultResponseJSON(w, r, http.StatusOK, newItem) + } +} + +// UpdateAccessList is self explanatory +// Route: PUT /access-lists/{accessListID} +func UpdateAccessList() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var err error + var accessListID int + if accessListID, err = getURLParamInt(r, "accessListID"); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + item, err := accesslist.GetByID(accessListID) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + } else { + bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) + err := json.Unmarshal(bodyBytes, &item) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil) + return + } + + if err = item.Save(); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + h.ResultResponseJSON(w, r, http.StatusOK, item) + } + } +} + +// DeleteAccessList is self explanatory +// Route: DELETE /access-lists/{accessListID} +func DeleteAccessList() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var err error + var accessListID int + if accessListID, err = getURLParamInt(r, "accessListID"); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + item, err := accesslist.GetByID(accessListID) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + } else { + h.ResultResponseJSON(w, r, http.StatusOK, item.Delete()) + } + } +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 494538d..3ea5bfc 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -8,6 +8,7 @@ import ( "npm/internal/api/middleware" "npm/internal/api/schema" "npm/internal/config" + "npm/internal/entity/accesslist" "npm/internal/entity/certificate" "npm/internal/entity/certificateauthority" "npm/internal/entity/dnsprovider" @@ -114,6 +115,18 @@ func applyRoutes(r chi.Router) chi.Router { Put("/{name}", handler.UpdateSetting()) }) + // Access Lists + r.With(middleware.EnforceSetup(true)).Route("/access-lists", func(r chi.Router) { + r.With(middleware.Filters(accesslist.GetFilterSchema()), middleware.Enforce(user.CapabilityAccessListsView)). + Get("/", handler.GetAccessLists()) + r.With(middleware.Enforce(user.CapabilityAccessListsView)).Get("/{accessListID:[0-9]+}", handler.GetAccessList()) + r.With(middleware.Enforce(user.CapabilityAccessListsManage)).Delete("/{accessListID:[0-9]+}", handler.DeleteAccessList()) + r.With(middleware.Enforce(user.CapabilityAccessListsManage)).With(middleware.EnforceRequestSchema(schema.CreateAccessList())). + Post("/", handler.CreateAccessList()) + r.With(middleware.Enforce(user.CapabilityAccessListsManage)).With(middleware.EnforceRequestSchema(schema.UpdateAccessList())). + Put("/{accessListID:[0-9]+}", handler.UpdateAccessList()) + }) + // DNS Providers r.With(middleware.EnforceSetup(true)).Route("/dns-providers", func(r chi.Router) { r.With(middleware.Filters(dnsprovider.GetFilterSchema()), middleware.Enforce(user.CapabilityDNSProvidersView)). @@ -125,7 +138,7 @@ func applyRoutes(r chi.Router) chi.Router { r.With(middleware.Enforce(user.CapabilityDNSProvidersManage)).With(middleware.EnforceRequestSchema(schema.UpdateDNSProvider())). Put("/{providerID:[0-9]+}", handler.UpdateDNSProvider()) - r.With(middleware.EnforceSetup(true), middleware.Enforce(user.CapabilityDNSProvidersView)).Route("/acmesh", func(r chi.Router) { + r.With(middleware.Enforce(user.CapabilityDNSProvidersView)).Route("/acmesh", func(r chi.Router) { r.Get("/{acmeshID:[a-z0-9_]+}", handler.GetAcmeshProvider()) r.Get("/", handler.GetAcmeshProviders()) }) diff --git a/backend/internal/api/schema/create_access_list.go b/backend/internal/api/schema/create_access_list.go new file mode 100644 index 0000000..47ded39 --- /dev/null +++ b/backend/internal/api/schema/create_access_list.go @@ -0,0 +1,21 @@ +package schema + +import ( + "fmt" +) + +// CreateAccessList is the schema for incoming data validation +func CreateAccessList() string { + return fmt.Sprintf(` + { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": %s + } + } + `, stringMinMax(2, 100)) +} diff --git a/backend/internal/api/schema/update_access_list.go b/backend/internal/api/schema/update_access_list.go new file mode 100644 index 0000000..786ac26 --- /dev/null +++ b/backend/internal/api/schema/update_access_list.go @@ -0,0 +1,17 @@ +package schema + +import "fmt" + +// UpdateAccessList is the schema for incoming data validation +func UpdateAccessList() string { + return fmt.Sprintf(` + { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "name": %s + } + } + `, stringMinMax(2, 100)) +} diff --git a/backend/internal/entity/accesslist/filters.go b/backend/internal/entity/accesslist/filters.go new file mode 100644 index 0000000..5c1bd1b --- /dev/null +++ b/backend/internal/entity/accesslist/filters.go @@ -0,0 +1,25 @@ +package accesslist + +import ( + "npm/internal/entity" +) + +var filterMapFunctions = make(map[string]entity.FilterMapFunction) + +// getFilterMapFunctions is a map of functions that should be executed +// during the filtering process, if a field is defined here then the value in +// the filter will be given to the defined function and it will return a new +// value for use in the sql query. +func getFilterMapFunctions() map[string]entity.FilterMapFunction { + // if len(filterMapFunctions) == 0 { + // TODO: See internal/model/file_item.go:620 for an example + // } + + return filterMapFunctions +} + +// GetFilterSchema returns filter schema +func GetFilterSchema() string { + var m Model + return entity.GetFilterSchema(m) +} diff --git a/backend/internal/entity/accesslist/methods.go b/backend/internal/entity/accesslist/methods.go new file mode 100644 index 0000000..c399cf4 --- /dev/null +++ b/backend/internal/entity/accesslist/methods.go @@ -0,0 +1,128 @@ +package accesslist + +import ( + "database/sql" + goerrors "errors" + "fmt" + + "npm/internal/database" + "npm/internal/entity" + "npm/internal/errors" + "npm/internal/logger" + "npm/internal/model" +) + +// GetByID finds a row by ID +func GetByID(id int) (Model, error) { + var m Model + err := m.LoadByID(id) + return m, err +} + +// Create will create a row from this model +func Create(m *Model) (int, error) { + if m.ID != 0 { + return 0, goerrors.New("Cannot create access list when model already has an ID") + } + + m.Touch(true) + + db := database.GetInstance() + // nolint: gosec + result, err := db.NamedExec(`INSERT INTO `+fmt.Sprintf("`%s`", tableName)+` ( + created_on, + modified_on, + user_id, + name, + meta, + is_deleted + ) VALUES ( + :created_on, + :modified_on, + :user_id, + :name, + :meta, + :is_deleted + )`, m) + + if err != nil { + return 0, err + } + + last, lastErr := result.LastInsertId() + if lastErr != nil { + return 0, lastErr + } + + return int(last), nil +} + +// Update will Update a row from this model +func Update(m *Model) error { + if m.ID == 0 { + return goerrors.New("Cannot update access list when model doesn't have an ID") + } + + m.Touch(false) + + db := database.GetInstance() + // nolint: gosec + _, err := db.NamedExec(`UPDATE `+fmt.Sprintf("`%s`", tableName)+` SET + created_on = :created_on, + modified_on = :modified_on, + user_id = :user_id, + name = :name, + meta = :meta, + is_deleted = :is_deleted + WHERE id = :id`, m) + + return err +} + +// List will return a list of access lists +func List(pageInfo model.PageInfo, filters []model.Filter) (ListResponse, error) { + var result ListResponse + var exampleModel Model + + defaultSort := model.Sort{ + Field: "name", + Direction: "ASC", + } + + db := database.GetInstance() + if db == nil { + return result, errors.ErrDatabaseUnavailable + } + + // Get count of items in this search + query, params := entity.ListQueryBuilder(exampleModel, tableName, &pageInfo, defaultSort, filters, getFilterMapFunctions(), true) + countRow := db.QueryRowx(query, params...) + var totalRows int + queryErr := countRow.Scan(&totalRows) + if queryErr != nil && queryErr != sql.ErrNoRows { + logger.Error("ListAccessListsError", queryErr) + logger.Debug("%s -- %+v", query, params) + return result, queryErr + } + + // Get rows + items := make([]Model, 0) + query, params = entity.ListQueryBuilder(exampleModel, tableName, &pageInfo, defaultSort, filters, getFilterMapFunctions(), false) + err := db.Select(&items, query, params...) + if err != nil { + logger.Error("ListAccessListsError", err) + logger.Debug("%s -- %+v", query, params) + return result, err + } + + result = ListResponse{ + Items: items, + Total: totalRows, + Limit: pageInfo.Limit, + Offset: pageInfo.Offset, + Sort: pageInfo.Sort, + Filter: filters, + } + + return result, nil +} diff --git a/backend/internal/entity/accesslist/model.go b/backend/internal/entity/accesslist/model.go new file mode 100644 index 0000000..7dc175e --- /dev/null +++ b/backend/internal/entity/accesslist/model.go @@ -0,0 +1,75 @@ +package accesslist + +import ( + "fmt" + "time" + + "npm/internal/database" + "npm/internal/entity/user" + "npm/internal/types" +) + +const ( + tableName = "access_list" +) + +// Model is the access list model +type Model struct { + ID int `json:"id" db:"id" filter:"id,integer"` + CreatedOn types.DBDate `json:"created_on" db:"created_on" filter:"created_on,integer"` + ModifiedOn types.DBDate `json:"modified_on" db:"modified_on" filter:"modified_on,integer"` + UserID int `json:"user_id" db:"user_id" filter:"user_id,integer"` + Name string `json:"name" db:"name" filter:"name,string"` + Meta types.JSONB `json:"meta" db:"meta"` + IsDeleted bool `json:"is_deleted,omitempty" db:"is_deleted"` + // Expansions + User *user.Model `json:"user,omitempty"` +} + +func (m *Model) getByQuery(query string, params []interface{}) error { + return database.GetByQuery(m, query, params) +} + +// LoadByID will load from an ID +func (m *Model) LoadByID(id int) error { + query := fmt.Sprintf("SELECT * FROM `%s` WHERE id = ? AND is_deleted = ? LIMIT 1", tableName) + params := []interface{}{id, 0} + return m.getByQuery(query, params) +} + +// Touch will update model's timestamp(s) +func (m *Model) Touch(created bool) { + var d types.DBDate + d.Time = time.Now() + if created { + m.CreatedOn = d + } + m.ModifiedOn = d +} + +// Save will save this model to the DB +func (m *Model) Save() error { + var err error + + if m.UserID == 0 { + return fmt.Errorf("User ID must be specified") + } + + if m.ID == 0 { + m.ID, err = Create(m) + } else { + err = Update(m) + } + + return err +} + +// Delete will mark a access list as deleted +func (m *Model) Delete() bool { + m.Touch(false) + m.IsDeleted = true + if err := m.Save(); err != nil { + return false + } + return true +} diff --git a/backend/internal/entity/accesslist/structs.go b/backend/internal/entity/accesslist/structs.go new file mode 100644 index 0000000..b755b74 --- /dev/null +++ b/backend/internal/entity/accesslist/structs.go @@ -0,0 +1,15 @@ +package accesslist + +import ( + "npm/internal/model" +) + +// ListResponse is the JSON response for the list +type ListResponse struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Sort []model.Sort `json:"sort"` + Filter []model.Filter `json:"filter,omitempty"` + Items []Model `json:"items,omitempty"` +} diff --git a/frontend/src/api/npm/getAccessLists.ts b/frontend/src/api/npm/getAccessLists.ts new file mode 100644 index 0000000..7fb8465 --- /dev/null +++ b/frontend/src/api/npm/getAccessLists.ts @@ -0,0 +1,19 @@ +import * as api from "./base"; +import { AccessListsResponse } from "./responseTypes"; + +export async function getAccessLists( + offset = 0, + limit = 10, + sort?: string, + filters?: { [key: string]: string }, + abortController?: AbortController, +): Promise { + const { result } = await api.get( + { + url: "access-lists", + params: { limit, offset, sort, ...filters }, + }, + abortController, + ); + return result; +} diff --git a/frontend/src/api/npm/index.ts b/frontend/src/api/npm/index.ts index 029fdb1..5bccc13 100644 --- a/frontend/src/api/npm/index.ts +++ b/frontend/src/api/npm/index.ts @@ -1,6 +1,7 @@ export * from "./createCertificateAuthority"; export * from "./createDNSProvider"; export * from "./createUser"; +export * from "./getAccessLists"; export * from "./getCertificateAuthorities"; export * from "./getCertificateAuthority"; export * from "./getCertificates"; diff --git a/frontend/src/api/npm/models.ts b/frontend/src/api/npm/models.ts index 4d7bd21..3f2574e 100644 --- a/frontend/src/api/npm/models.ts +++ b/frontend/src/api/npm/models.ts @@ -38,6 +38,15 @@ export interface Setting { value: any; } +export interface AccessList { + id: number; + createdOn: number; + modifiedOn: number; + userId: number; + name: string; + meta: any; +} + // TODO: copy pasta not right export interface Certificate { id: number; diff --git a/frontend/src/api/npm/responseTypes.ts b/frontend/src/api/npm/responseTypes.ts index b4b27dd..0135a12 100644 --- a/frontend/src/api/npm/responseTypes.ts +++ b/frontend/src/api/npm/responseTypes.ts @@ -1,4 +1,5 @@ import { + AccessList, Certificate, CertificateAuthority, DNSProvider, @@ -34,6 +35,10 @@ export interface SettingsResponse extends BaseResponse { items: Setting[]; } +export interface AccessListsResponse extends BaseResponse { + items: AccessList[]; +} + export interface CertificatesResponse extends BaseResponse { items: Certificate[]; } diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 4281186..2d150a2 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from "./useAccessLists"; export * from "./useCertificateAuthorities"; export * from "./useCertificateAuthority"; export * from "./useCertificates"; diff --git a/frontend/src/hooks/useAccessLists.ts b/frontend/src/hooks/useAccessLists.ts new file mode 100644 index 0000000..6dac865 --- /dev/null +++ b/frontend/src/hooks/useAccessLists.ts @@ -0,0 +1,41 @@ +import { + getAccessLists, + AccessListsResponse, + tableSortToAPI, + tableFiltersToAPI, +} from "api/npm"; +import { useQuery } from "react-query"; + +const fetchAccessLists = ( + offset = 0, + limit = 10, + sortBy?: any, + filters?: any, +) => { + return getAccessLists( + offset, + limit, + tableSortToAPI(sortBy), + tableFiltersToAPI(filters), + ); +}; + +const useAccessLists = ( + offset = 0, + limit = 10, + sortBy?: any, + filters?: any, + options = {}, +) => { + return useQuery( + ["access-lists", { offset, limit, sortBy, filters }], + () => fetchAccessLists(offset, limit, sortBy, filters), + { + keepPreviousData: true, + staleTime: 15 * 1000, // 15 seconds + ...options, + }, + ); +}; + +export { useAccessLists }; diff --git a/frontend/src/pages/AccessLists/Table.tsx b/frontend/src/pages/AccessLists/Table.tsx new file mode 100644 index 0000000..e8694a9 --- /dev/null +++ b/frontend/src/pages/AccessLists/Table.tsx @@ -0,0 +1,137 @@ +import { useEffect, useMemo } from "react"; + +import { + tableEvents, + ActionsFormatter, + IDFormatter, + TableFilter, + TableLayout, + TablePagination, + TableSortBy, + TextFilter, +} from "components"; +import { intl } from "locale"; +import { FiEdit } from "react-icons/fi"; +import { useSortBy, useFilters, useTable, usePagination } from "react-table"; + +export interface TableProps { + data: any; + pagination: TablePagination; + sortBy: TableSortBy[]; + filters: TableFilter[]; + onTableEvent: any; +} +function Table({ + data, + pagination, + onTableEvent, + sortBy, + filters, +}: TableProps) { + const [columns, tableData] = useMemo(() => { + const columns: any = [ + { + Header: intl.formatMessage({ id: "column.id" }), + accessor: "id", + Cell: IDFormatter(), + sortable: true, + }, + { + Header: intl.formatMessage({ id: "column.name" }), + accessor: "name", + sortable: true, + Filter: TextFilter, + }, + { + id: "actions", + accessor: "id", + Cell: ActionsFormatter([ + { + title: intl.formatMessage({ id: "action.edit" }), + onClick: (e: any, data: any) => { + alert(JSON.stringify(data, null, 2)); + }, + icon: , + show: (data: any) => !data.isSystem, + }, + ]), + className: "w-80", + }, + ]; + return [columns, data]; + }, [data]); + + const tableInstance = useTable( + { + columns, + data: tableData, + initialState: { + pageIndex: Math.floor(pagination.offset / pagination.limit), + pageSize: pagination.limit, + sortBy, + filters, + }, + // Tell the usePagination + // hook that we'll handle our own data fetching + // This means we'll also have to provide our own + // pageCount. + pageCount: Math.ceil(pagination.total / pagination.limit), + manualPagination: true, + // Sorting options + manualSortBy: true, + disableMultiSort: true, + disableSortRemove: true, + autoResetSortBy: false, + // Filter options + manualFilters: true, + autoResetFilters: false, + }, + useFilters, + useSortBy, + usePagination, + ); + + const gotoPage = tableInstance.gotoPage; + + useEffect(() => { + onTableEvent({ + type: tableEvents.PAGE_CHANGED, + payload: tableInstance.state.pageIndex, + }); + }, [onTableEvent, tableInstance.state.pageIndex]); + + useEffect(() => { + onTableEvent({ + type: tableEvents.PAGE_SIZE_CHANGED, + payload: tableInstance.state.pageSize, + }); + gotoPage(0); + }, [gotoPage, onTableEvent, tableInstance.state.pageSize]); + + useEffect(() => { + if (pagination.total) { + onTableEvent({ + type: tableEvents.TOTAL_COUNT_CHANGED, + payload: pagination.total, + }); + } + }, [pagination.total, onTableEvent]); + + useEffect(() => { + onTableEvent({ + type: tableEvents.SORT_CHANGED, + payload: tableInstance.state.sortBy, + }); + }, [onTableEvent, tableInstance.state.sortBy]); + + useEffect(() => { + onTableEvent({ + type: tableEvents.FILTERS_CHANGED, + payload: tableInstance.state.filters, + }); + }, [onTableEvent, tableInstance.state.filters]); + + return ; +} + +export default Table; diff --git a/frontend/src/pages/AccessLists/TableWrapper.tsx b/frontend/src/pages/AccessLists/TableWrapper.tsx new file mode 100644 index 0000000..5597576 --- /dev/null +++ b/frontend/src/pages/AccessLists/TableWrapper.tsx @@ -0,0 +1,87 @@ +import { useEffect, useReducer, useState } from "react"; + +import { Alert, AlertIcon } from "@chakra-ui/react"; +import { EmptyList, SpinnerPage, tableEventReducer } from "components"; +import { useAccessLists } from "hooks"; +import { intl } from "locale"; + +import Table from "./Table"; + +const initialState = { + offset: 0, + limit: 10, + sortBy: [ + { + id: "name", + desc: false, + }, + ], + filters: [], +}; + +interface TableWrapperProps { + onCreateClick?: () => void; +} +function TableWrapper({ onCreateClick }: TableWrapperProps) { + const [{ offset, limit, sortBy, filters }, dispatch] = useReducer( + tableEventReducer, + initialState, + ); + + const [tableData, setTableData] = useState(null); + const { isFetching, isLoading, isError, error, data } = useAccessLists( + offset, + limit, + sortBy, + filters, + ); + + useEffect(() => { + setTableData(data as any); + }, [data]); + + if (isFetching || isLoading || !tableData) { + return ; + } + + if (isError) { + return ( + + + {error?.message || "Unknown error"} + + ); + } + + if (isFetching || isLoading || !tableData) { + return ; + } + + // When there are no items and no filters active, show the nicer empty view + if (data?.total === 0 && filters?.length === 0) { + return ( + + ); + } + + const pagination = { + offset: data?.offset || initialState.offset, + limit: data?.limit || initialState.limit, + total: data?.total || 0, + }; + + return ( + + ); +} + +export default TableWrapper; diff --git a/frontend/src/pages/AccessLists/index.tsx b/frontend/src/pages/AccessLists/index.tsx index e83a57f..3096742 100644 --- a/frontend/src/pages/AccessLists/index.tsx +++ b/frontend/src/pages/AccessLists/index.tsx @@ -1,9 +1,16 @@ import { Heading } from "@chakra-ui/react"; import { intl } from "locale"; +import TableWrapper from "./TableWrapper"; + function AccessLists() { return ( - {intl.formatMessage({ id: "access-lists.title" })} + <> + + {intl.formatMessage({ id: "access-lists.title" })} + + + ); }