Access lists basics

This commit is contained in:
Jamie Curnow 2023-01-13 15:03:28 +10:00
parent a239be993c
commit 6c76c041c4
17 changed files with 732 additions and 2 deletions

View File

@ -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())
}
}
}

View File

@ -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())
})

View File

@ -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))
}

View File

@ -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))
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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<AccessListsResponse> {
const { result } = await api.get(
{
url: "access-lists",
params: { limit, offset, sort, ...filters },
},
abortController,
);
return result;
}

View File

@ -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";

View File

@ -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;

View File

@ -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[];
}

View File

@ -1,3 +1,4 @@
export * from "./useAccessLists";
export * from "./useCertificateAuthorities";
export * from "./useCertificateAuthority";
export * from "./useCertificates";

View File

@ -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<AccessListsResponse, Error>(
["access-lists", { offset, limit, sortBy, filters }],
() => fetchAccessLists(offset, limit, sortBy, filters),
{
keepPreviousData: true,
staleTime: 15 * 1000, // 15 seconds
...options,
},
);
};
export { useAccessLists };

View File

@ -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: <FiEdit />,
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 <TableLayout pagination={pagination} {...tableInstance} />;
}
export default Table;

View File

@ -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 <SpinnerPage />;
}
if (isError) {
return (
<Alert status="error">
<AlertIcon />
{error?.message || "Unknown error"}
</Alert>
);
}
if (isFetching || isLoading || !tableData) {
return <SpinnerPage />;
}
// When there are no items and no filters active, show the nicer empty view
if (data?.total === 0 && filters?.length === 0) {
return (
<EmptyList
title={intl.formatMessage({ id: "create-access-list-title" })}
summary={intl.formatMessage({ id: "create-hint" })}
/>
);
}
const pagination = {
offset: data?.offset || initialState.offset,
limit: data?.limit || initialState.limit,
total: data?.total || 0,
};
return (
<Table
data={data?.items || []}
pagination={pagination}
sortBy={sortBy}
filters={filters}
onTableEvent={dispatch}
/>
);
}
export default TableWrapper;

View File

@ -1,9 +1,16 @@
import { Heading } from "@chakra-ui/react";
import { intl } from "locale";
import TableWrapper from "./TableWrapper";
function AccessLists() {
return (
<Heading mb={2}>{intl.formatMessage({ id: "access-lists.title" })}</Heading>
<>
<Heading mb={2}>
{intl.formatMessage({ id: "access-lists.title" })}
</Heading>
<TableWrapper />
</>
);
}