diff --git a/satellite/admin/back-office/api-docs.gen.md b/satellite/admin/back-office/api-docs.gen.md
new file mode 100644
index 000000000..e8052ec1d
--- /dev/null
+++ b/satellite/admin/back-office/api-docs.gen.md
@@ -0,0 +1,26 @@
+# API Docs
+
+**Description:**
+
+**Version:** `v1`
+
+
List of Endpoints
+
+* Example
+ * [Get examples](#example-get-examples)
+
+
+
+Get a list with the names of the all available examples
+
+`GET /api/v1/example/examples`
+
+**Response body:**
+
+```typescript
+[
+string
+]
+
+```
+
diff --git a/satellite/admin/back-office/gen/main.go b/satellite/admin/back-office/gen/main.go
new file mode 100644
index 000000000..f5b5b9aef
--- /dev/null
+++ b/satellite/admin/back-office/gen/main.go
@@ -0,0 +1,63 @@
+// Copyright (C) 2023 Storj Labs, Inc.
+// See LICENSE for copying information.
+
+// Package main defines the satellite administration API through the API generator and generates
+// source code of the API server handlers and clients and the documentation markdown document.
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "storj.io/storj/private/apigen"
+)
+
+func main() {
+ api := &apigen.API{PackageName: "admin", Version: "v1", BasePath: "/api"}
+
+ // This is an example and must be deleted when we define the first real endpoint.
+ group := api.Group("Example", "example")
+
+ group.Get("/examples", &apigen.Endpoint{
+ Name: "Get examples",
+ Description: "Get a list with the names of the all available examples",
+ GoName: "GetExamples",
+ TypeScriptName: "getExamples",
+ Response: []string{},
+ ResponseMock: []string{"example-1", "example-2", "example-3"},
+ NoCookieAuth: false,
+ NoAPIAuth: false,
+ })
+
+ modroot := findModuleRootDir()
+ api.MustWriteGo(filepath.Join(modroot, "satellite", "admin", "back-office", "handlers.gen.go"))
+ api.MustWriteTS(filepath.Join(modroot, "satellite", "admin", "back-office", "ui", "src", "api", "client.gen.ts"))
+ api.MustWriteTSMock(filepath.Join(modroot, "satellite", "admin", "back-office", "ui", "src", "api", "client-mock.gen.ts"))
+ api.MustWriteDocs(filepath.Join(modroot, "satellite", "admin", "back-office", "api-docs.gen.md"))
+}
+
+func findModuleRootDir() string {
+ dir, err := os.Getwd()
+ if err != nil {
+ panic("unable to find current working directory")
+ }
+ start := dir
+
+ for i := 0; i < 100; i++ {
+ if fileExists(filepath.Join(dir, "go.mod")) {
+ return dir
+ }
+ next := filepath.Dir(dir)
+ if next == dir {
+ break
+ }
+ dir = next
+ }
+
+ panic("unable to find go.mod starting from " + start)
+}
+
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
diff --git a/satellite/admin/back-office/handlers.gen.go b/satellite/admin/back-office/handlers.gen.go
new file mode 100644
index 000000000..cb212f2e2
--- /dev/null
+++ b/satellite/admin/back-office/handlers.gen.go
@@ -0,0 +1,71 @@
+// AUTOGENERATED BY private/apigen
+// DO NOT EDIT.
+
+package admin
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "github.com/spacemonkeygo/monkit/v3"
+ "github.com/zeebo/errs"
+ "go.uber.org/zap"
+
+ "storj.io/storj/private/api"
+)
+
+var ErrExampleAPI = errs.Class("admin example api")
+
+type ExampleService interface {
+ GetExamples(ctx context.Context) ([]string, api.HTTPError)
+}
+
+// ExampleHandler is an api handler that implements all Example API endpoints functionality.
+type ExampleHandler struct {
+ log *zap.Logger
+ mon *monkit.Scope
+ service ExampleService
+ auth api.Auth
+}
+
+func NewExample(log *zap.Logger, mon *monkit.Scope, service ExampleService, router *mux.Router, auth api.Auth) *ExampleHandler {
+ handler := &ExampleHandler{
+ log: log,
+ mon: mon,
+ service: service,
+ auth: auth,
+ }
+
+ exampleRouter := router.PathPrefix("/api/v1/example").Subrouter()
+ exampleRouter.HandleFunc("/examples", handler.handleGetExamples).Methods("GET")
+
+ return handler
+}
+
+func (h *ExampleHandler) handleGetExamples(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ var err error
+ defer h.mon.Task()(&ctx)(&err)
+
+ w.Header().Set("Content-Type", "application/json")
+
+ ctx, err = h.auth.IsAuthenticated(ctx, r, true, true)
+ if err != nil {
+ h.auth.RemoveAuthCookie(w)
+ api.ServeError(h.log, w, http.StatusUnauthorized, err)
+ return
+ }
+
+ retVal, httpErr := h.service.GetExamples(ctx)
+ if httpErr.Err != nil {
+ api.ServeError(h.log, w, httpErr.Status, httpErr.Err)
+ return
+ }
+
+ err = json.NewEncoder(w).Encode(retVal)
+ if err != nil {
+ h.log.Debug("failed to write json GetExamples response", zap.Error(ErrExampleAPI.Wrap(err)))
+ }
+}
diff --git a/satellite/admin/back-office/server.go b/satellite/admin/back-office/server.go
new file mode 100644
index 000000000..1246d3cc5
--- /dev/null
+++ b/satellite/admin/back-office/server.go
@@ -0,0 +1,127 @@
+// Copyright (C) 2023 Storj Labs, Inc.
+// See LICENSE for copying information.
+
+// Package admin implements a server which serves a REST API and a web application to allow
+// performing satellite administration tasks.
+//
+// NOTE this is work in progress and will eventually replace the current satellite administration
+// server implemented in the parent package, hence this package name is the same than its parent
+// because it will simplify the replace once it's ready.
+package admin
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "github.com/zeebo/errs"
+ "go.uber.org/zap"
+ "golang.org/x/sync/errgroup"
+
+ "storj.io/common/errs2"
+ ui "storj.io/storj/satellite/admin/back-office/ui"
+)
+
+// Error is the error class that wraps all the errors returned by this package.
+var Error = errs.Class("satellite-admin")
+
+// Config defines configuration for the satellite administration server.
+type Config struct {
+ StaticDir string `help:"an alternate directory path which contains the static assets for the satellite administration web app. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""`
+}
+
+// Server serves the API endpoints and the web application to allow preforming satellite
+// administration tasks.
+type Server struct {
+ log *zap.Logger
+
+ listener net.Listener
+ server http.Server
+
+ config Config
+}
+
+// ParentRouter is mux.Router with its full path prefix.
+type ParentRouter struct {
+ Router *mux.Router
+ PathPrefix string
+}
+
+// NewServer creates a satellite administration server instance with the provided dependencies and
+// configurations.
+//
+// When listener is nil, Server.Run is a noop.
+//
+// When parentRouter is nil it creates a new Router to attach the server endpoints, otherwise , it
+// attaches them to the provided one, allowing to expose its functionality through another server.
+func NewServer(log *zap.Logger, listener net.Listener, parentRouter *ParentRouter, config Config) *Server {
+ server := &Server{
+ log: log,
+ listener: listener,
+ config: config,
+ }
+
+ if parentRouter == nil {
+ parentRouter = &ParentRouter{}
+ }
+
+ root := parentRouter.Router
+ if root == nil {
+ root = mux.NewRouter()
+ }
+
+ // API endpoints.
+ // api := root.PathPrefix("/api/").Subrouter()
+
+ // Static assets for the web interface.
+ // This handler must be the last one because it uses the root as prefix, otherwise, it will serve
+ // all the paths defined by the handlers set after this one.
+ var staticHandler http.Handler
+ if config.StaticDir == "" {
+ if parentRouter.PathPrefix != "" {
+ staticHandler = http.StripPrefix("/back-office/", http.FileServer(http.FS(ui.Assets)))
+ } else {
+ staticHandler = http.FileServer(http.FS(ui.Assets))
+ }
+ } else {
+ if parentRouter.PathPrefix != "" {
+ staticHandler = http.StripPrefix("/back-office/", http.FileServer(http.Dir(config.StaticDir)))
+ } else {
+ staticHandler = http.FileServer(http.Dir(config.StaticDir))
+ }
+ }
+
+ root.PathPrefix("/").Handler(staticHandler).Methods("GET")
+
+ return server
+}
+
+// Run starts the administration HTTP server using the provided listener.
+// If listener is nil, it does nothing and return nil.
+func (server *Server) Run(ctx context.Context) error {
+ if server.listener == nil {
+ return nil
+ }
+ ctx, cancel := context.WithCancel(ctx)
+ var group errgroup.Group
+ group.Go(func() error {
+ <-ctx.Done()
+ return Error.Wrap(server.server.Shutdown(context.Background()))
+ })
+ group.Go(func() error {
+ defer cancel()
+ err := server.server.Serve(server.listener)
+ if errs2.IsCanceled(err) || errors.Is(err, http.ErrServerClosed) {
+ err = nil
+ }
+ return Error.Wrap(err)
+ })
+ return group.Wait()
+}
+
+// Close closes server and underlying listener.
+func (server *Server) Close() error {
+ return Error.Wrap(server.server.Close())
+}
diff --git a/satellite/admin/back-office/ui/src/api/client-mock.gen.ts b/satellite/admin/back-office/ui/src/api/client-mock.gen.ts
new file mode 100644
index 000000000..88e6998d6
--- /dev/null
+++ b/satellite/admin/back-office/ui/src/api/client-mock.gen.ts
@@ -0,0 +1,39 @@
+// AUTOGENERATED BY private/apigen
+// DO NOT EDIT.
+
+class APIError extends Error {
+ constructor(
+ public readonly msg: string,
+ public readonly responseStatusCode?: number,
+ ) {
+ super(msg);
+ }
+}
+
+export class ExampleHttpApiV1 {
+ public readonly respStatusCode: number;
+
+ // When respStatuscode is passed, the client throws an APIError on each method call
+ // with respStatusCode as HTTP status code.
+ // respStatuscode must be equal or greater than 400
+ constructor(respStatusCode?: number) {
+ if (typeof respStatusCode === 'undefined') {
+ this.respStatusCode = 0;
+ return;
+ }
+
+ if (respStatusCode < 400) {
+ throw new Error('invalid response status code for API Error, it must be greater or equal than 400');
+ }
+
+ this.respStatusCode = respStatusCode;
+ }
+
+ public async getExamples(): Promise {
+ if (this.respStatusCode !== 0) {
+ throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode);
+ }
+
+ return JSON.parse('["example-1","example-2","example-3"]') as string[];
+ }
+}
diff --git a/satellite/admin/back-office/ui/src/api/client.gen.ts b/satellite/admin/back-office/ui/src/api/client.gen.ts
new file mode 100644
index 000000000..5d959f0ed
--- /dev/null
+++ b/satellite/admin/back-office/ui/src/api/client.gen.ts
@@ -0,0 +1,28 @@
+// AUTOGENERATED BY private/apigen
+// DO NOT EDIT.
+
+import { HttpClient } from '@/utils/httpClient';
+
+class APIError extends Error {
+ constructor(
+ public readonly msg: string,
+ public readonly responseStatusCode?: number,
+ ) {
+ super(msg);
+ }
+}
+
+export class ExampleHttpApiV1 {
+ private readonly http: HttpClient = new HttpClient();
+ private readonly ROOT_PATH: string = '/api/v1/example';
+
+ public async getExamples(): Promise {
+ const fullPath = `${this.ROOT_PATH}/examples`;
+ const response = await this.http.get(fullPath);
+ if (response.ok) {
+ return response.json().then((body) => body as string[]);
+ }
+ const err = await response.json();
+ throw new APIError(err.error, response.status);
+ }
+}
diff --git a/satellite/admin/back-office/ui/src/api/errors/ErrorUnauthorized.ts b/satellite/admin/back-office/ui/src/api/errors/ErrorUnauthorized.ts
new file mode 100644
index 000000000..8616ad03d
--- /dev/null
+++ b/satellite/admin/back-office/ui/src/api/errors/ErrorUnauthorized.ts
@@ -0,0 +1,11 @@
+// Copyright (C) 2019 Storj Labs, Inc.
+// See LICENSE for copying information.
+
+/**
+ * ErrorUnauthorized is a custom error type for performing unauthorized operations.
+ */
+export class ErrorUnauthorized extends Error {
+ public constructor(message = 'Authorization required') {
+ super(message);
+ }
+}
diff --git a/satellite/admin/back-office/ui/src/utils/httpClient.ts b/satellite/admin/back-office/ui/src/utils/httpClient.ts
new file mode 100644
index 000000000..c43c23469
--- /dev/null
+++ b/satellite/admin/back-office/ui/src/utils/httpClient.ts
@@ -0,0 +1,106 @@
+// Copyright (C) 2019 Storj Labs, Inc.
+// See LICENSE for copying information.
+
+import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
+
+/**
+ * HttpClient is a custom wrapper around fetch api.
+ * Exposes get, post and delete methods for JSON strings.
+ */
+export class HttpClient {
+ /**
+ *
+ * @param method holds http method type
+ * @param path
+ * @param body serialized JSON
+ */
+ private async sendJSON(method: string, path: string, body: string | null): Promise {
+ const request: RequestInit = {
+ method: method,
+ body: body,
+ };
+
+ request.headers = {
+ 'Content-Type': 'application/json',
+ };
+
+ const response = await fetch(path, request);
+ if (response.status === 401) {
+ await this.handleUnauthorized();
+ throw new ErrorUnauthorized();
+ }
+
+ return response;
+ }
+
+ /**
+ * Performs POST http request with JSON body.
+ * @param path
+ * @param body serialized JSON
+ */
+ public async post(path: string, body: string | null): Promise {
+ return this.sendJSON('POST', path, body);
+ }
+
+ /**
+ * Performs PATCH http request with JSON body.
+ * @param path
+ * @param body serialized JSON
+ */
+ public async patch(path: string, body: string | null): Promise {
+ return this.sendJSON('PATCH', path, body);
+ }
+
+ /**
+ * Performs PUT http request with JSON body.
+ * @param path
+ * @param body serialized JSON
+ */
+ public async put(path: string, body: string | null): Promise {
+ return this.sendJSON('PUT', path, body);
+ }
+
+ /**
+ * Performs GET http request.
+ * @param path
+ */
+ public async get(path: string): Promise {
+ return this.sendJSON('GET', path, null);
+ }
+
+ /**
+ * Performs DELETE http request.
+ * @param path
+ * @param body serialized JSON
+ */
+ public async delete(path: string, body: string | null = null): Promise {
+ return this.sendJSON('DELETE', path, body);
+ }
+
+ /**
+ * Handles unauthorized actions.
+ * Call logout and redirect to login.
+ */
+ private async handleUnauthorized(): Promise {
+ try {
+ const logoutPath = '/api/v0/auth/logout';
+ const request: RequestInit = {
+ method: 'POST',
+ body: null,
+ };
+
+ request.headers = {
+ 'Content-Type': 'application/json',
+ };
+
+ await fetch(logoutPath, request);
+ // eslint-disable-next-line no-empty
+ } catch (error) {}
+
+ setTimeout(() => {
+ if (!window.location.href.includes('/login')) {
+ window.location.href = window.location.origin + '/login';
+ }
+ }, 2000);
+ }
+}
diff --git a/satellite/admin/server.go b/satellite/admin/server.go
index 6d88d1578..cc7a40181 100644
--- a/satellite/admin/server.go
+++ b/satellite/admin/server.go
@@ -20,7 +20,7 @@ import (
"storj.io/common/errs2"
"storj.io/storj/satellite/accounting"
- backofficeui "storj.io/storj/satellite/admin/back-office/ui"
+ backoffice "storj.io/storj/satellite/admin/back-office"
adminui "storj.io/storj/satellite/admin/ui"
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/attribution"
@@ -44,13 +44,13 @@ const (
// Config defines configuration for debug server.
type Config struct {
- Address string `help:"admin peer http listening address" releaseDefault:"" devDefault:""`
- StaticDir string `help:"an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""`
- StaticDirBackOffice string `help:"an alternate directory path which contains the static assets for the currently in development back-office. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""`
- AllowedOauthHost string `help:"the oauth host allowed to bypass token authentication."`
- Groups Groups
+ Address string `help:"admin peer http listening address" releaseDefault:"" devDefault:""`
+ StaticDir string `help:"an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""`
+ AllowedOauthHost string `help:"the oauth host allowed to bypass token authentication."`
+ Groups Groups
AuthorizationToken string `internal:"true"`
+ BackOffice backoffice.Config
}
// Groups defines permission groups.
@@ -178,16 +178,15 @@ func NewServer(
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.getProjectLimit).Methods("GET")
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST")
- // Temporary path until the new back-office is implemented and we can remove the current admin UI.
- if config.StaticDirBackOffice == "" {
- root.PathPrefix("/back-office").Handler(
- http.StripPrefix("/back-office/", http.FileServer(http.FS(backofficeui.Assets))),
- ).Methods("GET")
- } else {
- root.PathPrefix("/back-office").Handler(
- http.StripPrefix("/back-office/", http.FileServer(http.Dir(config.StaticDirBackOffice))),
- ).Methods("GET")
- }
+ _ = backoffice.NewServer(
+ log.Named("back-office"),
+ nil,
+ &backoffice.ParentRouter{
+ Router: root.PathPrefix("/back-office/").Subrouter(),
+ PathPrefix: "/back-office",
+ },
+ config.BackOffice,
+ )
// This handler must be the last one because it uses the root as prefix,
// otherwise will try to serve all the handlers set after this one.
diff --git a/satellite/admin/server_test.go b/satellite/admin/server_test.go
index def674128..9947f57fc 100644
--- a/satellite/admin/server_test.go
+++ b/satellite/admin/server_test.go
@@ -28,7 +28,7 @@ func TestBasic(t *testing.T) {
Satellite: func(_ *zap.Logger, _ int, config *satellite.Config) {
config.Admin.Address = "127.0.0.1:0"
config.Admin.StaticDir = "ui"
- config.Admin.StaticDirBackOffice = "back-office/ui"
+ config.Admin.BackOffice.StaticDir = "back-office/ui"
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
diff --git a/scripts/testdata/satellite-config.yaml.lock b/scripts/testdata/satellite-config.yaml.lock
index 81a4a90c9..779695711 100755
--- a/scripts/testdata/satellite-config.yaml.lock
+++ b/scripts/testdata/satellite-config.yaml.lock
@@ -16,15 +16,15 @@
# the oauth host allowed to bypass token authentication.
# admin.allowed-oauth-host: ""
+# an alternate directory path which contains the static assets for the satellite administration web app. When empty, it uses the embedded assets
+# admin.back-office.static-dir: ""
+
# the group which is only allowed to update user and project limits and freeze and unfreeze accounts.
# admin.groups.limit-update: ""
# an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets
# admin.static-dir: ""
-# an alternate directory path which contains the static assets for the currently in development back-office. When empty, it uses the embedded assets
-# admin.static-dir-back-office: ""
-
# enable analytics reporting
# analytics.enabled: false