feat(sdk,client): add menu to help navigation between apps
arcad/edge/pipeline/head This commit looks good
Details
11
Makefile
|
@ -9,6 +9,9 @@ ESBUILD_VERSION ?= v0.17.5
|
|||
GIT_VERSION := $(shell git describe --always)
|
||||
DATE_VERSION := $(shell date +%Y.%-m.%-d)
|
||||
FULL_VERSION := v$(DATE_VERSION)-$(GIT_VERSION)$(if $(shell git diff --stat),-dirty,)
|
||||
APP_PATH ?= misc/client-sdk-testsuite/dist
|
||||
RUN_APP_ARGS ?=
|
||||
SHELL := bash
|
||||
|
||||
build: build-edge-cli build-client-sdk-test-app
|
||||
|
||||
|
@ -55,12 +58,18 @@ pkg/sdk/client/dist/client.js: tools/esbuild/bin/esbuild node_modules
|
|||
--global-name=Edge \
|
||||
--define:global=window \
|
||||
--platform=browser \
|
||||
--footer:js="EdgeFrame=Edge.crossFrameMessenger;Edge=Edge.client" \
|
||||
--loader:.svg=dataurl \
|
||||
--outfile=pkg/sdk/client/dist/client.js
|
||||
|
||||
node_modules:
|
||||
npm ci
|
||||
|
||||
run-app: .env
|
||||
( set -o allexport && source .env && set +o allexport && bin/cli app run -p $(APP_PATH) $$RUN_APP_ARGS )
|
||||
|
||||
.env:
|
||||
cp .env.dist .env
|
||||
|
||||
gitea-release: tools/yq/bin/yq tools/gitea-release/bin/gitea-release.sh build
|
||||
mkdir -p .gitea-release
|
||||
rm -rf .gitea-release/*
|
||||
|
|
|
@ -4,9 +4,9 @@
|
|||
"algo": "argon2id",
|
||||
"password": "$argon2id$v=19$m=65536,t=3,p=2$cWOxfEyBy4EyKZR5usB2Pw$xG+Z/E2DUJP9kF0s1fhZjIuP03gFQ65dP7pHRJz7eR8",
|
||||
"claims": {
|
||||
"arcad_entrypoint": "edge",
|
||||
"arcad_role": "superadmin",
|
||||
"arcad_tenant": "dev.cli",
|
||||
"edge_entrypoint": "edge",
|
||||
"edge_role": "superadmin",
|
||||
"edge_tenant": "dev.cli",
|
||||
"preferred_username": "SuperAdmin",
|
||||
"sub": "superadmin"
|
||||
}
|
||||
|
@ -16,9 +16,9 @@
|
|||
"algo": "argon2id",
|
||||
"password": "$argon2id$v=19$m=65536,t=3,p=2$WXXc4ECnkej6WO7f0Xya6Q$UG2wcGltJcuW0cNTR85mAl65tI1kFWMMw7ADS2FMOvY",
|
||||
"claims": {
|
||||
"arcad_entrypoint": "edge",
|
||||
"arcad_role": "admin",
|
||||
"arcad_tenant": "dev.cli",
|
||||
"edge_entrypoint": "edge",
|
||||
"edge_role": "admin",
|
||||
"edge_tenant": "dev.cli",
|
||||
"preferred_username": "Admin",
|
||||
"sub": "admin"
|
||||
}
|
||||
|
@ -28,9 +28,9 @@
|
|||
"algo": "argon2id",
|
||||
"password": "$argon2id$v=19$m=65536,t=3,p=2$gkDAWCzfU23+un3x0ny+YA$L/NSPrd5iKPK/UnSCKfSz4EO+v94N3LTLky4QGJOfpI",
|
||||
"claims": {
|
||||
"arcad_entrypoint": "edge",
|
||||
"arcad_role": "superuser",
|
||||
"arcad_tenant": "dev.cli",
|
||||
"edge_entrypoint": "edge",
|
||||
"edge_role": "superuser",
|
||||
"edge_tenant": "dev.cli",
|
||||
"preferred_username": "SuperUser",
|
||||
"sub": "superuser"
|
||||
}
|
||||
|
@ -40,9 +40,9 @@
|
|||
"algo": "argon2id",
|
||||
"password": "$argon2id$v=19$m=65536,t=3,p=2$DhUm9qXUKP35Lzp5M37eZA$2+h6yDxSTHZqFZIuI7JZfFWozwrObna8a8yCgEEPlPE",
|
||||
"claims": {
|
||||
"arcad_entrypoint": "edge",
|
||||
"arcad_role": "user",
|
||||
"arcad_tenant": "dev.cli",
|
||||
"edge_entrypoint": "edge",
|
||||
"edge_role": "user",
|
||||
"edge_tenant": "dev.cli",
|
||||
"preferred_username": "User",
|
||||
"sub": "user"
|
||||
}
|
||||
|
|
|
@ -9,7 +9,9 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
|
@ -18,7 +20,7 @@ import (
|
|||
"forge.cadoles.com/arcad/edge/pkg/module"
|
||||
appModule "forge.cadoles.com/arcad/edge/pkg/module/app"
|
||||
appModuleMemory "forge.cadoles.com/arcad/edge/pkg/module/app/memory"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/auth"
|
||||
authModule "forge.cadoles.com/arcad/edge/pkg/module/auth"
|
||||
authHTTP "forge.cadoles.com/arcad/edge/pkg/module/auth/http"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/blob"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/cast"
|
||||
|
@ -29,7 +31,6 @@ import (
|
|||
"gitlab.com/wpetit/goweb/logger"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bundle"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||
|
@ -48,15 +49,15 @@ func RunCommand() *cli.Command {
|
|||
Name: "run",
|
||||
Usage: "Run the specified app bundle",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "path",
|
||||
Usage: "use `PATH` as app bundle (zipped bundle or directory)",
|
||||
Aliases: []string{"p"},
|
||||
Value: ".",
|
||||
Value: cli.NewStringSlice("."),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "address",
|
||||
Usage: "use `ADDRESS` as http server listening address",
|
||||
Usage: "use `ADDRESS` as http server base listening address",
|
||||
Aliases: []string{"a"},
|
||||
Value: ":8080",
|
||||
},
|
||||
|
@ -83,96 +84,157 @@ func RunCommand() *cli.Command {
|
|||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
address := ctx.String("address")
|
||||
path := ctx.String("path")
|
||||
paths := ctx.StringSlice("path")
|
||||
|
||||
logFormat := ctx.String("log-format")
|
||||
logLevel := ctx.Int("log-level")
|
||||
storageFile := ctx.String("storage-file")
|
||||
accountsFile := ctx.String("accounts-file")
|
||||
|
||||
logger.SetFormat(logger.Format(logFormat))
|
||||
logger.SetLevel(logger.Level(logLevel))
|
||||
|
||||
cmdCtx := ctx.Context
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not resolve path '%s'", path)
|
||||
}
|
||||
|
||||
logger.Info(cmdCtx, "opening app bundle", logger.F("path", absPath))
|
||||
|
||||
bundle, err := bundle.FromPath(path)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not open path '%s' as an app bundle", path)
|
||||
}
|
||||
|
||||
manifest, err := app.LoadManifest(bundle)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not load manifest from app bundle")
|
||||
}
|
||||
|
||||
if valid, err := manifest.Validate(manifestMetadataValidators...); !valid {
|
||||
return errors.Wrap(err, "invalid app manifest")
|
||||
}
|
||||
|
||||
storageFile := injectAppID(ctx.String("storage-file"), manifest.ID)
|
||||
|
||||
if err := ensureDir(storageFile); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
db, err := sqlite.Open(storageFile)
|
||||
host, portStr, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
ds := sqlite.NewDocumentStoreWithDB(db)
|
||||
bs := sqlite.NewBlobStoreWithDB(db)
|
||||
bus := memory.NewBus()
|
||||
|
||||
handler := appHTTP.NewHandler(
|
||||
appHTTP.WithBus(bus),
|
||||
appHTTP.WithServerModules(getServerModules(bus, ds, bs, manifest, address)...),
|
||||
)
|
||||
if err := handler.Load(bundle); err != nil {
|
||||
return errors.Wrap(err, "could not load app bundle")
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.Logger)
|
||||
|
||||
accountsFile := injectAppID(ctx.String("accounts-file"), manifest.ID)
|
||||
|
||||
accounts, err := loadLocalAccounts(accountsFile)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not load local accounts")
|
||||
}
|
||||
|
||||
// Add auth handler
|
||||
key, err := dummyKey()
|
||||
port, err := strconv.ParseUint(portStr, 10, 32)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
router.Handle("/auth/*", authHTTP.NewLocalHandler(
|
||||
jwa.HS256, key,
|
||||
authHTTP.WithRoutePrefix("/auth"),
|
||||
authHTTP.WithAccounts(accounts...),
|
||||
))
|
||||
|
||||
// Add app handler
|
||||
router.Handle("/*", handler)
|
||||
manifests := make([]*app.Manifest, len(paths))
|
||||
for idx, pth := range paths {
|
||||
bdl, err := bundle.FromPath(pth)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Info(cmdCtx, "listening", logger.F("address", address))
|
||||
manifest, err := app.LoadManifest(bdl)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := http.ListenAndServe(address, router); err != nil {
|
||||
return errors.WithStack(err)
|
||||
manifests[idx] = manifest
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for idx, p := range paths {
|
||||
wg.Add(1)
|
||||
|
||||
go func(path string, basePort uint64, appIndex int) {
|
||||
defer wg.Done()
|
||||
|
||||
port := basePort + uint64(appIndex)
|
||||
address := fmt.Sprintf("%s:%d", host, port)
|
||||
appsRepository := newAppRepository(host, basePort, manifests...)
|
||||
|
||||
appCtx := logger.With(cmdCtx, logger.F("address", address))
|
||||
|
||||
if err := runApp(appCtx, path, address, storageFile, accountsFile, appsRepository); err != nil {
|
||||
logger.Error(appCtx, "could not run app", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}(p, port, idx)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getServerModules(bus bus.Bus, ds storage.DocumentStore, bs storage.BlobStore, manifest *app.Manifest, address string) []app.ServerModuleFactory {
|
||||
func runApp(ctx context.Context, path string, address string, storageFile string, accountsFile string, appRepository appModule.Repository) error {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not resolve path '%s'", path)
|
||||
}
|
||||
|
||||
logger.Info(ctx, "opening app bundle", logger.F("path", absPath))
|
||||
|
||||
bundle, err := bundle.FromPath(path)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not open path '%s' as an app bundle", path)
|
||||
}
|
||||
|
||||
manifest, err := app.LoadManifest(bundle)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not load manifest from app bundle")
|
||||
}
|
||||
|
||||
if valid, err := manifest.Validate(manifestMetadataValidators...); !valid {
|
||||
return errors.Wrap(err, "invalid app manifest")
|
||||
}
|
||||
|
||||
ctx = logger.With(ctx, logger.F("appID", manifest.ID))
|
||||
|
||||
storageFile = injectAppID(storageFile, manifest.ID)
|
||||
|
||||
if err := ensureDir(storageFile); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
db, err := sqlite.Open(storageFile)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
accountsFile = injectAppID(accountsFile, manifest.ID)
|
||||
|
||||
accounts, err := loadLocalAccounts(accountsFile)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not load local accounts")
|
||||
}
|
||||
|
||||
// Add auth handler
|
||||
key, err := dummyKey()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
ds := sqlite.NewDocumentStoreWithDB(db)
|
||||
bs := sqlite.NewBlobStoreWithDB(db)
|
||||
bus := memory.NewBus()
|
||||
|
||||
handler := appHTTP.NewHandler(
|
||||
appHTTP.WithBus(bus),
|
||||
appHTTP.WithServerModules(getServerModules(bus, ds, bs, appRepository)...),
|
||||
appHTTP.WithHTTPMounts(
|
||||
appModule.Mount(appRepository),
|
||||
authModule.Mount(
|
||||
authHTTP.NewLocalHandler(
|
||||
jwa.HS256, key,
|
||||
authHTTP.WithRoutePrefix("/auth"),
|
||||
authHTTP.WithAccounts(accounts...),
|
||||
),
|
||||
authModule.WithJWT(dummyKeySet),
|
||||
),
|
||||
),
|
||||
)
|
||||
if err := handler.Load(bundle); err != nil {
|
||||
return errors.Wrap(err, "could not load app bundle")
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.Logger)
|
||||
|
||||
// Add app handler
|
||||
router.Handle("/*", handler)
|
||||
|
||||
logger.Info(ctx, "listening", logger.F("address", address))
|
||||
|
||||
if err := http.ListenAndServe(address, router); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getServerModules(bus bus.Bus, ds storage.DocumentStore, bs storage.BlobStore, appRepository appModule.Repository) []app.ServerModuleFactory {
|
||||
return []app.ServerModuleFactory{
|
||||
module.ContextModuleFactory(),
|
||||
module.ConsoleModuleFactory(),
|
||||
|
@ -182,49 +244,10 @@ func getServerModules(bus bus.Bus, ds storage.DocumentStore, bs storage.BlobStor
|
|||
module.RPCModuleFactory(bus),
|
||||
module.StoreModuleFactory(ds),
|
||||
blob.ModuleFactory(bus, bs),
|
||||
module.Extends(
|
||||
auth.ModuleFactory(
|
||||
auth.WithJWT(dummyKeySet),
|
||||
),
|
||||
func(o *goja.Object) {
|
||||
if err := o.Set("CLAIM_TENANT", "arcad_tenant"); err != nil {
|
||||
panic(errors.New("could not set 'CLAIM_TENANT' property"))
|
||||
}
|
||||
|
||||
if err := o.Set("CLAIM_ENTRYPOINT", "arcad_entrypoint"); err != nil {
|
||||
panic(errors.New("could not set 'CLAIM_ENTRYPOINT' property"))
|
||||
}
|
||||
|
||||
if err := o.Set("CLAIM_ROLE", "arcad_role"); err != nil {
|
||||
panic(errors.New("could not set 'CLAIM_ROLE' property"))
|
||||
}
|
||||
|
||||
if err := o.Set("CLAIM_PREFERRED_USERNAME", "preferred_username"); err != nil {
|
||||
panic(errors.New("could not set 'CLAIM_PREFERRED_USERNAME' property"))
|
||||
}
|
||||
},
|
||||
authModule.ModuleFactory(
|
||||
authModule.WithJWT(dummyKeySet),
|
||||
),
|
||||
appModule.ModuleFactory(appModuleMemory.NewRepository(
|
||||
func(ctx context.Context, id app.ID, from string) (string, error) {
|
||||
addr := address
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
addr = "0.0.0.0" + addr
|
||||
}
|
||||
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
addr, err = findMatchingDeviceAddress(ctx, from, host)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://%s:%s", addr, port), nil
|
||||
},
|
||||
manifest,
|
||||
)),
|
||||
appModule.ModuleFactory(appRepository),
|
||||
fetch.ModuleFactory(bus),
|
||||
}
|
||||
}
|
||||
|
@ -349,3 +372,25 @@ func findMatchingDeviceAddress(ctx context.Context, from string, defaultAddr str
|
|||
|
||||
return defaultAddr, nil
|
||||
}
|
||||
|
||||
func newAppRepository(host string, basePort uint64, manifests ...*app.Manifest) *appModuleMemory.Repository {
|
||||
return appModuleMemory.NewRepository(
|
||||
func(ctx context.Context, id app.ID, from string) (string, error) {
|
||||
appIndex := 0
|
||||
for i := 0; i < len(manifests); i++ {
|
||||
if manifests[i].ID == id {
|
||||
appIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
addr, err := findMatchingDeviceAddress(ctx, from, host)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://%s:%d", addr, int(basePort)+appIndex), nil
|
||||
},
|
||||
manifests...,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -10,5 +10,6 @@ Afin de pouvoir utiliser le SDK "client", vous devez inclure dans la page HTML d
|
|||
|
||||
Vous pourrez ensuite accéder aux variables globales suivantes:
|
||||
|
||||
- [`Edge`](./edge.md) - Client principal d'échange avec le serveur
|
||||
- [`EdgeFrame`](./edge-frame.md)
|
||||
- [`Edge.Client`](./edge-client.md) - Client principal d'échange avec le serveur
|
||||
- [`Edge.Frame`](./edge-frame.md) - Utilitaire de communication avec une frame parente
|
||||
- [`Edge.Menu`](./edge-menu.md) - Gestionnaire de menu
|
|
@ -1,22 +1,22 @@
|
|||
# `Edge`
|
||||
# `Edge.Client`
|
||||
|
||||
## Méthodes
|
||||
|
||||
### `Edge.connect(): Promise`
|
||||
### `Edge.Client.connect(): Promise`
|
||||
|
||||
> `TODO`
|
||||
|
||||
### `Edge.disconnect(): void`
|
||||
### `Edge.Client.disconnect(): void`
|
||||
|
||||
> `TODO`
|
||||
|
||||
|
||||
### `Edge.send(message: Object): void`
|
||||
### `Edge.Client.send(message: Object): void`
|
||||
|
||||
> `TODO`
|
||||
|
||||
|
||||
### `Edge.rpc(method: string, params: Object): Promise`
|
||||
### `Edge.Client.rpc(method: string, params: Object): Promise`
|
||||
|
||||
> `TODO`
|
||||
#### Exemple
|
||||
|
@ -36,22 +36,22 @@ function echo(ctx, params) {
|
|||
**Côté client**
|
||||
|
||||
```js
|
||||
Edge.connect().then(() => {
|
||||
Edge.rpc("echo", { hello: "world!" })
|
||||
Edge.Client.connect().then(() => {
|
||||
Edge.Client.rpc("echo", { hello: "world!" })
|
||||
.then(result => console.log(result))
|
||||
.catch(err => console.error(err));
|
||||
});
|
||||
```
|
||||
|
||||
### `Edge.upload(blob: Blob, metadata: Object): Promise`
|
||||
### `Edge.Client.upload(blob: Blob, metadata: Object): Promise`
|
||||
|
||||
> `TODO`
|
||||
|
||||
### `Edge.blobUrl(bucketName: string, blobId: string): string`
|
||||
### `Edge.Client.blobUrl(bucketName: string, blobId: string): string`
|
||||
|
||||
> `TODO`
|
||||
|
||||
### `Edge.externalUrl(url: string): string`
|
||||
### `Edge.Client.externalUrl(url: string): string`
|
||||
|
||||
Retourne une URL "locale" permettant d'accéder à une ressource externe, en fonction de règles propres à l'application. Voir module [`fetch`](../server-api/fetch.md).
|
||||
|
||||
|
@ -64,5 +64,5 @@ Retourne une URL "locale" permettant d'accéder à une ressource externe, en fon
|
|||
#### Exemple
|
||||
|
||||
```js
|
||||
Edge.addEventListener("message", evt => console.log(evt.detail));
|
||||
Edge.Client.addEventListener("message", evt => console.log(evt.detail));
|
||||
```
|
|
@ -1,8 +1,8 @@
|
|||
# `EdgeFrame`
|
||||
# `Edge.Frame`
|
||||
|
||||
## Méthodes
|
||||
|
||||
### `EdgeFrame.addEventListener(name: string, listener: (event) => void)`
|
||||
### `Edge.Frame.addEventListener(name: string, listener: (event) => void)`
|
||||
|
||||
> `TODO`
|
||||
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
# `Edge.Menu`
|
||||
|
||||
## Méthodes
|
||||
|
||||
### `Edge.Menu.show()`
|
||||
|
||||
Afficher le menu.
|
||||
|
||||
### `Edge.Menu.hide()`
|
||||
|
||||
Cacher le menu.
|
||||
|
||||
### `setItem(name: string, label:string, options?: { iconUrl?: string, linkUrl?: string, order?: number })`
|
||||
|
||||
Créer/mettre à jour l'item nommé de la section du menu associée à l'application.
|
||||
|
||||
### `removeItem(name: string)`
|
||||
|
||||
Supprimer l'item de la section du menu associée à l'application.
|
||||
|
||||
### `setAppIconUrl(url: string)`
|
||||
|
||||
Mettre à jour l'URL de l'icône de la section du menu associée à l'application.
|
||||
|
||||
### `setAppTitle(title: string)`
|
||||
|
||||
Mettre à jour le titre de la section du menu associée à l'application.
|
|
@ -40,13 +40,13 @@ Créer le fichier `my-app/public/index.html`:
|
|||
<script type="text/javascript">
|
||||
// On utilise le SDK via la variable globale "Edge"
|
||||
// pour se connecter au serveur de notre application.
|
||||
Edge.connect().then(() => {
|
||||
Edge.Client.connect().then(() => {
|
||||
// Une fois connecté, on envoie un message au serveur.
|
||||
Edge.send({ "hello": "world" });
|
||||
Edge.Client.send({ "hello": "world" });
|
||||
});
|
||||
|
||||
// On écoute les messages en provenance du serveur.
|
||||
Edge.addEventListener("message", (evt) => {
|
||||
Edge.Client.addEventListener("message", (evt) => {
|
||||
console.log("New server message", evt.detail)
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -86,4 +86,4 @@ interface BlobInfo {
|
|||
|
||||
### `Metadata`
|
||||
|
||||
L'objet `Metadata` est un objet clé/valeur arbitraire transmis avec la requête de téléversement. Voir la méthode [`Edge.upload(blob, metadata)`](../client-api/README.md#edge-upload-blob-blob-metadata-object-promise) du SDK client.
|
||||
L'objet `Metadata` est un objet clé/valeur arbitraire transmis avec la requête de téléversement. Voir la méthode [`Edge.Client.upload(blob, metadata)`](../client-api/README.md#edge-upload-blob-blob-metadata-object-promise) du SDK client.
|
|
@ -13,7 +13,7 @@ Pour permettre aux utilisateurs d'accéder à des ressources distantes, vous dev
|
|||
**Côté client**
|
||||
```js
|
||||
// Création d'une URL "locale" permettant d'accéder à la ressource distante
|
||||
var url = Edge.externalUrl("http://example.com")
|
||||
var url = Edge.Client.externalUrl("http://example.com")
|
||||
|
||||
// Vous pouvez utiliser l'URL comme attribut `src` d'une balise <img> par exemple
|
||||
// ou effectuer une requête fetch() avec celle ci.
|
||||
|
|
|
@ -32,9 +32,9 @@ Aucune
|
|||
```js
|
||||
// Les données envoyées par le serveur sont accessibles
|
||||
// via la propriété evt.detail.
|
||||
Edge.on('message', evt => console.log(evt.detail));
|
||||
Edge.Client.on('message', evt => console.log(evt.detail));
|
||||
|
||||
Edge.connect();
|
||||
Edge.Client.connect();
|
||||
```
|
||||
|
||||
**Côté serveur**
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# Module `rpc`
|
||||
|
||||
Ce module permet de déclarer des méthodes côté serveur qui seront "invoquable" côté client via la méthode [`Edge.rpc(method: string, params: Object): Promise`](../client-api/README.md#edgerpcmethod-string-params-object-promise).
|
||||
Ce module permet de déclarer des méthodes côté serveur qui seront "invoquable" côté client via la méthode [`Edge.Client.rpc(method: string, params: Object): Promise`](../client-api/README.md#edgerpcmethod-string-params-object-promise).
|
||||
|
||||
## Méthodes
|
||||
|
||||
|
@ -31,8 +31,8 @@ function echo(ctx, params) {
|
|||
**Côté client**
|
||||
|
||||
```js
|
||||
Edge.connect().then(() => {
|
||||
Edge.rpc("echo", { hello: "world!" })
|
||||
Edge.Client.connect().then(() => {
|
||||
Edge.Client.rpc("echo", { hello: "world!" })
|
||||
.then(result => console.log(result))
|
||||
.catch(err => console.error(err));
|
||||
});
|
||||
|
|
6
go.mod
|
@ -10,17 +10,21 @@ require (
|
|||
require (
|
||||
github.com/brutella/dnssd v1.2.6 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect
|
||||
github.com/go-playground/locales v0.12.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.16.0 // indirect
|
||||
github.com/goccy/go-json v0.9.11 // indirect
|
||||
github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/hashicorp/go.net v0.0.0-20151006203346-104dcad90073 // indirect
|
||||
github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8 // indirect
|
||||
github.com/leodido/go-urn v1.1.0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.4 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/option v1.0.0 // indirect
|
||||
github.com/miekg/dns v1.1.50 // indirect
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
|
@ -54,7 +58,7 @@ require (
|
|||
github.com/spf13/afero v1.9.3 // indirect
|
||||
github.com/urfave/cli/v2 v2.24.3
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9
|
||||
gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b
|
||||
go.opencensus.io v0.22.5 // indirect
|
||||
golang.org/x/crypto v0.7.0 // indirect
|
||||
golang.org/x/mod v0.8.0 // indirect
|
||||
|
|
6
go.sum
|
@ -109,7 +109,9 @@ github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITL
|
|||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
|
||||
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
|
||||
github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
|
||||
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
|
@ -210,6 +212,7 @@ github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NB
|
|||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8=
|
||||
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
|
||||
github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80=
|
||||
github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
|
@ -291,6 +294,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
|
|||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9 h1:6JlkcdjYVQglPWYuemK2MoZAtRE4vFx85zLXflGIyI8=
|
||||
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9/go.mod h1:3sus4zjoUv1GB7eDLL60QaPkUnXJCWBpjvbe0jWifeY=
|
||||
gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b h1:nkvOl8TCj/mErADnwFFynjxBtC+hHsrESw6rw56JGmg=
|
||||
gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b/go.mod h1:3sus4zjoUv1GB7eDLL60QaPkUnXJCWBpjvbe0jWifeY=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
|
@ -636,6 +641,7 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
|||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc=
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
|
|
|
@ -9,4 +9,4 @@ tags: ["test"]
|
|||
metadata:
|
||||
paths:
|
||||
icon: /icon.png
|
||||
minimumRole: visitor
|
||||
minimumRole: superadmin
|
|
@ -8,7 +8,10 @@
|
|||
<link rel="stylesheet" href="/vendor/mocha.css" />
|
||||
<style>
|
||||
body {
|
||||
background-color: white;
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
body:not([edge-auto-padding="false"]) #mocha-stats {
|
||||
top: 75px !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
@ -30,6 +33,17 @@
|
|||
<script src="/test/fetch-module.js"></script>
|
||||
<script class="mocha-exec">
|
||||
mocha.run();
|
||||
|
||||
Edge.Menu
|
||||
.setAppIconUrl('/icon.png')
|
||||
.setAppTitle('SDK Tests')
|
||||
.setItem('client', 'Client', { linkUrl: '/?grep=Edge', order: 1 })
|
||||
.setItem('auth-module', 'Auth Module', { linkUrl: '/?grep=Auth%20Module' , order: 4})
|
||||
.setItem('net-module', 'Net Module', { linkUrl: '/?grep=Net%20Module' , order: 3})
|
||||
.setItem('rpc', 'Remote Procedure Call', { linkUrl: '/?grep=Remote%20Procedure%20Call' , order: 5})
|
||||
.setItem('file-module', 'File Module', { linkUrl: '/?grep=File%20Module', order: 6})
|
||||
.setItem('app-module', 'App Module', { linkUrl: '/?grep=App%20Module' , order: 7})
|
||||
.setItem('fetch-module', 'Fetch Module', { linkUrl: '/?grep=Fetch%20Module' , order: 8})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,15 +1,15 @@
|
|||
describe('App Module', function() {
|
||||
|
||||
before(() => {
|
||||
return Edge.connect();
|
||||
return Edge.Client.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.disconnect();
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should list apps', function() {
|
||||
return Edge.rpc("listApps")
|
||||
return Edge.Client.rpc("listApps")
|
||||
.then(apps => {
|
||||
console.log("listApps result:", apps);
|
||||
chai.assert.isNotNull(apps);
|
||||
|
@ -18,7 +18,7 @@ describe('App Module', function() {
|
|||
});
|
||||
|
||||
it('should retrieve requested app', function() {
|
||||
return Edge.rpc("getApp", { appId: "edge.sdk.client.test" })
|
||||
return Edge.Client.rpc("getApp", { appId: "edge.sdk.client.test" })
|
||||
.then(app => {
|
||||
console.log("getApp result:", app);
|
||||
chai.assert.isNotNull(app);
|
||||
|
@ -27,7 +27,7 @@ describe('App Module', function() {
|
|||
});
|
||||
|
||||
it('should retrieve requested app url without from address', function() {
|
||||
return Edge.rpc("getAppUrl", { appId: "edge.sdk.client.test" })
|
||||
return Edge.Client.rpc("getAppUrl", { appId: "edge.sdk.client.test" })
|
||||
.then(url => {
|
||||
console.log("getAppUrl result:", url);
|
||||
chai.assert.isNotEmpty(url);
|
||||
|
@ -35,7 +35,7 @@ describe('App Module', function() {
|
|||
});
|
||||
|
||||
it('should retrieve requested app url with from address', function() {
|
||||
return Edge.rpc("getAppUrl", { appId: "edge.sdk.client.test", from: "127.0.0.2" })
|
||||
return Edge.Client.rpc("getAppUrl", { appId: "edge.sdk.client.test", from: "127.0.0.2" })
|
||||
.then(url => {
|
||||
console.log("getAppUrl result:", url);
|
||||
chai.assert.isNotEmpty(url);
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
describe('Auth Module', function() {
|
||||
|
||||
before(() => {
|
||||
return Edge.connect();
|
||||
return Edge.Client.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.disconnect();
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should retrieve user informations', function() {
|
||||
return Edge.rpc("getUserInfo")
|
||||
return Edge.Client.rpc("getUserInfo")
|
||||
.then(userInfo => {
|
||||
console.log("getUserInfo result:", userInfo);
|
||||
chai.assert.property(userInfo, 'subject');
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
Edge.debug = true;
|
||||
EdgeFrame.debug = true;
|
||||
Edge.Client.debug = true;
|
||||
Edge.Frame.debug = true;
|
||||
|
||||
describe('Edge', function() {
|
||||
|
||||
describe('#connect()', function() {
|
||||
after(() => {
|
||||
Edge.disconnect();
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should open the connection', function() {
|
||||
return Edge.connect()
|
||||
return Edge.Client.connect()
|
||||
.then(() => {
|
||||
chai.assert.isNotNull(Edge._conn);
|
||||
chai.assert.isNotNull(Edge.Client._conn);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#disconnect()', function() {
|
||||
it('should close the connection', function() {
|
||||
Edge.disconnect();
|
||||
chai.assert.isNull(Edge._conn);
|
||||
Edge.Client.disconnect();
|
||||
chai.assert.isNull(Edge.Client._conn);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
describe('Fetch Module', function () {
|
||||
|
||||
before(() => {
|
||||
return Edge.connect();
|
||||
return Edge.Client.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.disconnect();
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should fetch an authorized external url', function () {
|
||||
var externalUrl = Edge.externalUrl("http://example.com");
|
||||
var externalUrl = Edge.Client.externalUrl("http://example.com");
|
||||
|
||||
return fetch(externalUrl)
|
||||
.then(res => {
|
||||
|
@ -22,7 +22,7 @@ describe('Fetch Module', function () {
|
|||
});
|
||||
|
||||
it('should not fetch an unauthorized external url', function () {
|
||||
var externalUrl = Edge.externalUrl("https://google.com");
|
||||
var externalUrl = Edge.Client.externalUrl("https://google.com");
|
||||
|
||||
return fetch(externalUrl)
|
||||
.then(res => {
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
describe('File Module', function () {
|
||||
|
||||
before(() => {
|
||||
return Edge.connect();
|
||||
return Edge.Client.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.disconnect();
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should upload then download a blob', function () {
|
||||
const content = JSON.stringify({ "date": new Date() });
|
||||
const blob = new Blob([content], { type: "application/json" });
|
||||
|
||||
return Edge.upload(blob)
|
||||
return Edge.Client.upload(blob)
|
||||
.then(upload => upload.result())
|
||||
.then(result => {
|
||||
|
||||
chai.assert.isNotEmpty(result.blobId);
|
||||
chai.assert.isNotEmpty(result.bucket);
|
||||
|
||||
const blobUrl = Edge.blobUrl(result.bucket, result.blobId);
|
||||
const blobUrl = Edge.Client.blobUrl(result.bucket, result.blobId);
|
||||
chai.assert.isNotEmpty(blobUrl);
|
||||
|
||||
return fetch(blobUrl)
|
||||
|
|
|
@ -2,11 +2,11 @@ describe('Net Module', function () {
|
|||
this.timeout(5000);
|
||||
|
||||
before(() => {
|
||||
return Edge.connect();
|
||||
return Edge.Client.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.disconnect();
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should broadcast a message from server', function (done) {
|
||||
|
@ -18,12 +18,12 @@ describe('Net Module', function () {
|
|||
|
||||
chai.assert.deepEqual(message, evt.detail);
|
||||
|
||||
Edge.removeEventListener('message', handler);
|
||||
Edge.Client.removeEventListener('message', handler);
|
||||
done();
|
||||
};
|
||||
|
||||
Edge.addEventListener("message", handler);
|
||||
Edge.send(message);
|
||||
Edge.Client.addEventListener("message", handler);
|
||||
Edge.Client.send(message);
|
||||
});
|
||||
|
||||
it('should send a message to the server and echo back', function(done) {
|
||||
|
@ -35,15 +35,15 @@ describe('Net Module', function () {
|
|||
|
||||
chai.assert.equal(receivedMessage.now, now.toJSON());
|
||||
|
||||
Edge.removeEventListener('message', handler);
|
||||
Edge.Client.removeEventListener('message', handler);
|
||||
done();
|
||||
}
|
||||
|
||||
// Server should echo back message
|
||||
Edge.addEventListener('message', handler);
|
||||
Edge.Client.addEventListener('message', handler);
|
||||
|
||||
// Send message to server
|
||||
Edge.send({ test: 'echo', now });
|
||||
Edge.Client.send({ test: 'echo', now });
|
||||
});
|
||||
|
||||
});
|
|
@ -1,17 +1,17 @@
|
|||
describe('Remote Procedure Call', function () {
|
||||
|
||||
before(() => {
|
||||
return Edge.connect();
|
||||
return Edge.Client.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.disconnect();
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should call the remote echo() method and resolve the returned value', function () {
|
||||
const foo = "bar";
|
||||
|
||||
return Edge.rpc('echo', { foo })
|
||||
return Edge.Client.rpc('echo', { foo })
|
||||
.then(result => {
|
||||
console.log(result);
|
||||
chai.assert.equal(result.foo, foo);
|
||||
|
@ -19,7 +19,7 @@ describe('Remote Procedure Call', function () {
|
|||
});
|
||||
|
||||
it('should call the remote throwErrorFromClient() method and reject with an error', function () {
|
||||
return Edge.rpc('throwErrorFromClient')
|
||||
return Edge.Client.rpc('throwErrorFromClient')
|
||||
.catch(err => {
|
||||
// Assert that it's an "internal" error
|
||||
// See https://www.jsonrpc.org/specification#error_object
|
||||
|
@ -28,7 +28,7 @@ describe('Remote Procedure Call', function () {
|
|||
});
|
||||
|
||||
it('should call an unregistered method and reject with an error', function () {
|
||||
return Edge.rpc('unregisteredMethod')
|
||||
return Edge.Client.rpc('unregisteredMethod')
|
||||
.catch(err => {
|
||||
// Assert that it's an "method not found" error
|
||||
// See https://www.jsonrpc.org/specification#error_object
|
||||
|
@ -44,11 +44,11 @@ describe('Remote Procedure Call', function () {
|
|||
for (let i = 0; i <= 1000; i++) {
|
||||
values.push((Math.random() * 1000 | 0));
|
||||
}
|
||||
return Edge.rpc('reset')
|
||||
return Edge.Client.rpc('reset')
|
||||
.then(() => {
|
||||
return Promise.all(values.map(v => Edge.rpc("add", { value: v })));
|
||||
return Promise.all(values.map(v => Edge.Client.rpc("add", { value: v })));
|
||||
})
|
||||
.then(() => Edge.rpc('total'))
|
||||
.then(() => Edge.Client.rpc('total'))
|
||||
.then(remoteTotal => {
|
||||
const localTotal = values.reduce((t, v) => t + v);
|
||||
console.log("Remote total:", remoteTotal, "Local total:", localTotal);
|
||||
|
|
|
@ -6,8 +6,11 @@ misc/client-sdk-testsuite/src/**/*
|
|||
modd.conf
|
||||
{
|
||||
prep: make build-sdk
|
||||
prep: cd misc/client-sdk-testsuite && make dist
|
||||
prep: make build-client-sdk-test-app
|
||||
prep: make build
|
||||
prep: make GOTEST_ARGS="-short" test
|
||||
daemon: bin/cli app run -p misc/client-sdk-testsuite/dist
|
||||
daemon: make run-app
|
||||
}
|
||||
|
||||
**/*.go {
|
||||
prep: make GOTEST_ARGS="-short" test
|
||||
}
|
|
@ -10,14 +10,50 @@
|
|||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/sockjs-client": "^1.5.1",
|
||||
"@webcomponents/webcomponentsjs": "^2.8.0",
|
||||
"core-js": "^3.30.1",
|
||||
"lit": "^2.7.2",
|
||||
"sockjs-client": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@lit-labs/ssr-dom-shim": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.0.tgz",
|
||||
"integrity": "sha512-92uQ5ARf7UXYrzaFcAX3T2rTvaS9Z1//ukV+DqjACM4c8s0ZBQd7ayJU5Dh2AFLD/Ayuyz4uMmxQec8q3U4Ong=="
|
||||
},
|
||||
"node_modules/@lit/reactive-element": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.1.tgz",
|
||||
"integrity": "sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA==",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sockjs-client": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.1.tgz",
|
||||
"integrity": "sha512-bmZM6A1GPdjF0bcuIUC+50hZEMGkzMsiG9by6X9U+7IZFOiPtz7MJ9h05FSpPVxlj4i+TzzoG3ESo1FJlbLb6A=="
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz",
|
||||
"integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g=="
|
||||
},
|
||||
"node_modules/@webcomponents/webcomponentsjs": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz",
|
||||
"integrity": "sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w=="
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.30.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.30.1.tgz",
|
||||
"integrity": "sha512-ZNS5nbiSwDTq4hFosEDqm65izl2CWmLz0hARJMyNQBgkUZMIF51cQiMvIQKA6hvuaeWxQDP3hEedM1JZIgTldQ==",
|
||||
"hasInstallScript": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
|
@ -55,6 +91,34 @@
|
|||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/lit": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-2.7.2.tgz",
|
||||
"integrity": "sha512-9QnZmG5mIKPRja96cpndMclLSi0Qrz2BXD6EbqNqCKMMjOWVm/BwAeXufFk2jqFsNmY07HOzU8X+8aTSVt3yrA==",
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^1.6.0",
|
||||
"lit-element": "^3.3.0",
|
||||
"lit-html": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lit-element": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.1.tgz",
|
||||
"integrity": "sha512-Gl+2409uXWbf7n6cCl7Kzasm7zjT9xmdwi2BhLNi70sRKAgRkqueDu5mSIH3hPYMM0/vqBCdPXod3NbGkRA2ww==",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.1.0",
|
||||
"@lit/reactive-element": "^1.3.0",
|
||||
"lit-html": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lit-html": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.7.2.tgz",
|
||||
"integrity": "sha512-ZJCfKlA2XELu5tn7XuzOziGFGvf1SeQm+ngLWoJ8bXtSkRrrR3ms6SWy+gsdxeYwySLij5xAhdd2C3EX0ftxdQ==",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
@ -139,11 +203,39 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.0.tgz",
|
||||
"integrity": "sha512-92uQ5ARf7UXYrzaFcAX3T2rTvaS9Z1//ukV+DqjACM4c8s0ZBQd7ayJU5Dh2AFLD/Ayuyz4uMmxQec8q3U4Ong=="
|
||||
},
|
||||
"@lit/reactive-element": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.1.tgz",
|
||||
"integrity": "sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA==",
|
||||
"requires": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"@types/sockjs-client": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.1.tgz",
|
||||
"integrity": "sha512-bmZM6A1GPdjF0bcuIUC+50hZEMGkzMsiG9by6X9U+7IZFOiPtz7MJ9h05FSpPVxlj4i+TzzoG3ESo1FJlbLb6A=="
|
||||
},
|
||||
"@types/trusted-types": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz",
|
||||
"integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g=="
|
||||
},
|
||||
"@webcomponents/webcomponentsjs": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz",
|
||||
"integrity": "sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w=="
|
||||
},
|
||||
"core-js": {
|
||||
"version": "3.30.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.30.1.tgz",
|
||||
"integrity": "sha512-ZNS5nbiSwDTq4hFosEDqm65izl2CWmLz0hARJMyNQBgkUZMIF51cQiMvIQKA6hvuaeWxQDP3hEedM1JZIgTldQ=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
|
@ -175,6 +267,34 @@
|
|||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"lit": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-2.7.2.tgz",
|
||||
"integrity": "sha512-9QnZmG5mIKPRja96cpndMclLSi0Qrz2BXD6EbqNqCKMMjOWVm/BwAeXufFk2jqFsNmY07HOzU8X+8aTSVt3yrA==",
|
||||
"requires": {
|
||||
"@lit/reactive-element": "^1.6.0",
|
||||
"lit-element": "^3.3.0",
|
||||
"lit-html": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"lit-element": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.1.tgz",
|
||||
"integrity": "sha512-Gl+2409uXWbf7n6cCl7Kzasm7zjT9xmdwi2BhLNi70sRKAgRkqueDu5mSIH3hPYMM0/vqBCdPXod3NbGkRA2ww==",
|
||||
"requires": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.1.0",
|
||||
"@lit/reactive-element": "^1.3.0",
|
||||
"lit-html": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"lit-html": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.7.2.tgz",
|
||||
"integrity": "sha512-ZJCfKlA2XELu5tn7XuzOziGFGvf1SeQm+ngLWoJ8bXtSkRrrR3ms6SWy+gsdxeYwySLij5xAhdd2C3EX0ftxdQ==",
|
||||
"requires": {
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
|
@ -10,6 +10,9 @@
|
|||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/sockjs-client": "^1.5.1",
|
||||
"@webcomponents/webcomponentsjs": "^2.8.0",
|
||||
"core-js": "^3.30.1",
|
||||
"lit": "^2.7.2",
|
||||
"sockjs-client": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -103,13 +103,19 @@ func NewHandler(funcs ...HandlerOptionFunc) *Handler {
|
|||
r.Get("/client.js.map", handler.handleSDKClientMap)
|
||||
})
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Post("/upload", handler.handleAppUpload)
|
||||
r.Get("/download/{bucket}/{blobID}", handler.handleAppDownload)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Post("/v1/upload", handler.handleAppUpload)
|
||||
r.Get("/v1/download/{bucket}/{blobID}", handler.handleAppDownload)
|
||||
|
||||
r.Get("/fetch", handler.handleAppFetch)
|
||||
r.Get("/v1/fetch", handler.handleAppFetch)
|
||||
})
|
||||
|
||||
for _, fn := range opts.HTTPMounts {
|
||||
r.Group(func(r chi.Router) {
|
||||
fn(r)
|
||||
})
|
||||
}
|
||||
|
||||
r.HandleFunc("/sock/*", handler.handleSockJS)
|
||||
})
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus/memory"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/igm/sockjs-go/v3/sockjs"
|
||||
)
|
||||
|
||||
|
@ -16,6 +17,7 @@ type HandlerOptions struct {
|
|||
ServerModuleFactories []app.ServerModuleFactory
|
||||
UploadMaxFileSize int64
|
||||
HTTPClient *http.Client
|
||||
HTTPMounts []func(r chi.Router)
|
||||
}
|
||||
|
||||
func defaultHandlerOptions() *HandlerOptions {
|
||||
|
@ -32,6 +34,7 @@ func defaultHandlerOptions() *HandlerOptions {
|
|||
HTTPClient: &http.Client{
|
||||
Timeout: time.Second * 30,
|
||||
},
|
||||
HTTPMounts: make([]func(r chi.Router), 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -66,3 +69,9 @@ func WithHTTPClient(client *http.Client) HandlerOptionFunc {
|
|||
opts.HTTPClient = client
|
||||
}
|
||||
}
|
||||
|
||||
func WithHTTPMounts(mounts ...func(r chi.Router)) HandlerOptionFunc {
|
||||
return func(opts *HandlerOptions) {
|
||||
opts.HTTPMounts = mounts
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,6 +44,10 @@ func (r *Repository) List(ctx context.Context) ([]*app.Manifest, error) {
|
|||
}
|
||||
|
||||
func NewRepository(getURL GetURLFunc, manifests ...*app.Manifest) *Repository {
|
||||
if manifests == nil {
|
||||
manifests = make([]*app.Manifest, 0)
|
||||
}
|
||||
|
||||
return &Repository{getURL, manifests}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type MountFunc func(r chi.Router)
|
||||
|
||||
type Handler struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func (h *Handler) serveApps(w http.ResponseWriter, r *http.Request) {
|
||||
manifests, err := h.repo.List(r.Context())
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not retrieve app manifest", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Manifests []*app.Manifest `json:"manifests"`
|
||||
}{
|
||||
Manifests: manifests,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) serveApp(w http.ResponseWriter, r *http.Request) {
|
||||
appID := app.ID(chi.URLParam(r, "appID"))
|
||||
|
||||
manifest, err := h.repo.Get(r.Context(), appID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(r.Context(), "could not retrieve app manifest", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Manifest *app.Manifest `json:"manifest"`
|
||||
}{
|
||||
Manifest: manifest,
|
||||
})
|
||||
}
|
||||
|
||||
type serveAppURLRequest struct {
|
||||
From string `json:"from,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) serveAppURL(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
req := &serveAppURLRequest{}
|
||||
if ok := api.Bind(w, r, req); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
appID := app.ID(chi.URLParam(r, "appID"))
|
||||
|
||||
from := req.From
|
||||
if from == "" {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "could not split remote address", logger.E(errors.WithStack(err)))
|
||||
} else {
|
||||
from = host
|
||||
}
|
||||
}
|
||||
|
||||
url, err := h.repo.GetURL(ctx, appID, from)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(r.Context(), "could not retrieve app url", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
URL string `json:"url"`
|
||||
}{
|
||||
URL: url,
|
||||
})
|
||||
}
|
||||
|
||||
func Mount(repository Repository) MountFunc {
|
||||
handler := &Handler{repository}
|
||||
return func(r chi.Router) {
|
||||
r.Get("/api/v1/apps", handler.serveApps)
|
||||
r.Get("/api/v1/apps/{appID}", handler.serveApp)
|
||||
r.Post("/api/v1/apps/{appID}/url", handler.serveAppURL)
|
||||
}
|
||||
}
|
|
@ -9,5 +9,5 @@ import (
|
|||
type Repository interface {
|
||||
List(context.Context) ([]*app.Manifest, error)
|
||||
Get(context.Context, app.ID) (*app.Manifest, error)
|
||||
GetURL(context.Context, app.ID, string) (string, error)
|
||||
GetURL(ctx context.Context, id app.ID, from string) (string, error)
|
||||
}
|
||||
|
|
|
@ -2,7 +2,4 @@ package auth
|
|||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
ErrClaimNotFound = errors.New("claim not found")
|
||||
)
|
||||
var ErrUnauthenticated = errors.New("unauthenticated")
|
||||
|
|
|
@ -110,6 +110,8 @@ func (h *LocalHandler) handleForm(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
account.Claims[auth.ClaimIssuer] = "local"
|
||||
|
||||
token, err := generateSignedToken(h.algo, h.key, account.Claims)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not generate signed token", logger.E(errors.WithStack(err)))
|
||||
|
|
|
@ -91,7 +91,7 @@
|
|||
<form method="post" action="{{ .URL }}">
|
||||
<div class="form-control">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" value="{{ .Username }}" required />
|
||||
<input type="text" id="username" name="username" value="{{ .Username }}" required autofocus />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="password">Password</label>
|
||||
|
|
|
@ -19,10 +19,10 @@ type GetKeySetFunc func() (jwk.Set, error)
|
|||
|
||||
func WithJWT(getKeySet GetKeySetFunc) OptionFunc {
|
||||
return func(o *Option) {
|
||||
o.GetClaim = func(ctx context.Context, r *http.Request, claimName string) (string, error) {
|
||||
claim, err := getClaim[string](r, claimName, getKeySet)
|
||||
o.GetClaims = func(ctx context.Context, r *http.Request, names ...string) ([]string, error) {
|
||||
claim, err := getClaims[string](r, getKeySet, names...)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return claim, nil
|
||||
|
@ -76,28 +76,34 @@ func FindToken(r *http.Request, getKeySet GetKeySetFunc) (jwt.Token, error) {
|
|||
return token, nil
|
||||
}
|
||||
|
||||
func getClaim[T any](r *http.Request, claimAttr string, getKeySet GetKeySetFunc) (T, error) {
|
||||
func getClaims[T any](r *http.Request, getKeySet GetKeySetFunc, names ...string) ([]T, error) {
|
||||
token, err := FindToken(r, getKeySet)
|
||||
if err != nil {
|
||||
return *new(T), errors.WithStack(err)
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
mapClaims, err := token.AsMap(ctx)
|
||||
if err != nil {
|
||||
return *new(T), errors.WithStack(err)
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
rawClaim, exists := mapClaims[claimAttr]
|
||||
if !exists {
|
||||
return *new(T), errors.WithStack(ErrClaimNotFound)
|
||||
claims := make([]T, len(names))
|
||||
|
||||
for idx, n := range names {
|
||||
rawClaim, exists := mapClaims[n]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
claim, ok := rawClaim.(T)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("unexpected claim '%s' to be of type '%T', got '%T'", n, new(T), rawClaim)
|
||||
}
|
||||
|
||||
claims[idx] = claim
|
||||
}
|
||||
|
||||
claim, ok := rawClaim.(T)
|
||||
if !ok {
|
||||
return *new(T), errors.Errorf("unexpected claim '%s' to be of type '%T', got '%T'", claimAttr, new(T), rawClaim)
|
||||
}
|
||||
|
||||
return claim, nil
|
||||
return claims, nil
|
||||
}
|
||||
|
|
|
@ -8,15 +8,21 @@ import (
|
|||
"forge.cadoles.com/arcad/edge/pkg/module/util"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
ClaimSubject = "sub"
|
||||
ClaimSubject = "sub"
|
||||
ClaimIssuer = "iss"
|
||||
ClaimPreferredUsername = "preferred_username"
|
||||
ClaimEdgeRole = "edge_role"
|
||||
ClaimEdgeTenant = "edge_tenant"
|
||||
ClaimEdgeEntrypoint = "edge_entrypoint"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
server *app.Server
|
||||
getClaimFunc GetClaimFunc
|
||||
server *app.Server
|
||||
getClaims GetClaimsFunc
|
||||
}
|
||||
|
||||
func (m *Module) Name() string {
|
||||
|
@ -31,6 +37,22 @@ func (m *Module) Export(export *goja.Object) {
|
|||
if err := export.Set("CLAIM_SUBJECT", ClaimSubject); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_SUBJECT' property"))
|
||||
}
|
||||
|
||||
if err := export.Set("CLAIM_TENANT", ClaimEdgeTenant); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_TENANT' property"))
|
||||
}
|
||||
|
||||
if err := export.Set("CLAIM_ENTRYPOINT", ClaimEdgeEntrypoint); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_ENTRYPOINT' property"))
|
||||
}
|
||||
|
||||
if err := export.Set("CLAIM_ROLE", ClaimEdgeRole); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_ROLE' property"))
|
||||
}
|
||||
|
||||
if err := export.Set("CLAIM_PREFERRED_USERNAME", ClaimPreferredUsername); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_PREFERRED_USERNAME' property"))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) getClaim(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
|
@ -42,16 +64,21 @@ func (m *Module) getClaim(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
|||
panic(rt.ToValue(errors.New("could not find http request in context")))
|
||||
}
|
||||
|
||||
claim, err := m.getClaimFunc(ctx, req, claimName)
|
||||
claim, err := m.getClaims(ctx, req, claimName)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnauthenticated) || errors.Is(err, ErrClaimNotFound) {
|
||||
if errors.Is(err, ErrUnauthenticated) {
|
||||
return nil
|
||||
}
|
||||
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
logger.Error(ctx, "could not retrieve claim", logger.E(errors.WithStack(err)))
|
||||
return nil
|
||||
}
|
||||
|
||||
return rt.ToValue(claim)
|
||||
if len(claim) == 0 || claim[0] == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return rt.ToValue(claim[0])
|
||||
}
|
||||
|
||||
func ModuleFactory(funcs ...OptionFunc) app.ServerModuleFactory {
|
||||
|
@ -62,8 +89,8 @@ func ModuleFactory(funcs ...OptionFunc) app.ServerModuleFactory {
|
|||
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
return &Module{
|
||||
server: server,
|
||||
getClaimFunc: opt.GetClaim,
|
||||
server: server,
|
||||
getClaims: opt.GetClaims,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type MountFunc func(r chi.Router)
|
||||
|
||||
type Handler struct {
|
||||
getClaims GetClaimsFunc
|
||||
profileClaims []string
|
||||
}
|
||||
|
||||
func (h *Handler) serveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
claims, err := h.getClaims(ctx, r, h.profileClaims...)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnauthenticated) {
|
||||
api.ErrorResponse(
|
||||
w, http.StatusUnauthorized,
|
||||
api.ErrCodeUnauthorized,
|
||||
nil,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not retrieve claims", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(
|
||||
w, http.StatusInternalServerError,
|
||||
api.ErrCodeUnknownError,
|
||||
nil,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
profile := make(map[string]any)
|
||||
|
||||
for idx, cl := range h.profileClaims {
|
||||
profile[cl] = claims[idx]
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Profile map[string]any `json:"profile"`
|
||||
}{
|
||||
Profile: profile,
|
||||
})
|
||||
}
|
||||
|
||||
func Mount(authHandler http.Handler, funcs ...OptionFunc) MountFunc {
|
||||
opt := defaultOptions()
|
||||
for _, fn := range funcs {
|
||||
fn(opt)
|
||||
}
|
||||
|
||||
handler := &Handler{
|
||||
profileClaims: opt.ProfileClaims,
|
||||
getClaims: opt.GetClaims,
|
||||
}
|
||||
|
||||
return func(r chi.Router) {
|
||||
r.Get("/api/v1/profile", handler.serveProfile)
|
||||
r.Handle("/auth/*", authHandler)
|
||||
}
|
||||
}
|
|
@ -7,26 +7,41 @@ import (
|
|||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type GetClaimFunc func(ctx context.Context, r *http.Request, claimName string) (string, error)
|
||||
type GetClaimsFunc func(ctx context.Context, r *http.Request, claims ...string) ([]string, error)
|
||||
|
||||
type Option struct {
|
||||
GetClaim GetClaimFunc
|
||||
GetClaims GetClaimsFunc
|
||||
ProfileClaims []string
|
||||
}
|
||||
|
||||
type OptionFunc func(*Option)
|
||||
|
||||
func defaultOptions() *Option {
|
||||
return &Option{
|
||||
GetClaim: dummyGetClaim,
|
||||
GetClaims: dummyGetClaims,
|
||||
ProfileClaims: []string{
|
||||
ClaimSubject,
|
||||
ClaimIssuer,
|
||||
ClaimEdgeEntrypoint,
|
||||
ClaimEdgeRole,
|
||||
ClaimPreferredUsername,
|
||||
ClaimEdgeTenant,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dummyGetClaim(ctx context.Context, r *http.Request, claimName string) (string, error) {
|
||||
return "", errors.Errorf("dummy getclaim func cannot retrieve claim '%s'", claimName)
|
||||
func dummyGetClaims(ctx context.Context, r *http.Request, claims ...string) ([]string, error) {
|
||||
return nil, errors.Errorf("dummy getclaim func cannot retrieve claims '%s'", claims)
|
||||
}
|
||||
|
||||
func WithGetClaim(fn GetClaimFunc) OptionFunc {
|
||||
func WithGetClaims(fn GetClaimsFunc) OptionFunc {
|
||||
return func(o *Option) {
|
||||
o.GetClaim = fn
|
||||
o.GetClaims = fn
|
||||
}
|
||||
}
|
||||
|
||||
func WithProfileClaims(claims ...string) OptionFunc {
|
||||
return func(o *Option) {
|
||||
o.ProfileClaims = claims
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 450 B |
|
@ -0,0 +1,9 @@
|
|||
import UserCircleIcon from './user-circle.svg';
|
||||
import MenuIcon from './menu.svg';
|
||||
import CloudIcon from './cloud.svg';
|
||||
import LoginIcon from './login.svg';
|
||||
import HomeIcon from './home.svg';
|
||||
import LinkIcon from './link.svg';
|
||||
import LogoutIcon from './logout.svg';
|
||||
|
||||
export { UserCircleIcon, MenuIcon, CloudIcon, LoginIcon, HomeIcon, LinkIcon, LogoutIcon }
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
After Width: | Height: | Size: 376 B |
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 356 B |
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 362 B |
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
After Width: | Height: | Size: 687 B |
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 405 B |
|
@ -0,0 +1,130 @@
|
|||
import { LitElement, html, css } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
|
||||
export const EVENT_MENU_ITEM_SELECTED = 'menu-item-selected';
|
||||
export const EVENT_MENU_ITEM_UNSELECTED = 'menu-item-unselected';
|
||||
|
||||
export class MenuItem extends LitElement {
|
||||
@property({ attribute: 'icon-url', type: String })
|
||||
iconUrl: string;
|
||||
|
||||
@property({ attribute: 'label', type: String })
|
||||
label: string;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid rgb(229,231,235);
|
||||
border-top: 10px solid transparent;
|
||||
transition: all 100ms ease-out;
|
||||
background-color: #fff;
|
||||
}
|
||||
:host(:hover) {
|
||||
background-color: rgb(249,250,251);
|
||||
}
|
||||
:host(.selected) {
|
||||
border-top: 10px solid #03A9F4;
|
||||
border-bottom: 1px solid transparent;
|
||||
background-color: #fff;
|
||||
}
|
||||
:host(.unselected) {
|
||||
background-color: hsl(210 20% 95% / 1);
|
||||
}
|
||||
.menu-item-icon {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.menu-item-icon > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.menu-item-panel {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 65px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
background-color: #fff;
|
||||
box-shadow: 0px 4px 5px 0px hsl(0deg 0% 0% / 10%);
|
||||
max-height: 75%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
:host(.selected) .menu-item-panel {
|
||||
display: block;
|
||||
}
|
||||
.menu-item-label {
|
||||
font-family: Arial, Helvetica Neue, Helvetica, sans-serif;
|
||||
color: black;
|
||||
font-size: 14px;
|
||||
margin: 3px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@state()
|
||||
selected: boolean
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener('click', this._handleClick.bind(this));
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="menu-item-icon">
|
||||
${
|
||||
this.iconUrl ?
|
||||
html`<img src="${this.iconUrl}" />` :
|
||||
''
|
||||
}
|
||||
</div>
|
||||
<div class="menu-item-label">
|
||||
${this.label}
|
||||
</div>
|
||||
<div class="menu-item-panel">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
_handleClick() {
|
||||
if (this.selected) {
|
||||
this.unselect();
|
||||
} else {
|
||||
this.select();
|
||||
}
|
||||
}
|
||||
|
||||
select() {
|
||||
this.selected = true;
|
||||
const event = new CustomEvent(EVENT_MENU_ITEM_SELECTED, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
element: this,
|
||||
}
|
||||
});
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
unselect() {
|
||||
this.selected = false;
|
||||
const event = new CustomEvent(EVENT_MENU_ITEM_UNSELECTED, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
element: this,
|
||||
}
|
||||
});
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import { LitElement, html, css } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { LinkIcon } from './icons';
|
||||
|
||||
export class MenuSubItem extends LitElement {
|
||||
@property({ attribute: 'label' })
|
||||
label: string;
|
||||
|
||||
@property({ attribute: 'icon-url' })
|
||||
iconUrl: string;
|
||||
|
||||
@property({ attribute: 'link-url' })
|
||||
linkUrl: string;
|
||||
|
||||
@property({ attribute: 'inactive', type: Boolean })
|
||||
inactive: boolean;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
transition: all 100ms ease-out;
|
||||
border-bottom: 1px solid rgb(229,231,235);
|
||||
padding: 5px 0 5px 7px;
|
||||
border-left: 5px solid transparent;
|
||||
}
|
||||
:host([inactive]) {
|
||||
cursor: initial;
|
||||
}
|
||||
:host(:hover) {
|
||||
border-left: 5px solid #03A9F4;
|
||||
background-color: rgb(28 169 247 / 10%);
|
||||
}
|
||||
a {
|
||||
font-size: 20px;
|
||||
font-family: Arial, Helvetica Neue, Helvetica, sans-serif;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: 40px;
|
||||
color: black;
|
||||
}
|
||||
.edge-menu-sub-item-icon {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
}
|
||||
.edge-menu-sub-item-label {
|
||||
margin-left: 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<a href="${this.linkUrl ? this.linkUrl : '#'}">
|
||||
<img class="edge-menu-sub-item-icon" src="${this.iconUrl ? this.iconUrl : LinkIcon}" />
|
||||
<span class="edge-menu-sub-item-label">${this.label}</span>
|
||||
</a>
|
||||
`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,239 @@
|
|||
import { LitElement, html, css } from 'lit';
|
||||
import { property, queryAll } from 'lit/decorators.js';
|
||||
import { CloudIcon, HomeIcon, LoginIcon, LinkIcon, MenuIcon, UserCircleIcon, LogoutIcon } from './icons'
|
||||
import { EVENT_MENU_ITEM_SELECTED, EVENT_MENU_ITEM_UNSELECTED, MenuItem } from './menu-item';
|
||||
import { MenuSubItem } from './menu-sub-item';
|
||||
|
||||
interface Manifest {
|
||||
id: string
|
||||
description: string
|
||||
metadata: { [key: string]: any }
|
||||
tags: string[]
|
||||
title: string
|
||||
version: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
sub?: string
|
||||
preferred_username?: string
|
||||
iss?: string
|
||||
edge_role?: string
|
||||
edge_tenant?: string
|
||||
edge_entrypoint?: string
|
||||
}
|
||||
|
||||
const BASE_API_URL = '/edge/api/v1';
|
||||
|
||||
enum Roles {
|
||||
visitor = 0,
|
||||
user = 1,
|
||||
superuser = 2,
|
||||
admin = 3,
|
||||
superadmin = 4
|
||||
}
|
||||
|
||||
export class Menu extends LitElement {
|
||||
@property({ attribute: 'app-icon-url', type: String })
|
||||
appIconUrl: string;
|
||||
|
||||
@property({ attribute: 'app-title', type: String })
|
||||
appTitle: string;
|
||||
|
||||
@property({ attribute: 'hidden', type: Boolean })
|
||||
hidden: boolean;
|
||||
|
||||
@property()
|
||||
_apps: Manifest[] = []
|
||||
|
||||
@property()
|
||||
_profile: Profile
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@queryAll('edge-menu-item')
|
||||
_menuItems: NodeListOf<MenuItem>
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener(EVENT_MENU_ITEM_SELECTED, this._handleMenuItemSelected.bind(this));
|
||||
this.addEventListener(EVENT_MENU_ITEM_UNSELECTED, this._handleMenuItemUnselected.bind(this));
|
||||
|
||||
this._fetchApps();
|
||||
this._fetchProfile();
|
||||
}
|
||||
|
||||
render() {
|
||||
const apps = this._renderApps()
|
||||
|
||||
return html`
|
||||
<edge-menu-item name='menu' label="${ this.appTitle || "App" }" icon-url='${ this.appIconUrl || MenuIcon }'>
|
||||
<edge-menu-sub-item name='home' label='Home' icon-url='${HomeIcon}' link-url='/'></edge-menu-sub-item>
|
||||
<slot></slot>
|
||||
</edge-menu-item>
|
||||
${ this._renderApps() }
|
||||
${ this._renderProfile() }
|
||||
`;
|
||||
}
|
||||
|
||||
_fetchApps() {
|
||||
return fetch(`${BASE_API_URL}/apps`)
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
throw new Error(`Unexpected server error: ${result.error.code}`);
|
||||
}
|
||||
|
||||
return result.data?.manifests || [];
|
||||
})
|
||||
.then((manifests: Manifest[]) => {
|
||||
const promises = manifests.map((m: Manifest) => {
|
||||
const fetchOptions: RequestInit = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
};
|
||||
return fetch(`${BASE_API_URL}/apps/${m.id}/url`, fetchOptions)
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
throw new Error(`Unexpected server error: ${result.error.code}`);
|
||||
}
|
||||
|
||||
m.url = result.data?.url;
|
||||
|
||||
return m;
|
||||
})
|
||||
;
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
})
|
||||
.then((manifests: Manifest[]) => {
|
||||
this._apps = manifests;
|
||||
})
|
||||
.catch(err => console.error(err))
|
||||
}
|
||||
|
||||
_fetchProfile() {
|
||||
return fetch(`${BASE_API_URL}/profile`)
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
switch (result.error.code) {
|
||||
case "unauthorized":
|
||||
return null;
|
||||
default:
|
||||
throw new Error(`Unexpected server error: ${result.error.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result.data?.profile;
|
||||
})
|
||||
.then(profile => {
|
||||
this._profile = profile;
|
||||
})
|
||||
.catch(err => console.error(err))
|
||||
;
|
||||
}
|
||||
|
||||
_renderApps() {
|
||||
const apps = this._apps
|
||||
.filter(manifest => this._canAccess(manifest))
|
||||
.map(manifest => {
|
||||
const iconUrl = ( ( manifest.url || '') + ( manifest.metadata?.paths?.icon || '' ) ) || LinkIcon;
|
||||
return html`
|
||||
<edge-menu-sub-item
|
||||
name='${ manifest.id }'
|
||||
label='${ manifest.title }'
|
||||
icon-url='${ iconUrl }'
|
||||
link-url='${ manifest.url || '#' }'>
|
||||
</edge-menu-sub-item>
|
||||
`
|
||||
});
|
||||
|
||||
return html`
|
||||
<edge-menu-item name='apps' label='Apps' icon-url='${CloudIcon}'>
|
||||
${ apps }
|
||||
</edge-menu-item>
|
||||
`;
|
||||
}
|
||||
|
||||
_canAccess(manifest: Manifest): boolean {
|
||||
const currentRole = this._profile?.edge_role || 'visitor';
|
||||
const minimumRole = manifest.metadata?.minimumRole || 'visitor';
|
||||
|
||||
return Roles[currentRole] >= Roles[minimumRole];
|
||||
}
|
||||
|
||||
_renderProfile() {
|
||||
const profile = this._profile;
|
||||
return html`
|
||||
<edge-menu-item name='profile' label="${profile?.preferred_username || 'Profile'}" icon-url='${UserCircleIcon}'>
|
||||
${
|
||||
profile ?
|
||||
html`<edge-menu-sub-item name='login' label='Logout' icon-url='${LogoutIcon}' link-url='/edge/auth/logout'></edge-menu-sub-item>` :
|
||||
html`<edge-menu-sub-item name='login' label='Login' icon-url='${LoginIcon}' link-url='/edge/auth/login'></edge-menu-sub-item>`
|
||||
}
|
||||
</edge-menu-item>
|
||||
`;
|
||||
}
|
||||
|
||||
_handleMenuItemSelected(evt: CustomEvent) {
|
||||
const selectedMenuItem: HTMLElement = evt.detail.element;
|
||||
|
||||
selectedMenuItem.classList.add('selected');
|
||||
selectedMenuItem.classList.remove('unselected');
|
||||
|
||||
for (let item, i = 0; (item = this._menuItems[i]); i++) {
|
||||
if (item === selectedMenuItem) continue;
|
||||
|
||||
item.unselect();
|
||||
item.classList.add('unselected');
|
||||
}
|
||||
}
|
||||
|
||||
_handleMenuItemUnselected(evt: CustomEvent) {
|
||||
const unselectedMenuItem: HTMLElement = evt.detail.element;
|
||||
|
||||
unselectedMenuItem.classList.remove('selected');
|
||||
|
||||
const hasSelectedItem = this.renderRoot.querySelectorAll('edge-menu-item.selected').length !== 0
|
||||
|
||||
if (hasSelectedItem) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let item, i = 0; (item = this._menuItems[i]); i++) {
|
||||
item.classList.remove('unselected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"edge-menu": Menu;
|
||||
"edge-menu-item": MenuItem;
|
||||
"edge-menu-sub-item": MenuSubItem;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
|
@ -1,5 +1,16 @@
|
|||
import { Client } from './client.js';
|
||||
import { CrossFrameMessenger } from './crossframe-messenger.js';
|
||||
import './polyfill';
|
||||
|
||||
export const client = new Client();
|
||||
export const crossFrameMessenger = new CrossFrameMessenger();
|
||||
import { Client as EdgeClient } from './client.js';
|
||||
import { Menu as MenuElement } from './components/menu.js';
|
||||
import { MenuItem as MenuItemElement } from './components/menu-item.js';
|
||||
import { MenuSubItem as MenuSubItemElement } from './components/menu-sub-item.js';
|
||||
import { CrossFrameMessenger } from './crossframe-messenger.js';
|
||||
import { MenuManager } from './menu-manager.js';
|
||||
|
||||
customElements.define('edge-menu', MenuElement);
|
||||
customElements.define('edge-menu-item', MenuItemElement);
|
||||
customElements.define('edge-menu-sub-item', MenuSubItemElement);
|
||||
|
||||
export const Client = new EdgeClient();
|
||||
export const Frame = new CrossFrameMessenger();
|
||||
export const Menu = new MenuManager();
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
import { Menu } from "./components/menu"
|
||||
|
||||
export interface MenuItem {
|
||||
label: string
|
||||
iconUrl: string
|
||||
linkUrl: string
|
||||
order: number
|
||||
}
|
||||
|
||||
const EdgeBodyAutoPaddingAttrName = 'edge-auto-padding'
|
||||
|
||||
export class MenuManager {
|
||||
|
||||
_items: { [name:string]: MenuItem }
|
||||
_menu: Menu
|
||||
_appIconUrl: string
|
||||
_appTitle: string
|
||||
_hidden: boolean
|
||||
_previousBodyAutoPadding: string
|
||||
|
||||
constructor() {
|
||||
this._items = {};
|
||||
|
||||
this._handleLoad = this._handleLoad.bind(this);
|
||||
|
||||
window.addEventListener('load', this._handleLoad);
|
||||
}
|
||||
|
||||
setItem(name: string, label:string, options?: { iconUrl?: string, linkUrl?: string, order?: number }) {
|
||||
this._items[name] = {
|
||||
label: label,
|
||||
iconUrl: options?.iconUrl ? options?.iconUrl : '',
|
||||
linkUrl: options?.linkUrl ? options?.linkUrl : '#',
|
||||
order: options?.order ? options?.order : 0,
|
||||
}
|
||||
this._render();
|
||||
return this;
|
||||
}
|
||||
|
||||
removeItem(name: string) {
|
||||
delete this._items[name];
|
||||
this._render();
|
||||
return this;
|
||||
}
|
||||
|
||||
setAppIconUrl(url: string) {
|
||||
this._appIconUrl = url;
|
||||
this._render();
|
||||
return this;
|
||||
}
|
||||
|
||||
setAppTitle(title: string) {
|
||||
this._appTitle = title;
|
||||
this._render();
|
||||
return this;
|
||||
}
|
||||
|
||||
show() {
|
||||
if (!this._hidden) return;
|
||||
|
||||
this._hidden = false;
|
||||
if (this._previousBodyAutoPadding) {
|
||||
document.body.setAttribute(EdgeBodyAutoPaddingAttrName, this._previousBodyAutoPadding);
|
||||
} else {
|
||||
document.body.removeAttribute(EdgeBodyAutoPaddingAttrName);
|
||||
}
|
||||
this._render();
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this._hidden) return;
|
||||
|
||||
this._hidden = true;
|
||||
this._previousBodyAutoPadding = document.body.getAttribute(EdgeBodyAutoPaddingAttrName);
|
||||
document.body.setAttribute(EdgeBodyAutoPaddingAttrName, "false");
|
||||
this._render();
|
||||
}
|
||||
|
||||
_handleLoad() {
|
||||
this._init();
|
||||
}
|
||||
|
||||
_init() {
|
||||
this._initMenu();
|
||||
this._initGlobalStyle();
|
||||
}
|
||||
|
||||
_initMenu() {
|
||||
const menu = document.createElement('edge-menu');
|
||||
document.body.appendChild(menu);
|
||||
this._menu = menu;
|
||||
this._render();
|
||||
}
|
||||
|
||||
_initGlobalStyle() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
body:not([${EdgeBodyAutoPaddingAttrName}="false"]) {
|
||||
padding-top: 60px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
_render() {
|
||||
if (!this._menu) return;
|
||||
|
||||
if (this._hidden) {
|
||||
this._menu.setAttribute("hidden", "true");
|
||||
} else {
|
||||
this._menu.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
if (this._appIconUrl) {
|
||||
this._menu.setAttribute("app-icon-url", this._appIconUrl);
|
||||
} else {
|
||||
this._menu.removeAttribute("app-icon-url");
|
||||
}
|
||||
|
||||
if (this._appTitle) {
|
||||
this._menu.setAttribute("app-title", this._appTitle);
|
||||
} else {
|
||||
this._menu.removeAttribute("app-title");
|
||||
}
|
||||
|
||||
const children: Node[] = [];
|
||||
|
||||
const items: MenuItem[] = Object.keys(this._items)
|
||||
.map(key => ({ name: key, ...this._items[key] }))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
;
|
||||
|
||||
for (let item: MenuItem, i = 0; (item = items[i]); i++) {
|
||||
const node = document.createElement('edge-menu-sub-item');
|
||||
node.label = item.label;
|
||||
node.iconUrl = item.iconUrl;
|
||||
node.linkUrl = item.linkUrl;
|
||||
children.push(node);
|
||||
}
|
||||
|
||||
this._menu.replaceChildren(...children);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
import 'core-js/actual';
|
||||
import '@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js';
|
||||
import 'lit/polyfill-support.js'
|
||||
import '@webcomponents/webcomponentsjs/webcomponents-loader.js';
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2015", "DOM"],
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"include": ["pkg/sdk/client/src/index.d.ts", "pkg/sdk/client/src/**/*.ts", "pkg/sdk/client/src/**/*.svg"]
|
||||
}
|