snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
394
ui/container/container.go
Normal file
394
ui/container/container.go
Normal file
@@ -0,0 +1,394 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/x/sqlxx"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
|
||||
"github.com/ory/x/decoderx"
|
||||
"github.com/ory/x/jsonschemax"
|
||||
"github.com/ory/x/jsonx"
|
||||
|
||||
"github.com/ory/kratos/schema"
|
||||
"github.com/ory/kratos/text"
|
||||
)
|
||||
|
||||
var (
|
||||
_ ErrorParser = (*Container)(nil)
|
||||
_ ValueSetter = (*Container)(nil)
|
||||
_ Resetter = (*Container)(nil)
|
||||
_ CSRFSetter = (*Container)(nil)
|
||||
_ NodeGetter = (*Container)(nil)
|
||||
)
|
||||
|
||||
// Container represents a HTML Form. The container can work with both HTTP Form and JSON requests
|
||||
//
|
||||
// swagger:model uiContainer
|
||||
type Container struct {
|
||||
// Action should be used as the form action URL `<form action="{{ .Action }}" method="post">`.
|
||||
//
|
||||
// required: true
|
||||
Action string `json:"action" faker:"url"`
|
||||
|
||||
// Method is the form method (e.g. POST)
|
||||
//
|
||||
// required: true
|
||||
Method string `json:"method" faker:"http_method"`
|
||||
|
||||
// Nodes contains the form's nodes
|
||||
//
|
||||
// The form's nodes can be input fields, text, images, and other UI elements.
|
||||
//
|
||||
// required: true
|
||||
Nodes node.Nodes `json:"nodes"`
|
||||
|
||||
// Messages contains all global form messages and errors.
|
||||
Messages text.Messages `json:"messages,omitempty"`
|
||||
}
|
||||
|
||||
// New returns an empty container.
|
||||
func New(action string) *Container {
|
||||
return &Container{
|
||||
Action: action,
|
||||
Method: "POST",
|
||||
Nodes: node.Nodes{},
|
||||
}
|
||||
}
|
||||
|
||||
// NewFromHTTPRequest creates a new Container and populates fields by parsing the HTTP Request body.
|
||||
// A jsonSchemaRef needs to be added to allow HTTP Form Post Body parsing.
|
||||
func NewFromHTTPRequest(r *http.Request, group node.UiNodeGroup, action string, compiler decoderx.HTTPDecoderOption) (*Container, error) {
|
||||
c := New(action)
|
||||
raw := json.RawMessage(`{}`)
|
||||
if err := decoderx.Decode(r, &raw, compiler); err != nil {
|
||||
if err := c.ParseError(group, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
c.UpdateNodeValuesFromJSON(raw, "", group)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewFromJSON creates a UI Container based on the provided JSON struct.
|
||||
func NewFromJSON(action string, group node.UiNodeGroup, raw json.RawMessage, prefix string) *Container {
|
||||
c := New(action)
|
||||
c.UpdateNodeValuesFromJSON(raw, prefix, group)
|
||||
return c
|
||||
}
|
||||
|
||||
// NewFromStruct creates a UI Container based on serialized contents of the provided struct.
|
||||
func NewFromStruct(action string, group node.UiNodeGroup, v interface{}, prefix string) (*Container, error) {
|
||||
c := New(action)
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.UpdateNodeValuesFromJSON(data, prefix, group)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewFromJSONSchema creates a new Container and populates the fields
|
||||
// using the provided JSON Schema. When compiler is nil, disallowRefs toggles
|
||||
// the `$ref` scheme restriction on the default compiler; otherwise the
|
||||
// caller is responsible for constructing a compiler with the desired
|
||||
// restriction.
|
||||
func NewFromJSONSchema(ctx context.Context, action string, group node.UiNodeGroup, jsonSchemaRef, prefix string, compiler *jsonschema.Compiler, disallowRefs bool) (*Container, error) {
|
||||
c := New(action)
|
||||
nodes, err := NodesFromJSONSchema(ctx, group, jsonSchemaRef, prefix, compiler, disallowRefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Nodes = nodes
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func NodesFromJSONSchema(ctx context.Context, group node.UiNodeGroup, jsonSchemaRef, prefix string, compiler *jsonschema.Compiler, disallowRefs bool) (node.Nodes, error) {
|
||||
if compiler == nil {
|
||||
// When the caller doesn't supply a compiler, we build a default one
|
||||
// ourselves. Apply the caller's disallowRefs preference so the default
|
||||
// doesn't silently downgrade to legacy behavior.
|
||||
var err error
|
||||
compiler, err = schema.NewCompilerWithURL(ctx, jsonSchemaRef, disallowRefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
paths, err := jsonschemax.ListPaths(ctx, jsonSchemaRef, compiler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodes := node.Nodes{}
|
||||
for _, value := range paths {
|
||||
if value.TypeHint == jsonschemax.JSON {
|
||||
continue
|
||||
}
|
||||
|
||||
name := addPrefix(value.Name, prefix, ".")
|
||||
nodes = append(nodes, node.NewInputFieldFromSchema(name, group, value))
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (c *Container) GetNodes() *node.Nodes {
|
||||
return &c.Nodes
|
||||
}
|
||||
|
||||
func (c *Container) SortNodes(ctx context.Context, opts ...node.SortOption) error {
|
||||
return c.Nodes.SortBySchema(ctx, opts...)
|
||||
}
|
||||
|
||||
// ResetMessages resets the container's own and its node's messages.
|
||||
func (c *Container) ResetMessages(exclude ...string) {
|
||||
c.Messages = nil
|
||||
for k, n := range c.Nodes {
|
||||
if !slices.Contains(exclude, n.ID()) {
|
||||
n.Messages = nil
|
||||
}
|
||||
c.Nodes[k] = n
|
||||
}
|
||||
}
|
||||
|
||||
// Reset resets the container's errors as well as each field's value and errors.
|
||||
func (c *Container) Reset(exclude ...string) {
|
||||
c.Messages = nil
|
||||
c.Nodes.Reset(exclude...)
|
||||
}
|
||||
|
||||
// ParseError type asserts the given error and sets the container's errors or a
|
||||
// field's errors and if the error is not something to be handled by the
|
||||
// formUI Container, the error is returned.
|
||||
//
|
||||
// This method DOES NOT touch the values of the node values/names, only its errors.
|
||||
func (c *Container) ParseError(group node.UiNodeGroup, err error) error {
|
||||
if e := richError(nil); errors.As(err, &e) {
|
||||
switch e.ID() {
|
||||
case text.ErrIDIdentityDisabled:
|
||||
c.AddMessage(group, text.NewErrorValidationIdentityDisabled())
|
||||
return nil
|
||||
default:
|
||||
if e.StatusCode() == http.StatusBadRequest {
|
||||
c.AddMessage(group, text.NewValidationErrorGeneric(e.Reason()))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
} else if e := new(schema.ValidationError); errors.As(err, &e) {
|
||||
pointer, _ := jsonschemax.JSONPointerToDotNotation(e.InstancePtr)
|
||||
for i := range e.Messages {
|
||||
c.AddMessage(group, &e.Messages[i], pointer)
|
||||
}
|
||||
return nil
|
||||
} else if e := new(jsonschema.ValidationError); errors.As(err, &e) {
|
||||
switch ctx := e.Context.(type) {
|
||||
case *jsonschema.ValidationErrorContextRequired:
|
||||
for _, required := range ctx.Missing {
|
||||
// The pointer can be ignored because if there is an error, we'll just use
|
||||
// the empty field (global error).
|
||||
pointer, _ := jsonschemax.JSONPointerToDotNotation(required)
|
||||
segments := strings.Split(required, "/")
|
||||
c.AddMessage(group, text.NewValidationErrorRequired(segments[len(segments)-1]), pointer)
|
||||
}
|
||||
default:
|
||||
// The pointer can be ignored because if there is an error, we'll just use
|
||||
// the empty field (global error).
|
||||
causes := e.Causes
|
||||
if len(e.Causes) == 0 {
|
||||
pointer, _ := jsonschemax.JSONPointerToDotNotation(e.InstancePtr)
|
||||
c.AddMessage(group, translateValidationError(e), pointer)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, ee := range causes {
|
||||
if err := c.ParseError(group, ee); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
} else if e := new(schema.ValidationListError); errors.As(err, &e) {
|
||||
for _, ee := range e.Validations {
|
||||
if err := c.ParseError(group, ee); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var formatErrorMatcher = regexp.MustCompile(`"([^"]+)"\s+is not valid\s+"([^"]+)"`)
|
||||
|
||||
func translateValidationError(err *jsonschema.ValidationError) *text.Message {
|
||||
segments := strings.Split(err.SchemaPtr, "/")
|
||||
switch segments[len(segments)-1] {
|
||||
case "minLength":
|
||||
minLength, actual := -1, -1
|
||||
_, _ = fmt.Sscanf(err.Message, "length must be >= %d, but got %d", &minLength, &actual)
|
||||
return text.NewErrorValidationMinLength(minLength, actual)
|
||||
case "maxLength":
|
||||
maxLength, actual := -1, -1
|
||||
_, _ = fmt.Sscanf(err.Message, "length must be <= %d, but got %d", &maxLength, &actual)
|
||||
return text.NewErrorValidationMaxLength(maxLength, actual)
|
||||
case "pattern":
|
||||
pattern := ""
|
||||
_, _ = fmt.Sscanf(err.Message, "does not match pattern %q", &pattern)
|
||||
return text.NewErrorValidationInvalidFormat(pattern)
|
||||
case "minimum":
|
||||
minimum, actual := -1.0, -1.0
|
||||
_, _ = fmt.Sscanf(err.Message, "must be >= %v but found %v", &minimum, &actual)
|
||||
return text.NewErrorValidationMinimum(minimum, actual)
|
||||
case "exclusiveMinimum":
|
||||
minimum, actual := -1.0, -1.0
|
||||
_, _ = fmt.Sscanf(err.Message, "must be > %v but found %v", &minimum, &actual)
|
||||
return text.NewErrorValidationExclusiveMinimum(minimum, actual)
|
||||
case "maximum":
|
||||
maximum, actual := -1.0, -1.0
|
||||
_, _ = fmt.Sscanf(err.Message, "must be <= %v but found %v", &maximum, &actual)
|
||||
return text.NewErrorValidationMaximum(maximum, actual)
|
||||
case "exclusiveMaximum":
|
||||
maximum, actual := -1.0, -1.0
|
||||
_, _ = fmt.Sscanf(err.Message, "must be < %v but found %v", &maximum, &actual)
|
||||
return text.NewErrorValidationExclusiveMaximum(maximum, actual)
|
||||
case "multipleOf":
|
||||
base, actual := -1.0, -1.0
|
||||
_, _ = fmt.Sscanf(err.Message, "%v not multipleOf %v", &actual, &base)
|
||||
return text.NewErrorValidationMultipleOf(base, actual)
|
||||
case "maxItems":
|
||||
maxItems, actual := -1, -1
|
||||
_, _ = fmt.Sscanf(err.Message, "maximum %d items allowed, but found %d items", &maxItems, &actual)
|
||||
return text.NewErrorValidationMaxItems(maxItems, actual)
|
||||
case "minItems":
|
||||
minItems, actual := -1, -1
|
||||
_, _ = fmt.Sscanf(err.Message, "minimum %d items allowed, but found %d items", &minItems, &actual)
|
||||
return text.NewErrorValidationMinItems(minItems, actual)
|
||||
case "uniqueItems":
|
||||
indexA, indexB := -1, -1
|
||||
_, _ = fmt.Sscanf(err.Message, "items at index %d and %d are equal", &indexA, &indexB)
|
||||
return text.NewErrorValidationUniqueItems(indexA, indexB)
|
||||
case "type":
|
||||
allowedTypes, actualType, _ := strings.Cut(strings.TrimPrefix(err.Message, "expected "), ", but got ")
|
||||
return text.NewErrorValidationWrongType(strings.Split(allowedTypes, " or "), actualType)
|
||||
case "const":
|
||||
if err.Message != "const failed" {
|
||||
expectedValue := strings.TrimPrefix(err.Message, "value must be ")
|
||||
return text.NewErrorValidationConst(expectedValue)
|
||||
}
|
||||
return text.NewErrorValidationConstGeneric()
|
||||
case "format":
|
||||
m := formatErrorMatcher.FindStringSubmatch(err.Message)
|
||||
if len(m) != 3 {
|
||||
return text.NewValidationErrorGeneric(err.Message)
|
||||
}
|
||||
|
||||
value := m[1]
|
||||
format := m[2]
|
||||
|
||||
switch format {
|
||||
case "email", "email_loose":
|
||||
return text.NewErrorValidationEmail(value)
|
||||
case "tel":
|
||||
return text.NewErrorValidationPhone(value)
|
||||
default:
|
||||
return text.NewValidationErrorGeneric(err.Message)
|
||||
}
|
||||
default:
|
||||
return text.NewValidationErrorGeneric(err.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateNodeValuesFromJSON sets the container's fields to the provided values.
|
||||
func (c *Container) UpdateNodeValuesFromJSON(raw json.RawMessage, prefix string, group node.UiNodeGroup) {
|
||||
for k, v := range jsonx.Flatten(raw) {
|
||||
k = addPrefix(k, prefix, ".")
|
||||
|
||||
if n := c.Nodes.Find(k); n != nil {
|
||||
n.Attributes.SetValue(v)
|
||||
n.Group = group
|
||||
continue
|
||||
}
|
||||
|
||||
c.Nodes.Upsert(node.NewInputFieldFromJSON(k, v, group))
|
||||
}
|
||||
}
|
||||
|
||||
// Unset removes a field from the container.
|
||||
func (c *Container) UnsetNode(id string) {
|
||||
c.Nodes.Remove(id)
|
||||
}
|
||||
|
||||
// SetCSRF sets the CSRF value using e.g. nosurf.Token(r).
|
||||
func (c *Container) SetCSRF(token string) {
|
||||
c.SetNode(node.NewCSRFNode(token))
|
||||
}
|
||||
|
||||
// SetNode sets a field.
|
||||
func (c *Container) SetNode(n *node.Node) {
|
||||
c.Nodes.Upsert(n)
|
||||
}
|
||||
|
||||
// SetValue sets a container's field to the provided name and value.
|
||||
func (c *Container) SetValue(id string, n *node.Node) {
|
||||
if f := c.Nodes.Find(id); f != nil {
|
||||
f.Attributes.SetValue(n.GetValue())
|
||||
return
|
||||
}
|
||||
|
||||
c.Nodes.Upsert(n)
|
||||
}
|
||||
|
||||
// AddMessage adds the provided error, and if a non-empty names list is set,
|
||||
// adds the error on the corresponding field.
|
||||
func (c *Container) AddMessage(group node.UiNodeGroup, err *text.Message, setForFields ...string) {
|
||||
if !slices.ContainsFunc(setForFields, func(s string) bool { return strings.TrimSpace(s) != "" }) {
|
||||
c.Messages = append(c.Messages, *err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, name := range setForFields {
|
||||
if ff := c.Nodes.Find(name); ff != nil {
|
||||
ff.Messages = append(ff.Messages, *err)
|
||||
continue
|
||||
}
|
||||
|
||||
n := node.NewInputField(name, nil, node.DefaultGroup, node.InputAttributeTypeText)
|
||||
n.Messages = text.Messages{*err}
|
||||
c.Nodes = append(c.Nodes, n)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Container) Scan(value interface{}) error {
|
||||
return sqlxx.JSONScan(c, value)
|
||||
}
|
||||
|
||||
func (c *Container) Value() (driver.Value, error) {
|
||||
if c == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func addPrefix(name, prefix, separator string) string {
|
||||
if prefix == "" {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("%s%s%s", prefix, separator, name)
|
||||
}
|
||||
335
ui/container/container_test.go
Normal file
335
ui/container/container_test.go
Normal file
@@ -0,0 +1,335 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/kratos/ui/node"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/x/decoderx"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/schema"
|
||||
"github.com/ory/kratos/text"
|
||||
)
|
||||
|
||||
func newJSONRequest(t *testing.T, j string) *http.Request {
|
||||
req, err := http.NewRequest("POST", "/", bytes.NewBufferString(j))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
func newFormRequest(t *testing.T, values url.Values) *http.Request {
|
||||
req, err := http.NewRequest("POST", "/", bytes.NewBufferString(values.Encode()))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return req
|
||||
}
|
||||
|
||||
func TestContainer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
t.Run("method=NewFromJSON", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
r string
|
||||
prefix string
|
||||
expect *Container
|
||||
}{
|
||||
{
|
||||
r: `{"numby":1.5,"stringy":"foobar","objy":{"objy":{},"numby":1.5,"stringy":"foobar"}}`,
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputFieldFromJSON("numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("objy.numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("objy.stringy", "foobar", node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("stringy", "foobar", node.DefaultGroup),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
r: `{"numby":1.5,"stringy":"foobar","objy":{"objy":{},"numby":1.5,"stringy":"foobar"}}`,
|
||||
prefix: "traits",
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputFieldFromJSON("traits.numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("traits.objy.numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("traits.objy.stringy", "foobar", node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("traits.stringy", "foobar", node.DefaultGroup),
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
actual := NewFromJSON("action", node.DefaultGroup, json.RawMessage(tc.r), tc.prefix)
|
||||
|
||||
// sort actual.fields lexicographically to have a deterministic order
|
||||
sort.SliceStable(actual.Nodes, func(i, j int) bool {
|
||||
return actual.Nodes[i].ID() < actual.Nodes[j].ID()
|
||||
})
|
||||
|
||||
assert.Equal(t, "action", actual.Action)
|
||||
assert.EqualValues(t, tc.expect.Nodes, actual.Nodes)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method=NewFromHTTPRequest", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
ref string
|
||||
r *http.Request
|
||||
expect *Container
|
||||
}{
|
||||
{
|
||||
ref: "./stub/simple.schema.json",
|
||||
r: newJSONRequest(t, `{"numby":1.5,"stringy":"foobar","objy":{"objy":{},"numby":1.5,"stringy":"foobar"}}`),
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputFieldFromJSON("numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("objy.numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("objy.stringy", "foobar", node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("stringy", "foobar", node.DefaultGroup),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ref: "./stub/simple.schema.json",
|
||||
r: newFormRequest(t, url.Values{
|
||||
"numby": {"1.5"},
|
||||
"stringy": {"foobar"},
|
||||
"objy.numby": {"1.5"},
|
||||
"objy.stringy": {"foobar"},
|
||||
}),
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputFieldFromJSON("numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("objy.numby", 1.5, node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("objy.stringy", "foobar", node.DefaultGroup),
|
||||
node.NewInputFieldFromJSON("stringy", "foobar", node.DefaultGroup),
|
||||
},
|
||||
},
|
||||
},
|
||||
// FIXME https://github.com/ory/kratos/issues/1316
|
||||
//
|
||||
//{
|
||||
// ref: "./stub/complex.schema.json",
|
||||
// r: newFormRequest(t, url.Values{
|
||||
// "meal.chef": {"aeneas"},
|
||||
// }),
|
||||
// expect: &Container{
|
||||
// Nodes: node.Nodes{
|
||||
// node.NewInputFieldFromJSON("meal.chef", "aeneas", node.DefaultGroup),
|
||||
// &node.Node{Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "meal.name", Type: node.InputAttributeTypeText}, Messages: text.Messages{*text.NewValidationErrorRequired("name")}, Meta: &node.Meta{}},
|
||||
// },
|
||||
// },
|
||||
//},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
actual, err := NewFromHTTPRequest(tc.r, node.DefaultGroup, "action", decoderx.HTTPJSONSchemaCompiler(tc.ref, nil))
|
||||
require.NoError(t, err)
|
||||
// sort actual.fields lexicographically to have a deterministic order
|
||||
sort.SliceStable(actual.Nodes, func(i, j int) bool {
|
||||
return actual.Nodes[i].ID() < actual.Nodes[j].ID()
|
||||
})
|
||||
assert.Equal(t, "action", actual.Action)
|
||||
assert.EqualValues(t, tc.expect.Nodes, actual.Nodes)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method=NewFromJSONSchema", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
ref string
|
||||
prefix string
|
||||
expect *Container
|
||||
}{
|
||||
{
|
||||
ref: "./stub/simple.schema.json",
|
||||
prefix: "",
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputField("numby", nil, node.DefaultGroup, node.InputAttributeTypeNumber),
|
||||
node.NewInputField("objy.numby", nil, node.DefaultGroup, node.InputAttributeTypeNumber),
|
||||
node.NewInputField("objy.stringy", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
node.NewInputField("stringy", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ref: "./stub/simple.schema.json",
|
||||
prefix: "traits",
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputField("traits.numby", nil, node.DefaultGroup, node.InputAttributeTypeNumber),
|
||||
node.NewInputField("traits.objy.numby", nil, node.DefaultGroup, node.InputAttributeTypeNumber),
|
||||
node.NewInputField("traits.objy.stringy", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
node.NewInputField("traits.stringy", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ref: "./stub/complex.schema.json",
|
||||
expect: &Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputField("fruits", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
node.NewInputField("meal.chef", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
node.NewInputField("meal.name", nil, node.DefaultGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute),
|
||||
|
||||
// FIXME https://github.com/ory/kratos/issues/1316
|
||||
//
|
||||
//node.NewInputField("vegetables.veggieName", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
//node.NewInputField("vegetables.veggieLike", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
//node.NewInputField("vegetables.veggieAmount", nil, node.DefaultGroup, node.InputAttributeTypeText),
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
actual, err := NewFromJSONSchema(ctx, "action",
|
||||
node.DefaultGroup, tc.ref, tc.prefix, nil, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "action", actual.Action)
|
||||
assert.EqualValues(t, tc.expect.Messages, actual.Messages)
|
||||
assert.EqualValues(t, tc.expect.Nodes, actual.Nodes)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method=ParseError", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
err error
|
||||
expectErr bool
|
||||
expect Container
|
||||
}{
|
||||
{err: errors.New("foo"), expectErr: true},
|
||||
{err: herodot.ErrNotFound(), expectErr: true},
|
||||
{err: herodot.ErrBadRequest().WithReason("tests"), expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewValidationErrorGeneric("tests")}}},
|
||||
{err: schema.NewInvalidCredentialsError(), expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewErrorValidationInvalidCredentials()}}},
|
||||
{err: &jsonschema.ValidationError{Message: "test", InstancePtr: "#/foo/bar/baz"}, expect: Container{Nodes: node.Nodes{
|
||||
&node.Node{Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "foo.bar.baz", Type: node.InputAttributeTypeText}, Messages: text.Messages{*text.NewValidationErrorGeneric("test")}, Meta: new(node.Meta)},
|
||||
}}},
|
||||
{err: &jsonschema.ValidationError{Message: "test", InstancePtr: ""}, expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewValidationErrorGeneric("test")}}},
|
||||
{err: &jsonschema.ValidationError{Message: `"asd" is not valid "email"`, InstancePtr: "", SchemaPtr: "#/email/format"}, expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewErrorValidationEmail("asd")}}},
|
||||
{err: &jsonschema.ValidationError{Message: `"asd" is not valid "tel"`, InstancePtr: "", SchemaPtr: "#/phone/format"}, expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewErrorValidationPhone("asd")}}},
|
||||
{err: &jsonschema.ValidationError{Message: `"asd" is not valid "unknown-format"`, InstancePtr: "", SchemaPtr: "#/unknown/format"}, expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewValidationErrorGeneric(`"asd" is not valid "unknown-format"`)}}},
|
||||
// Malformed format error message
|
||||
{err: &jsonschema.ValidationError{Message: `"asd" is not valid "unknown-format`, InstancePtr: "", SchemaPtr: "#/unknown/format"}, expect: Container{Nodes: node.Nodes{}, Messages: text.Messages{*text.NewValidationErrorGeneric(`"asd" is not valid "unknown-format`)}}},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
for _, in := range []error{tc.err, errors.WithStack(tc.err)} {
|
||||
c := New("")
|
||||
err := c.ParseError(node.DefaultGroup, in)
|
||||
if tc.expectErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, tc.expect.Messages, c.Messages)
|
||||
assert.EqualValues(t, tc.expect.Nodes, c.Nodes)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method=SetValue", func(t *testing.T) {
|
||||
c := Container{
|
||||
Nodes: node.Nodes{
|
||||
node.NewInputField("1", "foo", node.DefaultGroup, node.InputAttributeTypeText),
|
||||
node.NewInputField("2", "", node.DefaultGroup, node.InputAttributeTypeText),
|
||||
},
|
||||
}
|
||||
|
||||
assert.Len(t, c.Nodes, 2)
|
||||
|
||||
c.SetValue("1", node.NewInputFieldFromJSON("1", "baz1", node.DefaultGroup))
|
||||
c.SetValue("2", node.NewInputFieldFromJSON("2", "baz2", node.DefaultGroup))
|
||||
c.SetValue("3", node.NewInputFieldFromJSON("3", "baz3", node.DefaultGroup))
|
||||
|
||||
assert.Len(t, c.Nodes, 3)
|
||||
for _, k := range []string{"1", "2", "3"} {
|
||||
assert.EqualValues(t, fmt.Sprintf("baz%s", k), c.Nodes.Find(k).Attributes.GetValue(), "%+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method=SetCSRF", func(t *testing.T) {
|
||||
f := &Container{Nodes: node.Nodes{}}
|
||||
f.SetCSRF("csrf-token")
|
||||
assert.Contains(t, f.Nodes, node.NewCSRFNode("csrf-token"))
|
||||
})
|
||||
|
||||
t.Run("method=AddMessage", func(t *testing.T) {
|
||||
c := Container{
|
||||
Nodes: node.Nodes{
|
||||
&node.Node{Messages: text.Messages{*text.NewValidationErrorGeneric("foo")}, Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "1", Type: node.InputAttributeTypeText, FieldValue: ""}},
|
||||
&node.Node{Messages: text.Messages{}, Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "2", Type: node.InputAttributeTypeText, FieldValue: ""}},
|
||||
},
|
||||
}
|
||||
assert.Len(t, c.Nodes, 2)
|
||||
c.AddMessage(node.DefaultGroup, &text.Message{Text: "baz1"}, "1")
|
||||
c.AddMessage(node.DefaultGroup, &text.Message{Text: "baz2"}, "2")
|
||||
c.AddMessage(node.DefaultGroup, &text.Message{Text: "baz3"}, "3")
|
||||
c.AddMessage(node.DefaultGroup, &text.Message{Text: "baz"}, "4", "5", "6")
|
||||
c.AddMessage(node.DefaultGroup, &text.Message{Text: "rootbar"})
|
||||
|
||||
assert.Len(t, c.Nodes, 6)
|
||||
|
||||
for _, k := range []string{"1", "2", "3"} {
|
||||
n := c.Nodes.Find(k)
|
||||
assert.EqualValues(t, fmt.Sprintf("baz%s", k), n.Messages[len(n.Messages)-1].Text, "%+v", c)
|
||||
}
|
||||
|
||||
for _, k := range []string{"4", "5", "6"} {
|
||||
assert.EqualValues(t, "baz", c.Nodes.Find(k).Messages[0].Text, "%+v", c)
|
||||
}
|
||||
|
||||
assert.Len(t, c.Messages, 1)
|
||||
assert.Equal(t, "rootbar", c.Messages[0].Text)
|
||||
})
|
||||
|
||||
t.Run("method=Reset", func(t *testing.T) {
|
||||
c := Container{
|
||||
Nodes: node.Nodes{
|
||||
&node.Node{Messages: text.Messages{{Text: "foo"}}, Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "1", Type: node.InputAttributeTypeText, FieldValue: "foo"}},
|
||||
&node.Node{Messages: text.Messages{{Text: "bar"}}, Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "2", Type: node.InputAttributeTypeText, FieldValue: "bar"}},
|
||||
},
|
||||
Messages: text.Messages{{Text: ""}},
|
||||
}
|
||||
c.Reset()
|
||||
|
||||
assert.Empty(t, c.Messages)
|
||||
assert.Empty(t, c.Nodes.Find("1").Messages)
|
||||
assert.Empty(t, c.Nodes.Find("1").Attributes.(*node.InputAttributes).FieldValue)
|
||||
assert.Empty(t, c.Nodes.Find("2").Messages)
|
||||
assert.Empty(t, c.Nodes.Find("2").Attributes.(*node.InputAttributes).FieldValue)
|
||||
})
|
||||
|
||||
t.Run("method=remove", func(t *testing.T) {
|
||||
c := Container{
|
||||
Nodes: node.Nodes{
|
||||
&node.Node{Messages: text.Messages{{Text: "foo"}}, Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "1", Type: node.InputAttributeTypeText, FieldValue: "foo"}},
|
||||
&node.Node{Messages: text.Messages{{Text: "bar"}}, Group: node.DefaultGroup, Type: node.Input, Attributes: &node.InputAttributes{Name: "2", Type: node.InputAttributeTypeText, FieldValue: "bar"}},
|
||||
},
|
||||
Messages: text.Messages{{Text: ""}},
|
||||
}
|
||||
|
||||
require.Len(t, c.Nodes, 2)
|
||||
c.GetNodes().Remove("1")
|
||||
require.Len(t, c.Nodes, 1)
|
||||
require.EqualValues(t, "bar", c.Nodes[0].Attributes.GetValue())
|
||||
})
|
||||
}
|
||||
12
ui/container/error.go
Normal file
12
ui/container/error.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package container
|
||||
|
||||
type (
|
||||
richError interface {
|
||||
StatusCode() int
|
||||
Reason() string
|
||||
ID() string
|
||||
}
|
||||
)
|
||||
58
ui/container/stub/all_formats.schema.json
Normal file
58
ui/container/stub/all_formats.schema.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"$id": "https://example.com/all_formats.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string",
|
||||
"test_expected_type": "password"
|
||||
},
|
||||
"csrf_token": {
|
||||
"type": "string",
|
||||
"test_expected_type": "hidden"
|
||||
},
|
||||
"simpleString": {
|
||||
"type": "string",
|
||||
"test_expected_type": "text"
|
||||
},
|
||||
"simpleNumber": {
|
||||
"type": "number",
|
||||
"test_expected_type": "number"
|
||||
},
|
||||
"simpleBoolean": {
|
||||
"type": "boolean",
|
||||
"test_expected_type": "checkbox"
|
||||
},
|
||||
"emailString": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"test_expected_type": "email"
|
||||
},
|
||||
"dateTimeString": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"test_expected_type": "datetime-local"
|
||||
},
|
||||
"regexString": {
|
||||
"type": "string",
|
||||
"format": "regex",
|
||||
"test_expected_type": "text"
|
||||
},
|
||||
"dateString": {
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"test_expected_type": "date"
|
||||
},
|
||||
"uriString": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"test_expected_type": "url"
|
||||
},
|
||||
"patternString": {
|
||||
"type": "string",
|
||||
"pattern": "foo.*",
|
||||
"test_expected_type": "text",
|
||||
"test_expected_pattern": true
|
||||
}
|
||||
}
|
||||
}
|
||||
52
ui/container/stub/complex.schema.json
Normal file
52
ui/container/stub/complex.schema.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"$id": "https://example.com/complex.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"required": ["meal"],
|
||||
"properties": {
|
||||
"meal": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"chef": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fruits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"vegetables": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/veggie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"veggie": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"veggieName",
|
||||
"veggieLike"
|
||||
],
|
||||
"properties": {
|
||||
"veggieName": {
|
||||
"type": "string"
|
||||
},
|
||||
"veggieLike": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"veggieAmount": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
ui/container/stub/identity.schema.json
Normal file
43
ui/container/stub/identity.schema.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"stringy": {
|
||||
"type": "string"
|
||||
},
|
||||
"numby": {
|
||||
"type": "number"
|
||||
},
|
||||
"booly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"should_big_number": {
|
||||
"type": "number",
|
||||
"minimum": 1200
|
||||
},
|
||||
"should_long_string": {
|
||||
"type": "string",
|
||||
"minLength": 25
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
ui/container/stub/simple.schema.json
Normal file
29
ui/container/stub/simple.schema.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stringy": {
|
||||
"type": "string"
|
||||
},
|
||||
"numby": {
|
||||
"type": "number"
|
||||
},
|
||||
"objy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stringy": {
|
||||
"type": "string"
|
||||
},
|
||||
"numby": {
|
||||
"type": "number"
|
||||
},
|
||||
"objy": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
ui/container/types.go
Normal file
54
ui/container/types.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"github.com/ory/kratos/ui/node"
|
||||
)
|
||||
|
||||
// ErrorParser is capable of parsing and processing errors.
|
||||
type ErrorParser interface {
|
||||
// ParseError type asserts the given error and sets the forms's errors or a
|
||||
// field's errors and if the error is not something to be handled by the
|
||||
// formUI Container itself, the error is returned for further propagation (e.g. showing a 502 status code).
|
||||
ParseError(group node.UiNodeGroup, err error) error
|
||||
}
|
||||
|
||||
type NodeSetter interface {
|
||||
// SetNode sets (adds / replaces) a node.
|
||||
SetNode(field node.Node)
|
||||
}
|
||||
|
||||
type NodeUnsetter interface {
|
||||
// UnsetFields removes a node.
|
||||
UnsetNode(name string)
|
||||
}
|
||||
|
||||
type ValueSetter interface {
|
||||
// SetValue sets a value the passed node.
|
||||
SetValue(name string, value *node.Node)
|
||||
}
|
||||
|
||||
type CSRFSetter interface {
|
||||
// SetCSRF sets the CSRF value.
|
||||
SetCSRF(string)
|
||||
}
|
||||
|
||||
type Resetter interface {
|
||||
// Resets all values and messages recursively.
|
||||
Reset(exclude ...string)
|
||||
}
|
||||
|
||||
type MessageResetter interface {
|
||||
// ResetMessages resets the messages recursively.
|
||||
ResetMessages(exclude ...string)
|
||||
}
|
||||
|
||||
type FieldSorter interface {
|
||||
SortNodes(schemaRef string, prefix string, keysInOrder []string) error
|
||||
}
|
||||
|
||||
type NodeGetter interface {
|
||||
GetNodes() *node.Nodes
|
||||
}
|
||||
12
ui/node/.snapshots/TestNodeMarshalJSON-anchor_node.json
Normal file
12
ui/node/.snapshots/TestNodeMarshalJSON-anchor_node.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"type": "a",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"href": "https://example.com",
|
||||
"title": null,
|
||||
"id": "",
|
||||
"node_type": "a"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
10
ui/node/.snapshots/TestNodeMarshalJSON-division_node.json
Normal file
10
ui/node/.snapshots/TestNodeMarshalJSON-division_node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"type": "div",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"id": "",
|
||||
"node_type": "div"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "email",
|
||||
"type": "",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
13
ui/node/.snapshots/TestNodeMarshalJSON-image_node.json
Normal file
13
ui/node/.snapshots/TestNodeMarshalJSON-image_node.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "img",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"src": "image.jpg",
|
||||
"id": "",
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"node_type": "img"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
13
ui/node/.snapshots/TestNodeMarshalJSON-input_node.json
Normal file
13
ui/node/.snapshots/TestNodeMarshalJSON-input_node.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"value": "secret",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
17
ui/node/.snapshots/TestNodeMarshalJSON-script_node.json
Normal file
17
ui/node/.snapshots/TestNodeMarshalJSON-script_node.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"type": "script",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"src": "script.js",
|
||||
"async": false,
|
||||
"referrerpolicy": "",
|
||||
"crossorigin": "",
|
||||
"integrity": "",
|
||||
"type": "",
|
||||
"id": "",
|
||||
"nonce": "",
|
||||
"node_type": "script"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
11
ui/node/.snapshots/TestNodeMarshalJSON-text_node.json
Normal file
11
ui/node/.snapshots/TestNodeMarshalJSON-text_node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"type": "text",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"text": null,
|
||||
"id": "",
|
||||
"node_type": "text"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
542
ui/node/attributes.go
Normal file
542
ui/node/attributes.go
Normal file
@@ -0,0 +1,542 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package node
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/x/webauthnx/js"
|
||||
)
|
||||
|
||||
const (
|
||||
InputAttributeTypeText UiNodeInputAttributeType = "text"
|
||||
InputAttributeTypePassword UiNodeInputAttributeType = "password"
|
||||
InputAttributeTypeNumber UiNodeInputAttributeType = "number"
|
||||
InputAttributeTypeCheckbox UiNodeInputAttributeType = "checkbox"
|
||||
InputAttributeTypeHidden UiNodeInputAttributeType = "hidden"
|
||||
InputAttributeTypeEmail UiNodeInputAttributeType = "email"
|
||||
InputAttributeTypeTel UiNodeInputAttributeType = "tel"
|
||||
InputAttributeTypeSubmit UiNodeInputAttributeType = "submit"
|
||||
InputAttributeTypeButton UiNodeInputAttributeType = "button"
|
||||
InputAttributeTypeDateTimeLocal UiNodeInputAttributeType = "datetime-local"
|
||||
InputAttributeTypeDate UiNodeInputAttributeType = "date"
|
||||
InputAttributeTypeURI UiNodeInputAttributeType = "url"
|
||||
)
|
||||
|
||||
const (
|
||||
InputAttributeAutocompleteEmail UiNodeInputAttributeAutocomplete = "email"
|
||||
InputAttributeAutocompleteTel UiNodeInputAttributeAutocomplete = "tel"
|
||||
InputAttributeAutocompleteUrl UiNodeInputAttributeAutocomplete = "url"
|
||||
InputAttributeAutocompleteCurrentPassword UiNodeInputAttributeAutocomplete = "current-password"
|
||||
InputAttributeAutocompleteNewPassword UiNodeInputAttributeAutocomplete = "new-password"
|
||||
InputAttributeAutocompleteOneTimeCode UiNodeInputAttributeAutocomplete = "one-time-code"
|
||||
InputAttributeAutocompleteUsername UiNodeInputAttributeAutocomplete = "username"
|
||||
InputAttributeAutocompleteUsernameWebauthn UiNodeInputAttributeAutocomplete = "username webauthn"
|
||||
)
|
||||
|
||||
// swagger:enum UiNodeInputAttributeType
|
||||
type UiNodeInputAttributeType string
|
||||
|
||||
// swagger:enum UiNodeInputAttributeAutocomplete
|
||||
type UiNodeInputAttributeAutocomplete string
|
||||
|
||||
// Attributes represents a list of attributes (e.g. `href="foo"` for links).
|
||||
//
|
||||
// swagger:model uiNodeAttributes
|
||||
type Attributes interface {
|
||||
// swagger:ignore
|
||||
ID() string
|
||||
|
||||
// swagger:ignore
|
||||
Reset()
|
||||
|
||||
// swagger:ignore
|
||||
SetValue(value interface{})
|
||||
|
||||
// swagger:ignore
|
||||
GetValue() interface{}
|
||||
|
||||
// swagger:ignore
|
||||
GetNodeType() UiNodeType
|
||||
|
||||
// swagger:ignore
|
||||
Matches(other Attributes) bool
|
||||
}
|
||||
|
||||
// InputAttributes represents the attributes of an input node
|
||||
//
|
||||
// swagger:model uiNodeInputAttributes
|
||||
type InputAttributes struct {
|
||||
// The input's element name.
|
||||
//
|
||||
// required: true
|
||||
Name string `json:"name"`
|
||||
|
||||
// The input's element type.
|
||||
//
|
||||
// required: true
|
||||
Type UiNodeInputAttributeType `json:"type" faker:"-"`
|
||||
|
||||
// The input's value.
|
||||
FieldValue interface{} `json:"value,omitempty" faker:"string"`
|
||||
|
||||
// Mark this input field as required.
|
||||
Required bool `json:"required,omitempty"`
|
||||
|
||||
// The autocomplete attribute for the input.
|
||||
Autocomplete UiNodeInputAttributeAutocomplete `json:"autocomplete,omitempty"`
|
||||
|
||||
// The input's label text.
|
||||
Label *text.Message `json:"label,omitempty"`
|
||||
|
||||
// The input's pattern.
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
|
||||
// Sets the input's disabled field to true or false.
|
||||
//
|
||||
// required: true
|
||||
Disabled bool `json:"disabled"`
|
||||
|
||||
// OnClick may contain javascript which should be executed on click. This is primarily
|
||||
// used for WebAuthn.
|
||||
//
|
||||
// Deprecated: Using OnClick requires the use of eval() which is a security risk. Use OnClickTrigger instead.
|
||||
OnClick string `json:"onclick,omitempty"`
|
||||
|
||||
// OnClickTrigger may contain a WebAuthn trigger which should be executed on click.
|
||||
//
|
||||
// The trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login.
|
||||
OnClickTrigger js.WebAuthnTriggers `json:"onclickTrigger,omitempty"`
|
||||
|
||||
// OnLoad may contain javascript which should be executed on load. This is primarily
|
||||
// used for WebAuthn.
|
||||
//
|
||||
// Deprecated: Using OnLoad requires the use of eval() which is a security risk. Use OnLoadTrigger instead.
|
||||
OnLoad string `json:"onload,omitempty"`
|
||||
|
||||
// OnLoadTrigger may contain a WebAuthn trigger which should be executed on load.
|
||||
//
|
||||
// The trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login.
|
||||
OnLoadTrigger js.WebAuthnTriggers `json:"onloadTrigger,omitempty"`
|
||||
|
||||
// MaxLength may contain the input's maximum length.
|
||||
MaxLength int `json:"maxlength,omitempty"`
|
||||
|
||||
// The allowed values for the input when the underlying JSON schema
|
||||
// defines an `enum`. When present, clients should render the field as a
|
||||
// select/dropdown rather than a free-form text input. When absent,
|
||||
// clients continue to render a plain text input, so this field is
|
||||
// backward compatible with UIs that do not look at it.
|
||||
Options []InputAttributesOption `json:"options,omitempty" faker:"-"`
|
||||
|
||||
// NodeType represents this node's types. It is a mirror of `node.type` and
|
||||
// is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "input".
|
||||
//
|
||||
// required: true
|
||||
NodeType UiNodeType `json:"node_type"`
|
||||
}
|
||||
|
||||
// Represents a single selectable value for an input whose JSON schema
|
||||
// defined an `enum`. The value is always a scalar JSON type (string, number,
|
||||
// or boolean) serialized verbatim from the schema. When present, clients
|
||||
// should render the parent input as a select/dropdown.
|
||||
//
|
||||
// swagger:model uiNodeInputAttributesOption
|
||||
type InputAttributesOption struct {
|
||||
// The value that will be submitted when this option is picked.
|
||||
// It is serialized verbatim from the JSON schema `enum` entry, so it is
|
||||
// always a scalar JSON value (string, number, or boolean).
|
||||
//
|
||||
// required: true
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
// ImageAttributes represents the attributes of an image node.
|
||||
//
|
||||
// swagger:model uiNodeImageAttributes
|
||||
type ImageAttributes struct {
|
||||
// The image's source URL.
|
||||
//
|
||||
// format: uri
|
||||
// required: true
|
||||
Source string `json:"src"`
|
||||
|
||||
// A unique identifier
|
||||
//
|
||||
// required: true
|
||||
Identifier string `json:"id"`
|
||||
|
||||
// Width of the image
|
||||
//
|
||||
// required: true
|
||||
Width int `json:"width"`
|
||||
|
||||
// Height of the image
|
||||
//
|
||||
// required: true
|
||||
Height int `json:"height"`
|
||||
|
||||
// NodeType represents this node's types. It is a mirror of `node.type` and
|
||||
// is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "img".
|
||||
//
|
||||
// required: true
|
||||
NodeType UiNodeType `json:"node_type"`
|
||||
}
|
||||
|
||||
// AnchorAttributes represents the attributes of an anchor node.
|
||||
//
|
||||
// swagger:model uiNodeAnchorAttributes
|
||||
type AnchorAttributes struct {
|
||||
// The link's href (destination) URL.
|
||||
//
|
||||
// format: uri
|
||||
// required: true
|
||||
HREF string `json:"href"`
|
||||
|
||||
// The link's title.
|
||||
//
|
||||
// required: true
|
||||
Title *text.Message `json:"title"`
|
||||
|
||||
// A unique identifier
|
||||
//
|
||||
// required: true
|
||||
Identifier string `json:"id"`
|
||||
|
||||
// NodeType represents this node's types. It is a mirror of `node.type` and
|
||||
// is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "a".
|
||||
//
|
||||
// required: true
|
||||
NodeType UiNodeType `json:"node_type"`
|
||||
}
|
||||
|
||||
// TextAttributes represents the attributes of a text node.
|
||||
//
|
||||
// swagger:model uiNodeTextAttributes
|
||||
type TextAttributes struct {
|
||||
// The text of the text node.
|
||||
//
|
||||
// required: true
|
||||
Text *text.Message `json:"text"`
|
||||
|
||||
// A unique identifier
|
||||
//
|
||||
// required: true
|
||||
Identifier string `json:"id"`
|
||||
|
||||
// NodeType represents this node's types. It is a mirror of `node.type` and
|
||||
// is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "text".
|
||||
//
|
||||
// required: true
|
||||
NodeType UiNodeType `json:"node_type"`
|
||||
}
|
||||
|
||||
// ScriptAttributes represent script nodes which load javascript.
|
||||
//
|
||||
// swagger:model uiNodeScriptAttributes
|
||||
type ScriptAttributes struct {
|
||||
// The script source
|
||||
//
|
||||
// required: true
|
||||
Source string `json:"src"`
|
||||
|
||||
// The script async type
|
||||
//
|
||||
// required: true
|
||||
Async bool `json:"async"`
|
||||
|
||||
// The script referrer policy
|
||||
//
|
||||
// required: true
|
||||
ReferrerPolicy string `json:"referrerpolicy"`
|
||||
|
||||
// The script cross origin policy
|
||||
//
|
||||
// required: true
|
||||
CrossOrigin string `json:"crossorigin"`
|
||||
|
||||
// The script's integrity hash
|
||||
//
|
||||
// required: true
|
||||
Integrity string `json:"integrity"`
|
||||
|
||||
// The script MIME type
|
||||
//
|
||||
// required: true
|
||||
Type string `json:"type"`
|
||||
|
||||
// A unique identifier
|
||||
//
|
||||
// required: true
|
||||
Identifier string `json:"id"`
|
||||
|
||||
// Nonce for CSP
|
||||
//
|
||||
// A nonce you may want to use to improve your Content Security Policy.
|
||||
// You do not have to use this value but if you want to improve your CSP
|
||||
// policies you may use it. You can also choose to use your own nonce value!
|
||||
//
|
||||
// required: true
|
||||
Nonce string `json:"nonce"`
|
||||
|
||||
// NodeType represents this node's types. It is a mirror of `node.type` and
|
||||
// is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "script".
|
||||
//
|
||||
// required: true
|
||||
NodeType UiNodeType `json:"node_type"`
|
||||
}
|
||||
|
||||
// DivisionAttributes represent a division section.
|
||||
//
|
||||
// Division sections are used for interactive widgets that require a hook in the DOM / view.
|
||||
//
|
||||
// swagger:model uiNodeDivisionAttributes
|
||||
type DivisionAttributes struct {
|
||||
// A classname that should be rendered into the DOM.
|
||||
Classname string `json:"class,omitzero"`
|
||||
|
||||
// A unique identifier
|
||||
//
|
||||
// required: true
|
||||
Identifier string `json:"id"`
|
||||
|
||||
// Data is a map of key-value pairs that are passed to the division.
|
||||
//
|
||||
// They may be used for `data-...` attributes.
|
||||
Data map[string]string `json:"data,omitzero"`
|
||||
|
||||
// NodeType represents this node's type. It is a mirror of `node.type` and
|
||||
// is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "script".
|
||||
//
|
||||
// required: true
|
||||
NodeType UiNodeType `json:"node_type"`
|
||||
}
|
||||
|
||||
func (d DivisionAttributes) ID() string {
|
||||
return d.Identifier
|
||||
}
|
||||
|
||||
func (d DivisionAttributes) Reset() {}
|
||||
|
||||
func (d DivisionAttributes) SetValue(_ interface{}) {}
|
||||
|
||||
func (d DivisionAttributes) GetValue() interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DivisionAttributes) GetNodeType() UiNodeType {
|
||||
return d.NodeType
|
||||
}
|
||||
|
||||
func (d DivisionAttributes) Matches(other Attributes) bool {
|
||||
ot, ok := other.(*DivisionAttributes)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ot.ID()) > 0 && d.ID() != ot.ID() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
var (
|
||||
_ Attributes = new(InputAttributes)
|
||||
_ Attributes = new(ImageAttributes)
|
||||
_ Attributes = new(AnchorAttributes)
|
||||
_ Attributes = new(TextAttributes)
|
||||
_ Attributes = new(ScriptAttributes)
|
||||
_ Attributes = new(DivisionAttributes)
|
||||
)
|
||||
|
||||
func (a *InputAttributes) ID() string {
|
||||
return a.Name
|
||||
}
|
||||
|
||||
func (a *ImageAttributes) ID() string {
|
||||
return a.Identifier
|
||||
}
|
||||
|
||||
func (a *AnchorAttributes) ID() string {
|
||||
return a.Identifier
|
||||
}
|
||||
|
||||
func (a *TextAttributes) ID() string {
|
||||
return a.Identifier
|
||||
}
|
||||
|
||||
func (a *ScriptAttributes) ID() string {
|
||||
return a.Identifier
|
||||
}
|
||||
|
||||
func (a *InputAttributes) Matches(other Attributes) bool {
|
||||
ot, ok := other.(*InputAttributes)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ot.ID()) > 0 && a.ID() != ot.ID() {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ot.Type) > 0 && a.Type != ot.Type {
|
||||
return false
|
||||
}
|
||||
|
||||
if ot.FieldValue != nil && fmt.Sprintf("%v", a.FieldValue) != fmt.Sprintf("%v", ot.FieldValue) {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ot.Name) > 0 && a.Name != ot.Name {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *ImageAttributes) Matches(other Attributes) bool {
|
||||
ot, ok := other.(*ImageAttributes)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ot.ID()) > 0 && a.ID() != ot.ID() {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ot.Source) > 0 && a.Source != ot.Source {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *AnchorAttributes) Matches(other Attributes) bool {
|
||||
ot, ok := other.(*AnchorAttributes)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ot.ID()) > 0 && a.ID() != ot.ID() {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ot.HREF) > 0 && a.HREF != ot.HREF {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *TextAttributes) Matches(other Attributes) bool {
|
||||
ot, ok := other.(*TextAttributes)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ot.ID()) > 0 && a.ID() != ot.ID() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *ScriptAttributes) Matches(other Attributes) bool {
|
||||
ot, ok := other.(*ScriptAttributes)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ot.ID()) > 0 && a.ID() != ot.ID() {
|
||||
return false
|
||||
}
|
||||
|
||||
if ot.Type != "" && a.Type != ot.Type {
|
||||
return false
|
||||
}
|
||||
|
||||
if ot.Source != "" && a.Source != ot.Source {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *InputAttributes) SetValue(value interface{}) {
|
||||
a.FieldValue = value
|
||||
}
|
||||
|
||||
func (a *ImageAttributes) SetValue(value interface{}) {
|
||||
a.Source, _ = value.(string)
|
||||
}
|
||||
|
||||
func (a *AnchorAttributes) SetValue(value interface{}) {
|
||||
a.HREF, _ = value.(string)
|
||||
}
|
||||
|
||||
func (a *TextAttributes) SetValue(value interface{}) {
|
||||
a.Text, _ = value.(*text.Message)
|
||||
}
|
||||
|
||||
func (a *ScriptAttributes) SetValue(value interface{}) {
|
||||
a.Source, _ = value.(string)
|
||||
}
|
||||
|
||||
func (a *InputAttributes) GetValue() interface{} {
|
||||
return a.FieldValue
|
||||
}
|
||||
|
||||
func (a *ImageAttributes) GetValue() interface{} {
|
||||
return a.Source
|
||||
}
|
||||
|
||||
func (a *AnchorAttributes) GetValue() interface{} {
|
||||
return a.HREF
|
||||
}
|
||||
|
||||
func (a *TextAttributes) GetValue() interface{} {
|
||||
return a.Text
|
||||
}
|
||||
|
||||
func (a *ScriptAttributes) GetValue() interface{} {
|
||||
return a.Source
|
||||
}
|
||||
|
||||
func (a *InputAttributes) Reset() {
|
||||
a.FieldValue = nil
|
||||
}
|
||||
|
||||
func (a *ImageAttributes) Reset() {
|
||||
}
|
||||
|
||||
func (a *AnchorAttributes) Reset() {
|
||||
}
|
||||
|
||||
func (a *TextAttributes) Reset() {
|
||||
}
|
||||
|
||||
func (a *ScriptAttributes) Reset() {
|
||||
}
|
||||
|
||||
func (a *InputAttributes) GetNodeType() UiNodeType {
|
||||
return UiNodeType(a.NodeType)
|
||||
}
|
||||
|
||||
func (a *ImageAttributes) GetNodeType() UiNodeType {
|
||||
return UiNodeType(a.NodeType)
|
||||
}
|
||||
|
||||
func (a *AnchorAttributes) GetNodeType() UiNodeType {
|
||||
return UiNodeType(a.NodeType)
|
||||
}
|
||||
|
||||
func (a *TextAttributes) GetNodeType() UiNodeType {
|
||||
return UiNodeType(a.NodeType)
|
||||
}
|
||||
|
||||
func (a *ScriptAttributes) GetNodeType() UiNodeType {
|
||||
return UiNodeType(a.NodeType)
|
||||
}
|
||||
251
ui/node/attributes_input.go
Normal file
251
ui/node/attributes_input.go
Normal file
@@ -0,0 +1,251 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/x/jsonschemax"
|
||||
)
|
||||
|
||||
const DisableFormField = "disableFormField"
|
||||
|
||||
// maxEnumOptions caps how many `enum` values a single identity-schema
|
||||
// property may surface as a selectable option on a UI node. The cap bounds
|
||||
// the size of flow responses when an operator (accidentally or otherwise)
|
||||
// declares a very large enum, so a single misconfigured schema cannot
|
||||
// inflate every active registration/settings/login flow on a tenant.
|
||||
const maxEnumOptions = 500
|
||||
|
||||
func toFormType(n string, i interface{}) UiNodeInputAttributeType {
|
||||
switch n {
|
||||
case nosurfx.CSRFTokenName:
|
||||
return InputAttributeTypeHidden
|
||||
case "password":
|
||||
return InputAttributeTypePassword
|
||||
}
|
||||
|
||||
switch i.(type) {
|
||||
case float64, int64, int32, float32, json.Number:
|
||||
return InputAttributeTypeNumber
|
||||
case bool:
|
||||
return InputAttributeTypeCheckbox
|
||||
}
|
||||
|
||||
return InputAttributeTypeText
|
||||
}
|
||||
|
||||
type (
|
||||
InputAttributesModifier func(attributes *InputAttributes)
|
||||
InputAttributesModifiers []InputAttributesModifier
|
||||
)
|
||||
|
||||
func WithRequiredInputAttribute(a *InputAttributes) {
|
||||
a.Required = true
|
||||
}
|
||||
|
||||
func WithMaxLengthInputAttribute(maxLength int) func(a *InputAttributes) {
|
||||
return func(a *InputAttributes) {
|
||||
a.MaxLength = maxLength
|
||||
}
|
||||
}
|
||||
|
||||
func WithInputAttributes(f func(a *InputAttributes)) func(a *InputAttributes) {
|
||||
return func(a *InputAttributes) {
|
||||
f(a)
|
||||
}
|
||||
}
|
||||
|
||||
func applyInputAttributes(opts []InputAttributesModifier, attributes *InputAttributes) *InputAttributes {
|
||||
for _, f := range opts {
|
||||
f(attributes)
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
type (
|
||||
ImageAttributesModifier func(attributes *ImageAttributes)
|
||||
ImageAttributesModifiers []ImageAttributesModifier
|
||||
)
|
||||
|
||||
func WithImageAttributes(f func(a *ImageAttributes)) func(a *ImageAttributes) {
|
||||
return func(a *ImageAttributes) {
|
||||
f(a)
|
||||
}
|
||||
}
|
||||
|
||||
func applyImageAttributes(opts ImageAttributesModifiers, attributes *ImageAttributes) *ImageAttributes {
|
||||
for _, f := range opts {
|
||||
f(attributes)
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
type (
|
||||
ScriptAttributesModifier func(attributes *ScriptAttributes)
|
||||
ScriptAttributesModifiers []ScriptAttributesModifier
|
||||
)
|
||||
|
||||
func applyScriptAttributes(opts ScriptAttributesModifiers, attributes *ScriptAttributes) *ScriptAttributes {
|
||||
for _, f := range opts {
|
||||
f(attributes)
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
type (
|
||||
DivisionAttributesModifier func(attributes *DivisionAttributes)
|
||||
DivisionAttributesModifiers []DivisionAttributesModifier
|
||||
)
|
||||
|
||||
func WithDivisionAttributes(f func(a *DivisionAttributes)) func(a *DivisionAttributes) {
|
||||
return func(a *DivisionAttributes) {
|
||||
f(a)
|
||||
}
|
||||
}
|
||||
|
||||
func applyDivisionAttributes(opts DivisionAttributesModifiers, attributes *DivisionAttributes) *DivisionAttributes {
|
||||
for _, f := range opts {
|
||||
f(attributes)
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
func NewInputFieldFromJSON(name string, value interface{}, group UiNodeGroup, opts ...InputAttributesModifier) *Node {
|
||||
return &Node{
|
||||
Type: Input,
|
||||
Group: group,
|
||||
Attributes: applyInputAttributes(opts, &InputAttributes{Name: name, Type: toFormType(name, value), FieldValue: value}),
|
||||
Meta: &Meta{},
|
||||
}
|
||||
}
|
||||
|
||||
func NewInputField(name string, value interface{}, group UiNodeGroup, inputType UiNodeInputAttributeType, opts ...InputAttributesModifier) *Node {
|
||||
return &Node{
|
||||
Type: Input,
|
||||
Group: group,
|
||||
Attributes: applyInputAttributes(opts, &InputAttributes{Name: name, Type: inputType, FieldValue: value}),
|
||||
Meta: &Meta{},
|
||||
}
|
||||
}
|
||||
|
||||
func NewImageField(id string, src string, group UiNodeGroup, opts ...ImageAttributesModifier) *Node {
|
||||
return &Node{
|
||||
Type: Image,
|
||||
Group: group,
|
||||
Attributes: applyImageAttributes(opts, &ImageAttributes{Source: src, Identifier: id}),
|
||||
Meta: &Meta{},
|
||||
}
|
||||
}
|
||||
|
||||
func NewTextField(id string, text *text.Message, group UiNodeGroup) *Node {
|
||||
return &Node{
|
||||
Type: Text,
|
||||
Group: group,
|
||||
Attributes: &TextAttributes{Text: text, Identifier: id},
|
||||
Meta: &Meta{},
|
||||
}
|
||||
}
|
||||
|
||||
func NewDivisionField(id string, group UiNodeGroup, opts ...DivisionAttributesModifier) *Node {
|
||||
return &Node{
|
||||
Type: Division,
|
||||
Group: group,
|
||||
Attributes: applyDivisionAttributes(opts, &DivisionAttributes{Identifier: id}),
|
||||
Meta: &Meta{},
|
||||
}
|
||||
}
|
||||
|
||||
func NewAnchorField(id string, href string, group UiNodeGroup, title *text.Message) *Node {
|
||||
return &Node{
|
||||
Type: Anchor,
|
||||
Group: group,
|
||||
Attributes: &AnchorAttributes{Title: title, HREF: href, Identifier: id},
|
||||
Meta: &Meta{},
|
||||
}
|
||||
}
|
||||
|
||||
func NewScriptField(name string, src string, group UiNodeGroup, integrity string, opts ...ScriptAttributesModifier) *Node {
|
||||
return &Node{
|
||||
Type: Script,
|
||||
Group: group,
|
||||
Attributes: applyScriptAttributes(opts, &ScriptAttributes{
|
||||
Identifier: name,
|
||||
Type: "text/javascript",
|
||||
Source: src,
|
||||
Async: true,
|
||||
ReferrerPolicy: "no-referrer",
|
||||
CrossOrigin: "anonymous",
|
||||
Integrity: integrity,
|
||||
Nonce: x.NewUUID().String(),
|
||||
}),
|
||||
Meta: &Meta{},
|
||||
}
|
||||
}
|
||||
|
||||
func NewInputFieldFromSchema(name string, group UiNodeGroup, p jsonschemax.Path, opts ...InputAttributesModifier) *Node {
|
||||
attr := &InputAttributes{
|
||||
Name: name,
|
||||
Type: toFormType(p.Name, p.Type),
|
||||
Required: p.Required,
|
||||
}
|
||||
|
||||
// If format is set, we can make a more distinct decision:
|
||||
switch p.Format {
|
||||
case "date-time":
|
||||
attr.Type = InputAttributeTypeDateTimeLocal
|
||||
case "email":
|
||||
attr.Type = InputAttributeTypeEmail
|
||||
attr.Autocomplete = InputAttributeAutocompleteEmail
|
||||
case "tel":
|
||||
attr.Type = InputAttributeTypeTel
|
||||
attr.Autocomplete = InputAttributeAutocompleteTel
|
||||
case "date":
|
||||
attr.Type = InputAttributeTypeDate
|
||||
case "uri":
|
||||
attr.Type = InputAttributeTypeURI
|
||||
attr.Autocomplete = InputAttributeAutocompleteUrl
|
||||
case "regex":
|
||||
attr.Type = InputAttributeTypeText
|
||||
}
|
||||
|
||||
// Enum values become selectable options. The presence of options is the
|
||||
// signal for consumers to render a dropdown instead of a free-form input.
|
||||
// Cap the number of options to keep a pathological schema from inflating
|
||||
// every flow response.
|
||||
if len(p.Enum) > 0 {
|
||||
n := min(len(p.Enum), maxEnumOptions)
|
||||
attr.Options = make([]InputAttributesOption, n)
|
||||
for i, v := range p.Enum[:n] {
|
||||
attr.Options[i] = InputAttributesOption{Value: v}
|
||||
}
|
||||
}
|
||||
|
||||
// Other properties
|
||||
if p.Pattern != nil {
|
||||
attr.Pattern = p.Pattern.String()
|
||||
}
|
||||
|
||||
// Set disabled if the custom property is set
|
||||
if isDisabled, ok := p.CustomProperties[DisableFormField]; ok {
|
||||
if isDisabled, ok := isDisabled.(bool); ok {
|
||||
attr.Disabled = isDisabled
|
||||
}
|
||||
}
|
||||
|
||||
var meta Meta
|
||||
if len(p.Title) > 0 {
|
||||
meta.Label = text.NewInfoNodeLabelGenerated(p.Title, name)
|
||||
}
|
||||
|
||||
return &Node{
|
||||
Type: Input,
|
||||
Attributes: applyInputAttributes(opts, attr),
|
||||
Group: group,
|
||||
Meta: &meta,
|
||||
}
|
||||
}
|
||||
19
ui/node/attributes_input_csrf.go
Normal file
19
ui/node/attributes_input_csrf.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package node
|
||||
|
||||
import "github.com/ory/kratos/x/nosurfx"
|
||||
|
||||
func NewCSRFNode(token string) *Node {
|
||||
return &Node{
|
||||
Type: Input,
|
||||
Group: DefaultGroup,
|
||||
Attributes: &InputAttributes{
|
||||
Name: nosurfx.CSRFTokenName,
|
||||
Type: InputAttributeTypeHidden,
|
||||
FieldValue: token,
|
||||
Required: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
100
ui/node/attributes_input_test.go
Normal file
100
ui/node/attributes_input_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
"github.com/ory/x/jsonschemax"
|
||||
)
|
||||
|
||||
func TestFieldFromPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := t.Context()
|
||||
t.Run("all properties are properly transferred", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema, err := os.ReadFile("./fixtures/all_formats.schema.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
c := jsonschema.NewCompiler()
|
||||
require.NoError(t, c.AddResource("test.json", bytes.NewBuffer(schema)))
|
||||
|
||||
paths, err := jsonschemax.ListPaths(ctx, "test.json", c)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, path := range paths {
|
||||
node := NewInputFieldFromSchema(path.Name, DefaultGroup, path)
|
||||
assert.EqualValues(t, "input", node.Type)
|
||||
require.IsType(t, new(InputAttributes), node.Attributes)
|
||||
attr := node.Attributes.(*InputAttributes)
|
||||
|
||||
assert.EqualValues(t, gjson.GetBytes(schema, fmt.Sprintf("properties.%s.test_expected_type", path.Name)).String(), attr.Type)
|
||||
assert.True(t, !gjson.GetBytes(schema, fmt.Sprintf("properties.%s.test_expected_pattern", path.Name)).Exists() ||
|
||||
(gjson.GetBytes(schema, fmt.Sprintf("properties.%s.test_expected_pattern", path.Name)).Bool() && attr.Pattern != ""))
|
||||
|
||||
expectedAutocomplete := gjson.GetBytes(schema, fmt.Sprintf("properties.%s.test_expected_autocomplete", path.Name))
|
||||
|
||||
if expectedAutocomplete.Exists() {
|
||||
assert.EqualValues(t, expectedAutocomplete.String(), attr.Autocomplete)
|
||||
}
|
||||
|
||||
expectedOptions := gjson.GetBytes(schema, fmt.Sprintf("properties.%s.test_expected_options", path.Name))
|
||||
if expectedOptions.Exists() {
|
||||
// Compare through JSON so string, number, and boolean
|
||||
// option values all survive the round-trip without relying
|
||||
// on a lossy `%v` coercion.
|
||||
var got []any
|
||||
for _, o := range attr.Options {
|
||||
got = append(got, o.Value)
|
||||
}
|
||||
gotJSON, err := json.Marshal(got)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, expectedOptions.Raw, string(gotJSON), "field %s", path.Name)
|
||||
} else {
|
||||
assert.Empty(t, attr.Options, "field %s should have no options", path.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("caps enum options at maxEnumOptions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
enum := make([]any, maxEnumOptions+50)
|
||||
for i := range enum {
|
||||
enum[i] = fmt.Sprintf("v%d", i)
|
||||
}
|
||||
node := NewInputFieldFromSchema("field", DefaultGroup, jsonschemax.Path{
|
||||
Name: "field",
|
||||
Type: "",
|
||||
Enum: enum,
|
||||
})
|
||||
attr := node.Attributes.(*InputAttributes)
|
||||
assert.Len(t, attr.Options, maxEnumOptions)
|
||||
assert.Equal(t, "v0", attr.Options[0].Value)
|
||||
assert.Equal(t, fmt.Sprintf("v%d", maxEnumOptions-1), attr.Options[maxEnumOptions-1].Value)
|
||||
})
|
||||
|
||||
t.Run("empty enum produces no options", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node := NewInputFieldFromSchema("field", DefaultGroup, jsonschemax.Path{
|
||||
Name: "field",
|
||||
Type: "",
|
||||
Enum: []any{},
|
||||
})
|
||||
attr := node.Attributes.(*InputAttributes)
|
||||
assert.Empty(t, attr.Options)
|
||||
})
|
||||
}
|
||||
124
ui/node/attributes_test.go
Normal file
124
ui/node/attributes_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/x/jsonx"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIDs(t *testing.T) {
|
||||
assert.EqualValues(t, "foo", (&AnchorAttributes{Identifier: "foo"}).ID())
|
||||
assert.EqualValues(t, "foo", (&ImageAttributes{Identifier: "foo"}).ID())
|
||||
assert.EqualValues(t, "foo", (&TextAttributes{Identifier: "foo"}).ID())
|
||||
assert.EqualValues(t, "foo", (&InputAttributes{Name: "foo"}).ID())
|
||||
assert.EqualValues(t, "foo", (&ScriptAttributes{Identifier: "foo"}).ID())
|
||||
}
|
||||
|
||||
func TestMatchesAnchorAttributes(t *testing.T) {
|
||||
assert.True(t, (&AnchorAttributes{Identifier: "foo"}).Matches(&AnchorAttributes{Identifier: "foo"}))
|
||||
assert.True(t, (&AnchorAttributes{HREF: "bar"}).Matches(&AnchorAttributes{HREF: "bar"}))
|
||||
assert.False(t, (&AnchorAttributes{HREF: "foo"}).Matches(&AnchorAttributes{HREF: "bar"}))
|
||||
assert.False(t, (&AnchorAttributes{Identifier: "foo"}).Matches(&AnchorAttributes{HREF: "bar"}))
|
||||
|
||||
assert.True(t, (&AnchorAttributes{Identifier: "foo", HREF: "bar"}).Matches(&AnchorAttributes{Identifier: "foo", HREF: "bar"}))
|
||||
assert.False(t, (&AnchorAttributes{Identifier: "foo", HREF: "bar"}).Matches(&AnchorAttributes{Identifier: "foo", HREF: "baz"}))
|
||||
assert.False(t, (&AnchorAttributes{Identifier: "foo", HREF: "bar"}).Matches(&AnchorAttributes{Identifier: "bar", HREF: "bar"}))
|
||||
|
||||
assert.False(t, (&AnchorAttributes{Identifier: "foo"}).Matches(&TextAttributes{Identifier: "foo"}))
|
||||
}
|
||||
|
||||
func TestMatchesImageAttributes(t *testing.T) {
|
||||
assert.True(t, (&ImageAttributes{Identifier: "foo"}).Matches(&ImageAttributes{Identifier: "foo"}))
|
||||
assert.True(t, (&ImageAttributes{Source: "bar"}).Matches(&ImageAttributes{Source: "bar"}))
|
||||
assert.False(t, (&ImageAttributes{Source: "foo"}).Matches(&ImageAttributes{Source: "bar"}))
|
||||
assert.False(t, (&ImageAttributes{Identifier: "foo"}).Matches(&ImageAttributes{Source: "bar"}))
|
||||
|
||||
assert.True(t, (&ImageAttributes{Identifier: "foo", Source: "bar"}).Matches(&ImageAttributes{Identifier: "foo", Source: "bar"}))
|
||||
assert.False(t, (&ImageAttributes{Identifier: "foo", Source: "bar"}).Matches(&ImageAttributes{Identifier: "foo", Source: "baz"}))
|
||||
assert.False(t, (&ImageAttributes{Identifier: "foo", Source: "bar"}).Matches(&ImageAttributes{Identifier: "bar", Source: "bar"}))
|
||||
|
||||
assert.False(t, (&ImageAttributes{Identifier: "foo"}).Matches(&TextAttributes{Identifier: "foo"}))
|
||||
}
|
||||
|
||||
func TestMatchesInputAttributes(t *testing.T) {
|
||||
// Test when other is not of type *InputAttributes
|
||||
var attr Attributes = &ImageAttributes{}
|
||||
inputAttr := &InputAttributes{Name: "foo"}
|
||||
assert.False(t, inputAttr.Matches(attr))
|
||||
|
||||
// Test when ID is different
|
||||
attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText}
|
||||
inputAttr = &InputAttributes{Name: "bar", Type: InputAttributeTypeText}
|
||||
assert.False(t, inputAttr.Matches(attr))
|
||||
|
||||
// Test when Type is different
|
||||
attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText}
|
||||
inputAttr = &InputAttributes{Name: "foo", Type: InputAttributeTypeNumber}
|
||||
assert.False(t, inputAttr.Matches(attr))
|
||||
|
||||
// Test when FieldValue is different
|
||||
attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText, FieldValue: "bar"}
|
||||
inputAttr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText, FieldValue: "baz"}
|
||||
assert.False(t, inputAttr.Matches(attr))
|
||||
|
||||
// Test when Name is different
|
||||
attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText}
|
||||
inputAttr = &InputAttributes{Name: "bar", Type: InputAttributeTypeText}
|
||||
assert.False(t, inputAttr.Matches(attr))
|
||||
|
||||
// Test when all fields are the same
|
||||
attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText, FieldValue: "bar"}
|
||||
inputAttr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText, FieldValue: "bar"}
|
||||
assert.True(t, inputAttr.Matches(attr))
|
||||
}
|
||||
|
||||
func TestMatchesTextAttributes(t *testing.T) {
|
||||
assert.True(t, (&TextAttributes{Identifier: "foo"}).Matches(&TextAttributes{Identifier: "foo"}))
|
||||
assert.True(t, (&TextAttributes{Identifier: "foo"}).Matches(&TextAttributes{Identifier: "foo"}))
|
||||
assert.False(t, (&TextAttributes{Identifier: "foo"}).Matches(&ImageAttributes{Identifier: "foo"}))
|
||||
}
|
||||
|
||||
func TestNodeEncode(t *testing.T) {
|
||||
script := jsonx.TestMarshalJSONString(t, &Node{Attributes: &ScriptAttributes{}})
|
||||
assert.EqualValues(t, Script, gjson.Get(script, "attributes.node_type").String())
|
||||
assert.EqualValues(t, Script, gjson.Get(script, "type").String())
|
||||
|
||||
text := jsonx.TestMarshalJSONString(t, &Node{Attributes: &TextAttributes{}})
|
||||
assert.EqualValues(t, Text, gjson.Get(text, "attributes.node_type").String())
|
||||
assert.EqualValues(t, Text, gjson.Get(text, "type").String())
|
||||
|
||||
image := jsonx.TestMarshalJSONString(t, &Node{Attributes: &ImageAttributes{}})
|
||||
assert.EqualValues(t, Image, gjson.Get(image, "attributes.node_type").String())
|
||||
assert.EqualValues(t, Image, gjson.Get(image, "type").String())
|
||||
|
||||
input := jsonx.TestMarshalJSONString(t, &Node{Attributes: &InputAttributes{}})
|
||||
assert.EqualValues(t, Input, gjson.Get(input, "attributes.node_type").String())
|
||||
assert.EqualValues(t, Input, gjson.Get(input, "type").String())
|
||||
|
||||
anchor := jsonx.TestMarshalJSONString(t, &Node{Attributes: &AnchorAttributes{}})
|
||||
assert.EqualValues(t, Anchor, gjson.Get(anchor, "attributes.node_type").String())
|
||||
assert.EqualValues(t, Anchor, gjson.Get(anchor, "type").String())
|
||||
}
|
||||
|
||||
func TestNodeDecode(t *testing.T) {
|
||||
for _, kind := range []UiNodeType{
|
||||
Text,
|
||||
Input,
|
||||
Image,
|
||||
Anchor,
|
||||
Script,
|
||||
} {
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"type":"`+kind+`"}`), &n))
|
||||
assert.EqualValues(t, kind, n.Attributes.GetNodeType())
|
||||
}
|
||||
}
|
||||
92
ui/node/fixtures/all_formats.schema.json
Normal file
92
ui/node/fixtures/all_formats.schema.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"$id": "https://example.com/all_formats.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string",
|
||||
"test_expected_type": "password"
|
||||
},
|
||||
"csrf_token": {
|
||||
"type": "string",
|
||||
"test_expected_type": "hidden"
|
||||
},
|
||||
"simpleString": {
|
||||
"type": "string",
|
||||
"test_expected_type": "text"
|
||||
},
|
||||
"simpleNumber": {
|
||||
"type": "number",
|
||||
"test_expected_type": "number"
|
||||
},
|
||||
"simpleBoolean": {
|
||||
"type": "boolean",
|
||||
"test_expected_type": "checkbox"
|
||||
},
|
||||
"emailString": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"test_expected_type": "email",
|
||||
"test_expected_autocomplete": "email"
|
||||
},
|
||||
"phoneString": {
|
||||
"type": "string",
|
||||
"format": "tel",
|
||||
"test_expected_type": "tel",
|
||||
"test_expected_autocomplete": "tel"
|
||||
},
|
||||
"dateTimeString": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"test_expected_type": "datetime-local"
|
||||
},
|
||||
"regexString": {
|
||||
"type": "string",
|
||||
"format": "regex",
|
||||
"test_expected_type": "text"
|
||||
},
|
||||
"dateString": {
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"test_expected_type": "date"
|
||||
},
|
||||
"uriString": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"test_expected_type": "url",
|
||||
"test_expected_autocomplete": "url"
|
||||
},
|
||||
"patternString": {
|
||||
"type": "string",
|
||||
"pattern": "foo.*",
|
||||
"test_expected_type": "text",
|
||||
"test_expected_pattern": true
|
||||
},
|
||||
"enumString": {
|
||||
"type": "string",
|
||||
"enum": ["one", "two", "three"],
|
||||
"test_expected_type": "text",
|
||||
"test_expected_options": ["one", "two", "three"]
|
||||
},
|
||||
"enumNumber": {
|
||||
"type": "number",
|
||||
"enum": [1, 2.5, 3],
|
||||
"test_expected_type": "number",
|
||||
"test_expected_options": [1, 2.5, 3]
|
||||
},
|
||||
"enumBoolean": {
|
||||
"type": "boolean",
|
||||
"enum": [true, false],
|
||||
"test_expected_type": "checkbox",
|
||||
"test_expected_options": [true, false]
|
||||
},
|
||||
"enumEmail": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"enum": ["admin@example.com", "user@example.com"],
|
||||
"test_expected_type": "email",
|
||||
"test_expected_autocomplete": "email",
|
||||
"test_expected_options": ["admin@example.com", "user@example.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
52
ui/node/fixtures/complex.schema.json
Normal file
52
ui/node/fixtures/complex.schema.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"$id": "https://example.com/complex.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"required": ["meal"],
|
||||
"properties": {
|
||||
"meal": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"chef": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fruits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"vegetables": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/veggie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"veggie": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"veggieName",
|
||||
"veggieLike"
|
||||
],
|
||||
"properties": {
|
||||
"veggieName": {
|
||||
"type": "string"
|
||||
},
|
||||
"veggieLike": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"veggieAmount": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
ui/node/fixtures/identity.schema.json
Normal file
43
ui/node/fixtures/identity.schema.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"stringy": {
|
||||
"type": "string"
|
||||
},
|
||||
"numby": {
|
||||
"type": "number"
|
||||
},
|
||||
"booly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"should_big_number": {
|
||||
"type": "number",
|
||||
"minimum": 1200
|
||||
},
|
||||
"should_long_string": {
|
||||
"type": "string",
|
||||
"minLength": 25
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
ui/node/fixtures/simple.schema.json
Normal file
29
ui/node/fixtures/simple.schema.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stringy": {
|
||||
"type": "string"
|
||||
},
|
||||
"numby": {
|
||||
"type": "number"
|
||||
},
|
||||
"objy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stringy": {
|
||||
"type": "string"
|
||||
},
|
||||
"numby": {
|
||||
"type": "number"
|
||||
},
|
||||
"objy": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
ui/node/fixtures/sort/expected/1.json
Normal file
108
ui/node/fixtures/sort/expected/1.json
Normal file
@@ -0,0 +1,108 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"value": "Sk3WySQDVpL5V9IuQelZTXcRVbBE1STI0HAJEcHOnM0/xPz5p1piBzuBMKZWxgYJc5TN8k8BTM9Hazrf/n76pg==",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "github",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "google",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "hydra",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "oidc",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "password_identifier",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
]
|
||||
118
ui/node/fixtures/sort/expected/2.json
Normal file
118
ui/node/fixtures/sort/expected/2.json
Normal file
@@ -0,0 +1,118 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"value": "qomtJu6UOzh5Gqkq3MEwbbJuNgJEfDg9gEowfMH0OhjfAIcWbc0PrbvMS6LL7m8ptuuuQE+oUDoXUQOy/kRccw==",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "github",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "google",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "hydra",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "oidc",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "traits.abcd",
|
||||
"type": "input",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "traits.email",
|
||||
"type": "email",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
]
|
||||
105
ui/node/fixtures/sort/expected/3.json
Normal file
105
ui/node/fixtures/sort/expected/3.json
Normal file
@@ -0,0 +1,105 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"value": "qomtJu6UOzh5Gqkq3MEwbbJuNgJEfDg9gEowfMH0OhjfAIcWbc0PrbvMS6LL7m8ptuuuQE+oUDoXUQOy/kRccw==",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "github",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "google",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "hydra",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "oidc",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "traits.abcd",
|
||||
"type": "input",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "traits.email",
|
||||
"type": "email",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
]
|
||||
164
ui/node/fixtures/sort/expected/4.json
Normal file
164
ui/node/fixtures/sort/expected/4.json
Normal file
@@ -0,0 +1,164 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"value": "",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "profile",
|
||||
"attributes": {
|
||||
"name": "traits.email",
|
||||
"type": "email",
|
||||
"value": "john@doe.com",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "profile",
|
||||
"attributes": {
|
||||
"name": "traits.name",
|
||||
"type": "text",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "profile",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "profile",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070003,
|
||||
"text": "Save",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070001,
|
||||
"text": "Password",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070003,
|
||||
"text": "Save",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "link",
|
||||
"type": "submit",
|
||||
"value": "github",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050002,
|
||||
"text": "Link github",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"provider": "github"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "unlink",
|
||||
"type": "submit",
|
||||
"value": "google",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050003,
|
||||
"text": "Unlink google",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"provider": "google"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "link",
|
||||
"type": "submit",
|
||||
"value": "ory",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1050002,
|
||||
"text": "Link ory",
|
||||
"type": "info",
|
||||
"context": {
|
||||
"provider": "ory"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
92
ui/node/fixtures/sort/input/1.json
Normal file
92
ui/node/fixtures/sort/input/1.json
Normal file
@@ -0,0 +1,92 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "oidc",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "password_identifier",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"required": true,
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "google",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "github",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"value": "Sk3WySQDVpL5V9IuQelZTXcRVbBE1STI0HAJEcHOnM0/xPz5p1piBzuBMKZWxgYJc5TN8k8BTM9Hazrf/n76pg==",
|
||||
"required": true,
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "hydra",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
}
|
||||
]
|
||||
100
ui/node/fixtures/sort/input/2.json
Normal file
100
ui/node/fixtures/sort/input/2.json
Normal file
@@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "password",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "oidc",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"value": "qomtJu6UOzh5Gqkq3MEwbbJuNgJEfDg9gEowfMH0OhjfAIcWbc0PrbvMS6LL7m8ptuuuQE+oUDoXUQOy/kRccw==",
|
||||
"required": true,
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "traits.email",
|
||||
"type": "email",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "github",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "google",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "hydra",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "traits.abcd",
|
||||
"type": "input",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
}
|
||||
]
|
||||
89
ui/node/fixtures/sort/input/3.json
Normal file
89
ui/node/fixtures/sort/input/3.json
Normal file
@@ -0,0 +1,89 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "oidc",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"value": "qomtJu6UOzh5Gqkq3MEwbbJuNgJEfDg9gEowfMH0OhjfAIcWbc0PrbvMS6LL7m8ptuuuQE+oUDoXUQOy/kRccw==",
|
||||
"required": true,
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "traits.email",
|
||||
"type": "email",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "github",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "google",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "oidc",
|
||||
"attributes": {
|
||||
"name": "oidc.provider",
|
||||
"type": "submit",
|
||||
"value": "hydra",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "password",
|
||||
"attributes": {
|
||||
"name": "traits.abcd",
|
||||
"type": "input",
|
||||
"disabled": false
|
||||
},
|
||||
"messages": null
|
||||
}
|
||||
]
|
||||
155
ui/node/fixtures/sort/input/4.json
Normal file
155
ui/node/fixtures/sort/input/4.json
Normal file
@@ -0,0 +1,155 @@
|
||||
[
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "csrf_token",
|
||||
"required": true,
|
||||
"type": "hidden",
|
||||
"value": ""
|
||||
},
|
||||
"group": "default",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "traits.email",
|
||||
"type": "email",
|
||||
"value": "john@doe.com"
|
||||
},
|
||||
"group": "profile",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "traits.name",
|
||||
"type": "text"
|
||||
},
|
||||
"group": "profile",
|
||||
"messages": [],
|
||||
"meta": {},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "profile"
|
||||
},
|
||||
"group": "profile",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070003,
|
||||
"text": "Save",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "password",
|
||||
"required": true,
|
||||
"type": "password"
|
||||
},
|
||||
"group": "password",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070001,
|
||||
"text": "Password",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "password"
|
||||
},
|
||||
"group": "password",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070003,
|
||||
"text": "Save",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "link",
|
||||
"type": "submit",
|
||||
"value": "github"
|
||||
},
|
||||
"group": "oidc",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"context": {
|
||||
"provider": "github"
|
||||
},
|
||||
"id": 1050002,
|
||||
"text": "Link github",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "unlink",
|
||||
"type": "submit",
|
||||
"value": "google"
|
||||
},
|
||||
"group": "oidc",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"context": {
|
||||
"provider": "google"
|
||||
},
|
||||
"id": 1050003,
|
||||
"text": "Unlink google",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"attributes": {
|
||||
"disabled": false,
|
||||
"name": "link",
|
||||
"type": "submit",
|
||||
"value": "ory"
|
||||
},
|
||||
"group": "oidc",
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"context": {
|
||||
"provider": "ory"
|
||||
},
|
||||
"id": 1050002,
|
||||
"text": "Link ory",
|
||||
"type": "info"
|
||||
}
|
||||
},
|
||||
"type": "input"
|
||||
}
|
||||
]
|
||||
39
ui/node/fixtures/sort/schema/2.json
Normal file
39
ui/node/fixtures/sort/schema/2.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"abcd": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"title": "E-Mail",
|
||||
"minLength": 3,
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"website": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"minLength": 10
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"website"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
39
ui/node/fixtures/sort/schema/3.json
Normal file
39
ui/node/fixtures/sort/schema/3.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"abcd": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"title": "E-Mail",
|
||||
"minLength": 3,
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"website": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"minLength": 10
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"website"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
32
ui/node/fixtures/sort/schema/4.json
Normal file
32
ui/node/fixtures/sort/schema/4.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$id": "https://example.com/person.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"format": "email",
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 2
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
14
ui/node/helper.go
Normal file
14
ui/node/helper.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package node
|
||||
|
||||
func PasswordLoginOrder(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return []string{"csrf_token", "password"}
|
||||
}
|
||||
if len(in) == 1 {
|
||||
return append([]string{"csrf_token"}, in[0], "password")
|
||||
}
|
||||
return append([]string{"csrf_token", in[0], "password"}, in[1:]...)
|
||||
}
|
||||
50
ui/node/identifiers.go
Normal file
50
ui/node/identifiers.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package node
|
||||
|
||||
const (
|
||||
TOTPCode = "totp_code"
|
||||
TOTPSecretKey = "totp_secret_key"
|
||||
TOTPQR = "totp_qr"
|
||||
TOTPUnlink = "totp_unlink"
|
||||
)
|
||||
|
||||
const (
|
||||
LookupReveal = "lookup_secret_reveal"
|
||||
LookupRegenerate = "lookup_secret_regenerate"
|
||||
LookupDisable = "lookup_secret_disable"
|
||||
LookupCodes = "lookup_secret_codes"
|
||||
LookupConfirm = "lookup_secret_confirm"
|
||||
LookupCodeEnter = "lookup_secret"
|
||||
)
|
||||
|
||||
const (
|
||||
ProfileChooseCredentials = "profile_choose_credentials"
|
||||
)
|
||||
|
||||
const (
|
||||
WebAuthnRegisterTrigger = "webauthn_register_trigger"
|
||||
WebAuthnRegister = "webauthn_register"
|
||||
WebAuthnLogin = "webauthn_login"
|
||||
WebAuthnLoginTrigger = "webauthn_login_trigger"
|
||||
WebAuthnRegisterDisplayName = "webauthn_register_displayname"
|
||||
WebAuthnRemove = "webauthn_remove"
|
||||
WebAuthnScript = "webauthn_script"
|
||||
)
|
||||
|
||||
const (
|
||||
DeviceAuthnRemove = "deviceauthn_remove"
|
||||
DeviceAuthnNonce = "deviceauthn_nonce"
|
||||
)
|
||||
|
||||
const (
|
||||
PasskeyRegisterTrigger = "passkey_register_trigger"
|
||||
PasskeyRegister = "passkey_register"
|
||||
PasskeySettingsRegister = "passkey_settings_register"
|
||||
PasskeyCreateData = "passkey_create_data"
|
||||
PasskeyLogin = "passkey_login"
|
||||
PasskeyChallenge = "passkey_challenge"
|
||||
PasskeyLoginTrigger = "passkey_login_trigger" //#nosec G101 -- Not a credential
|
||||
PasskeyRemove = "passkey_remove"
|
||||
)
|
||||
577
ui/node/node.go
Normal file
577
ui/node/node.go
Normal file
@@ -0,0 +1,577 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/schema"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
)
|
||||
|
||||
// swagger:enum UiNodeType
|
||||
type UiNodeType string
|
||||
|
||||
const (
|
||||
Text UiNodeType = "text"
|
||||
Input UiNodeType = "input"
|
||||
Image UiNodeType = "img"
|
||||
Anchor UiNodeType = "a"
|
||||
Script UiNodeType = "script"
|
||||
Division UiNodeType = "div"
|
||||
)
|
||||
|
||||
func (t UiNodeType) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
// swagger:enum UiNodeGroup
|
||||
type UiNodeGroup string
|
||||
|
||||
const (
|
||||
DefaultGroup UiNodeGroup = "default"
|
||||
PasswordGroup UiNodeGroup = "password"
|
||||
OpenIDConnectGroup UiNodeGroup = "oidc"
|
||||
ProfileGroup UiNodeGroup = "profile"
|
||||
LinkGroup UiNodeGroup = "link"
|
||||
CodeGroup UiNodeGroup = "code"
|
||||
TOTPGroup UiNodeGroup = "totp"
|
||||
LookupGroup UiNodeGroup = "lookup_secret"
|
||||
WebAuthnGroup UiNodeGroup = "webauthn"
|
||||
PasskeyGroup UiNodeGroup = "passkey"
|
||||
IdentifierFirstGroup UiNodeGroup = "identifier_first"
|
||||
CaptchaGroup UiNodeGroup = "captcha" // Available in OEL
|
||||
SAMLGroup UiNodeGroup = "saml" // Available in OEL
|
||||
DeviceAuthnGroup UiNodeGroup = "deviceauthn" // Available in OEL
|
||||
)
|
||||
|
||||
func (g UiNodeGroup) String() string {
|
||||
return string(g)
|
||||
}
|
||||
|
||||
// swagger:model uiNodes
|
||||
type Nodes []*Node
|
||||
|
||||
// Node represents a flow's nodes
|
||||
//
|
||||
// Nodes are represented as HTML elements or their native UI equivalents. For example,
|
||||
// a node can be an `<img>` tag, or an `<input element>` but also `some plain text`.
|
||||
//
|
||||
// swagger:model uiNode
|
||||
type Node struct {
|
||||
// The node's type
|
||||
//
|
||||
// required: true
|
||||
Type UiNodeType `json:"type" faker:"-"`
|
||||
|
||||
// Group specifies which group (e.g. password authenticator) this node belongs to.
|
||||
//
|
||||
// required: true
|
||||
Group UiNodeGroup `json:"group"`
|
||||
|
||||
// The node's attributes.
|
||||
//
|
||||
// required: true
|
||||
// swagger:type uiNodeAttributes
|
||||
Attributes Attributes `json:"attributes" faker:"ui_node_attributes"`
|
||||
|
||||
// The node's messages
|
||||
//
|
||||
// Contains error, validation, or other messages relevant to this node.
|
||||
//
|
||||
// required: true
|
||||
Messages text.Messages `json:"messages"`
|
||||
|
||||
// Meta contains a node meta information
|
||||
//
|
||||
// This might include a label and other information that can optionally
|
||||
// be used to render UIs.
|
||||
//
|
||||
// required: true
|
||||
Meta *Meta `json:"meta"`
|
||||
}
|
||||
|
||||
// A Node's Meta Information
|
||||
//
|
||||
// This might include a label and other information that can optionally
|
||||
// be used to render UIs.
|
||||
//
|
||||
// swagger:model uiNodeMeta
|
||||
type Meta struct {
|
||||
// Label represents the node's label.
|
||||
//
|
||||
// Keep in mind that these values are autogenerated and can not be changed.
|
||||
// If you wish to use other titles or labels implement that directly in
|
||||
// your UI.
|
||||
Label *text.Message `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// Used for en/decoding the Attributes field.
|
||||
type jsonRawNode struct {
|
||||
Type UiNodeType `json:"type"`
|
||||
Group UiNodeGroup `json:"group"`
|
||||
Attributes Attributes `json:"attributes"`
|
||||
Messages text.Messages `json:"messages"`
|
||||
Meta *Meta `json:"meta"`
|
||||
}
|
||||
|
||||
func (n *Node) ID() string {
|
||||
return n.Attributes.ID()
|
||||
}
|
||||
|
||||
func (n *Node) Reset() {
|
||||
n.Messages = nil
|
||||
n.Attributes.Reset()
|
||||
}
|
||||
|
||||
func (n *Node) WithMetaLabel(label *text.Message) *Node {
|
||||
if n.Meta == nil {
|
||||
n.Meta = new(Meta)
|
||||
}
|
||||
n.Meta.Label = label
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *Node) GetValue() interface{} {
|
||||
return n.Attributes.GetValue()
|
||||
}
|
||||
|
||||
func (n Nodes) Find(id string) *Node {
|
||||
for _, nn := range n {
|
||||
if nn.ID() == id {
|
||||
return nn
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n Nodes) FindAll(id string) []Node {
|
||||
var nodes []Node
|
||||
for _, nn := range n {
|
||||
if nn.ID() == id {
|
||||
nodes = append(nodes, *nn)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
func (n Nodes) Reset(exclude ...string) {
|
||||
for k, nn := range n {
|
||||
nn.Messages = nil
|
||||
if !slices.Contains(exclude, nn.ID()) {
|
||||
nn.Reset()
|
||||
}
|
||||
n[k] = nn
|
||||
}
|
||||
}
|
||||
|
||||
func (n Nodes) ResetNodes(reset ...string) {
|
||||
for k, nn := range n {
|
||||
if slices.Contains(reset, nn.ID()) {
|
||||
nn.Reset()
|
||||
}
|
||||
n[k] = nn
|
||||
}
|
||||
}
|
||||
|
||||
func (n Nodes) ResetNodesWithPrefix(prefix string) {
|
||||
for k := range n {
|
||||
if strings.HasPrefix(n[k].ID(), prefix) {
|
||||
n[k].Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getStringSliceIndexOf(needle []string, haystack string) int {
|
||||
for k := range needle {
|
||||
if needle[k] == haystack {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
type sortOptions struct {
|
||||
orderByGroups []string
|
||||
schemaRef string
|
||||
keysInOrder []string
|
||||
keysInOrderAppend []string
|
||||
keysInOrderPost func([]string) []string
|
||||
stableGroups []string
|
||||
}
|
||||
|
||||
type SortOption func(*sortOptions)
|
||||
|
||||
func SortByGroups(orderByGroups []UiNodeGroup) func(*sortOptions) {
|
||||
return func(options *sortOptions) {
|
||||
options.orderByGroups = make([]string, len(orderByGroups))
|
||||
for k := range orderByGroups {
|
||||
options.orderByGroups[k] = string(orderByGroups[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SortBySchema(schemaRef string) func(*sortOptions) {
|
||||
return func(options *sortOptions) {
|
||||
options.schemaRef = schemaRef
|
||||
}
|
||||
}
|
||||
|
||||
func SortUseOrder(keysInOrder []string) func(*sortOptions) {
|
||||
return func(options *sortOptions) {
|
||||
options.keysInOrder = keysInOrder
|
||||
}
|
||||
}
|
||||
|
||||
func SortUseOrderAppend(keysInOrder []string) func(*sortOptions) {
|
||||
return func(options *sortOptions) {
|
||||
options.keysInOrderAppend = keysInOrder
|
||||
}
|
||||
}
|
||||
|
||||
func SortUpdateOrder(f func([]string) []string) func(*sortOptions) {
|
||||
return func(options *sortOptions) {
|
||||
options.keysInOrderPost = f
|
||||
}
|
||||
}
|
||||
|
||||
// SortStableGroups marks groups that arrive in a deterministic order and must
|
||||
// keep it: within such a group the sort preserves insertion order instead of
|
||||
// tiebreaking by value. Use it when the repeated nodes' only distinguishing
|
||||
// value is opaque and non-display, so value ordering would be meaningless and unstable.
|
||||
func SortStableGroups(groups ...UiNodeGroup) func(*sortOptions) {
|
||||
return func(options *sortOptions) {
|
||||
options.stableGroups = make([]string, len(groups))
|
||||
for k := range groups {
|
||||
options.stableGroups[k] = string(groups[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n Nodes) SortBySchema(ctx context.Context, opts ...SortOption) error {
|
||||
var o sortOptions
|
||||
for _, f := range opts {
|
||||
f(&o)
|
||||
}
|
||||
|
||||
if o.schemaRef != "" {
|
||||
schemaKeys, err := schema.GetKeysInOrder(ctx, o.schemaRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
o.keysInOrder = append(o.keysInOrder, schemaKeys...)
|
||||
}
|
||||
|
||||
if o.keysInOrderPost != nil {
|
||||
o.keysInOrder = o.keysInOrderPost(o.keysInOrder)
|
||||
}
|
||||
|
||||
o.keysInOrder = append(o.keysInOrder, o.keysInOrderAppend...)
|
||||
|
||||
getKeyPosition := func(node *Node) int {
|
||||
lastPrefix := len(o.keysInOrder)
|
||||
|
||||
// Method should always be the last element in the list
|
||||
if node.Attributes.ID() == "method" {
|
||||
return len(n) + len(o.keysInOrder) + 1
|
||||
}
|
||||
|
||||
for i, n := range o.keysInOrder {
|
||||
if strings.HasPrefix(node.ID(), n) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return lastPrefix
|
||||
}
|
||||
|
||||
if len(o.orderByGroups) > 0 {
|
||||
// Sort by groups so that default is in front, then oidc, password, ...
|
||||
// A stable sort keeps same-group nodes in their incoming order, so a
|
||||
// group listed in stableGroups is not reshuffled before the tiebreak
|
||||
// below has a chance to preserve it.
|
||||
sort.SliceStable(n, func(i, j int) bool {
|
||||
a := string(n[i].Group)
|
||||
b := string(n[j].Group)
|
||||
return getStringSliceIndexOf(o.orderByGroups, a) < getStringSliceIndexOf(o.orderByGroups, b)
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(n, func(i, j int) bool {
|
||||
a := n[i]
|
||||
b := n[j]
|
||||
|
||||
if a.Group == b.Group ||
|
||||
(a.Group == "default" && b.Group == "password") || (b.Group == "default" && a.Group == "password") {
|
||||
pa, pb := getKeyPosition(a), getKeyPosition(b)
|
||||
if pa < pb {
|
||||
return true
|
||||
} else if pa > pb {
|
||||
return false
|
||||
}
|
||||
|
||||
// A stable group provides its own deterministic order; keep it
|
||||
// rather than tiebreaking by the (meaningless, possibly unstable)
|
||||
// value.
|
||||
if a.Group == b.Group && slices.Contains(o.stableGroups, string(a.Group)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", a.GetValue()) < fmt.Sprintf("%v", b.GetValue())
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes one or more nodes by their IDs.
|
||||
func (n *Nodes) Remove(ids ...string) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var r Nodes
|
||||
for k, v := range *n {
|
||||
var found bool
|
||||
for _, needle := range ids {
|
||||
if (*n)[k].ID() == needle {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
r = append(r, v)
|
||||
}
|
||||
}
|
||||
*n = r
|
||||
}
|
||||
|
||||
// RemoveInGroup removes nodes whose ID is one of ids and that belong to the
|
||||
// given group. Unlike Remove, which matches on ID alone, this leaves nodes with
|
||||
// the same ID in other groups untouched — so one strategy clearing its own
|
||||
// link/unlink nodes does not clobber another strategy's nodes that share the
|
||||
// "link"/"unlink" input name but render in a different group.
|
||||
func (n *Nodes) RemoveInGroup(group UiNodeGroup, ids ...string) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var r Nodes
|
||||
for _, v := range *n {
|
||||
if v != nil && v.Group == group && slices.Contains(ids, v.ID()) {
|
||||
continue
|
||||
}
|
||||
r = append(r, v)
|
||||
}
|
||||
*n = r
|
||||
}
|
||||
|
||||
// RemoveGroup removes all nodes belonging to a specific group.
|
||||
func (n *Nodes) RemoveGroup(group UiNodeGroup) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var r Nodes
|
||||
for k, v := range *n {
|
||||
if (*n)[k] != nil && string((*n)[k].Group) != string(group) {
|
||||
r = append(r, v)
|
||||
}
|
||||
}
|
||||
*n = r
|
||||
}
|
||||
|
||||
// RemoveGroup removes all nodes belonging to a set of groups.
|
||||
func (n *Nodes) RemoveGroups(group ...UiNodeGroup) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, g := range group {
|
||||
n.RemoveGroup(g)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearTransientNodes removes all nodes that belong to transient groups.
|
||||
func (n *Nodes) ClearTransientNodes() {
|
||||
n.RemoveGroups(
|
||||
DefaultGroup,
|
||||
PasswordGroup,
|
||||
OpenIDConnectGroup,
|
||||
ProfileGroup,
|
||||
LinkGroup,
|
||||
CodeGroup,
|
||||
TOTPGroup,
|
||||
LookupGroup,
|
||||
WebAuthnGroup,
|
||||
PasskeyGroup,
|
||||
IdentifierFirstGroup,
|
||||
SAMLGroup,
|
||||
)
|
||||
}
|
||||
|
||||
// Upsert updates or appends a node.
|
||||
func (n *Nodes) Upsert(node *Node) {
|
||||
if n == nil {
|
||||
*n = append(*n, node)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range *n {
|
||||
if (*n)[i].ID() == node.ID() {
|
||||
(*n)[i] = node
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
*n = append(*n, node)
|
||||
}
|
||||
|
||||
// SetValueAttribute sets a node's attribute's value or returns false if no node is found.
|
||||
func (n *Nodes) SetValueAttribute(id string, value interface{}) bool {
|
||||
for i := range *n {
|
||||
if (*n)[i].ID() == id {
|
||||
(*n)[i].Attributes.SetValue(value)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Append appends a node.
|
||||
func (n *Nodes) Append(node *Node) {
|
||||
*n = append(*n, node)
|
||||
}
|
||||
|
||||
func (n *Nodes) RemoveMatching(node *Node) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r := Nodes{}
|
||||
for k, v := range *n {
|
||||
if !(*n)[k].Matches(node) {
|
||||
r = append(r, v)
|
||||
}
|
||||
}
|
||||
|
||||
*n = r
|
||||
}
|
||||
|
||||
func (n *Node) Matches(needle *Node) bool {
|
||||
if len(needle.ID()) > 0 && n.ID() != needle.ID() {
|
||||
return false
|
||||
}
|
||||
|
||||
if needle.Type != "" && n.Type != needle.Type {
|
||||
return false
|
||||
}
|
||||
|
||||
if needle.Group != "" && n.Group != needle.Group {
|
||||
return false
|
||||
}
|
||||
|
||||
return n.Attributes.Matches(needle.Attributes)
|
||||
}
|
||||
|
||||
func (n *Node) UnmarshalJSON(data []byte) error {
|
||||
var attr Attributes
|
||||
switch t := gjson.GetBytes(data, "type").String(); UiNodeType(t) {
|
||||
case Text:
|
||||
attr = &TextAttributes{
|
||||
NodeType: Text,
|
||||
}
|
||||
case Input:
|
||||
attr = &InputAttributes{
|
||||
NodeType: Input,
|
||||
}
|
||||
case Anchor:
|
||||
attr = &AnchorAttributes{
|
||||
NodeType: Anchor,
|
||||
}
|
||||
case Image:
|
||||
attr = &ImageAttributes{
|
||||
NodeType: Image,
|
||||
}
|
||||
case Script:
|
||||
attr = &ScriptAttributes{
|
||||
NodeType: Script,
|
||||
}
|
||||
case Division:
|
||||
attr = &DivisionAttributes{
|
||||
NodeType: Division,
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unexpected node type: %s", t)
|
||||
}
|
||||
|
||||
var d jsonRawNode
|
||||
d.Attributes = attr
|
||||
if err := json.NewDecoder(bytes.NewReader(data)).Decode(&d); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*n = Node(d)
|
||||
|
||||
if n.Meta == nil {
|
||||
n.Meta = new(Meta)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Node) MarshalJSON() ([]byte, error) {
|
||||
var t UiNodeType
|
||||
if n.Attributes != nil {
|
||||
switch attr := n.Attributes.(type) {
|
||||
case *TextAttributes:
|
||||
t = Text
|
||||
attr.NodeType = Text
|
||||
case *InputAttributes:
|
||||
t = Input
|
||||
attr.NodeType = Input
|
||||
case *AnchorAttributes:
|
||||
t = Anchor
|
||||
attr.NodeType = Anchor
|
||||
case *ImageAttributes:
|
||||
t = Image
|
||||
attr.NodeType = Image
|
||||
case *ScriptAttributes:
|
||||
t = Script
|
||||
attr.NodeType = Script
|
||||
case *DivisionAttributes:
|
||||
t = Division
|
||||
attr.NodeType = Division
|
||||
default:
|
||||
return nil, errors.WithStack(fmt.Errorf("unknown node type: %T", n.Attributes))
|
||||
}
|
||||
}
|
||||
|
||||
if n.Type == "" {
|
||||
n.Type = t
|
||||
} else if n.Type != t {
|
||||
return nil, errors.WithStack(fmt.Errorf("node type and node attributes mismatch: %T != %s", n.Attributes, n.Type))
|
||||
}
|
||||
|
||||
if n.Meta == nil {
|
||||
n.Meta = new(Meta)
|
||||
}
|
||||
|
||||
return json.Marshal((*jsonRawNode)(n))
|
||||
}
|
||||
560
ui/node/node_test.go
Normal file
560
ui/node/node_test.go
Normal file
@@ -0,0 +1,560 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package node_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/x/snapshotx"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
|
||||
"github.com/ory/x/assertx"
|
||||
|
||||
"github.com/ory/kratos/corpx"
|
||||
|
||||
"github.com/ory/kratos/ui/container"
|
||||
|
||||
"github.com/go-faker/faker/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
)
|
||||
|
||||
func init() {
|
||||
corpx.RegisterFakes()
|
||||
}
|
||||
|
||||
//go:embed fixtures/sort/*
|
||||
var sortFixtures embed.FS
|
||||
var ctx = context.Background()
|
||||
|
||||
func TestNodesSort(t *testing.T) {
|
||||
// use a schema compiler that disables identifiers
|
||||
schemaCompiler := jsonschema.NewCompiler()
|
||||
schemaPath := "fixtures/identity.schema.json"
|
||||
|
||||
f, err := container.NewFromJSONSchema(ctx, "/foo", node.DefaultGroup, schemaPath, "", schemaCompiler, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
f.UpdateNodeValuesFromJSON(json.RawMessage(`{}`), "traits", node.DefaultGroup)
|
||||
f.SetCSRF("csrf_token")
|
||||
|
||||
inputs, err := sortFixtures.ReadDir("fixtures/sort/input")
|
||||
require.NoError(t, err)
|
||||
|
||||
options := map[string][]node.SortOption{
|
||||
"1.json": {
|
||||
node.SortUseOrder([]string{"password_identifier"}),
|
||||
node.SortUpdateOrder(node.PasswordLoginOrder),
|
||||
node.SortByGroups([]node.UiNodeGroup{
|
||||
node.DefaultGroup,
|
||||
node.ProfileGroup,
|
||||
node.OpenIDConnectGroup,
|
||||
node.PasswordGroup,
|
||||
node.LinkGroup,
|
||||
node.LinkGroup,
|
||||
}),
|
||||
},
|
||||
"2.json": {
|
||||
node.SortBySchema(filepath.Join("fixtures/sort/schema", "2.json")),
|
||||
node.SortUpdateOrder(node.PasswordLoginOrder),
|
||||
node.SortByGroups([]node.UiNodeGroup{
|
||||
node.DefaultGroup,
|
||||
node.OpenIDConnectGroup,
|
||||
node.PasswordGroup,
|
||||
}),
|
||||
},
|
||||
"3.json": {
|
||||
node.SortBySchema(filepath.Join("fixtures/sort/schema", "3.json")),
|
||||
node.SortByGroups([]node.UiNodeGroup{
|
||||
node.DefaultGroup,
|
||||
node.OpenIDConnectGroup,
|
||||
node.PasswordGroup,
|
||||
}),
|
||||
},
|
||||
"4.json": {
|
||||
node.SortBySchema(filepath.Join("fixtures/sort/schema", "4.json")),
|
||||
node.SortByGroups([]node.UiNodeGroup{
|
||||
node.DefaultGroup,
|
||||
node.ProfileGroup,
|
||||
node.PasswordGroup,
|
||||
node.OpenIDConnectGroup,
|
||||
node.LookupGroup,
|
||||
node.WebAuthnGroup,
|
||||
node.TOTPGroup,
|
||||
}),
|
||||
node.SortUseOrderAppend([]string{
|
||||
// Lookup
|
||||
node.LookupReveal,
|
||||
node.LookupRegenerate,
|
||||
node.LookupCodes,
|
||||
node.LookupConfirm,
|
||||
|
||||
// Lookup
|
||||
node.WebAuthnRemove,
|
||||
node.WebAuthnRegisterDisplayName,
|
||||
node.WebAuthnRegister,
|
||||
|
||||
// TOTP
|
||||
node.TOTPSecretKey,
|
||||
node.TOTPQR,
|
||||
node.TOTPUnlink,
|
||||
node.TOTPCode,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
for _, in := range inputs {
|
||||
t.Run("file="+in.Name(), func(t *testing.T) {
|
||||
if in.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
fi, err := sortFixtures.Open(filepath.Join("fixtures/sort/input", in.Name()))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = fi.Close() }()
|
||||
|
||||
var nodes node.Nodes
|
||||
require.NoError(t, json.NewDecoder(fi).Decode(&nodes))
|
||||
require.NotEmpty(t, nodes)
|
||||
|
||||
require.NoError(t, nodes.SortBySchema(ctx, options[in.Name()]...))
|
||||
|
||||
fe, err := sortFixtures.ReadFile(filepath.Join("fixtures/sort/expected", in.Name()))
|
||||
require.NoError(t, err)
|
||||
|
||||
assertx.EqualAsJSON(t, json.RawMessage(fe), nodes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesSortStableGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Build count nodes in the same group, sharing an input name (as a list of
|
||||
// remove buttons does). Insertion order is 0..count-1, recorded in each
|
||||
// node's label; the value is the reverse, so a value tiebreak would flip the
|
||||
// order. count exceeds Go's insertion-sort threshold (12) so the sort takes
|
||||
// the quicksort path, where an unstable sort could otherwise reshuffle
|
||||
// same-group nodes.
|
||||
const count = 20
|
||||
build := func() node.Nodes {
|
||||
var n node.Nodes
|
||||
for i := range count {
|
||||
value := fmt.Sprintf("%02d", count-1-i)
|
||||
n = append(n, node.NewInputField(node.DeviceAuthnRemove, value, node.DeviceAuthnGroup, node.InputAttributeTypeSubmit).
|
||||
WithMetaLabel(&text.Message{Text: fmt.Sprintf("%02d", i)}))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
labels := func(n node.Nodes) []string {
|
||||
got := make([]string, 0, len(n))
|
||||
for _, nn := range n {
|
||||
got = append(got, nn.Meta.Label.Text)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
insertionOrder := make([]string, count)
|
||||
valueOrder := make([]string, count)
|
||||
for i := range count {
|
||||
insertionOrder[i] = fmt.Sprintf("%02d", i)
|
||||
valueOrder[i] = fmt.Sprintf("%02d", count-1-i)
|
||||
}
|
||||
|
||||
t.Run("case=stable group preserves insertion order", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
n := build()
|
||||
require.NoError(t, n.SortBySchema(ctx,
|
||||
node.SortByGroups([]node.UiNodeGroup{node.DefaultGroup, node.PasswordGroup}),
|
||||
node.SortStableGroups(node.DeviceAuthnGroup),
|
||||
))
|
||||
assert.Equal(t, insertionOrder, labels(n))
|
||||
})
|
||||
|
||||
t.Run("case=without the option the value tiebreak reorders", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
n := build()
|
||||
require.NoError(t, n.SortBySchema(ctx,
|
||||
node.SortByGroups([]node.UiNodeGroup{node.DefaultGroup, node.PasswordGroup}),
|
||||
))
|
||||
assert.Equal(t, valueOrder, labels(n))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNodesUpsert(t *testing.T) {
|
||||
var nodes node.Nodes
|
||||
nodes.Upsert(node.NewCSRFNode("foo"))
|
||||
require.Len(t, nodes, 1)
|
||||
nodes.Upsert(node.NewCSRFNode("bar"))
|
||||
require.Len(t, nodes, 1)
|
||||
assert.EqualValues(t, "bar", nodes[0].Attributes.GetValue())
|
||||
}
|
||||
|
||||
func TestNodesRemove(t *testing.T) {
|
||||
var nodes node.Nodes
|
||||
nodes.Append(node.NewInputField("other", "foo", node.OpenIDConnectGroup, node.InputAttributeTypeSubmit))
|
||||
nodes.Append(node.NewInputField("link", "foo", node.OpenIDConnectGroup, node.InputAttributeTypeSubmit))
|
||||
nodes.Append(node.NewInputField("link", "bar", node.OpenIDConnectGroup, node.InputAttributeTypeSubmit))
|
||||
nodes.Append(node.NewInputField("unlink", "baz", node.OpenIDConnectGroup, node.InputAttributeTypeSubmit))
|
||||
require.Len(t, nodes, 4)
|
||||
|
||||
nodes.Remove("link", "unlink")
|
||||
require.Len(t, nodes, 1)
|
||||
}
|
||||
|
||||
func TestNodesRemoveInGroup(t *testing.T) {
|
||||
samlLink := node.NewInputField("link", "saml-provider", node.SAMLGroup, node.InputAttributeTypeSubmit)
|
||||
nodes := node.Nodes{
|
||||
node.NewInputField("link", "oidc-provider", node.OpenIDConnectGroup, node.InputAttributeTypeSubmit),
|
||||
samlLink,
|
||||
node.NewInputField("unlink", "oidc-provider", node.OpenIDConnectGroup, node.InputAttributeTypeSubmit),
|
||||
node.NewCSRFNode("token"),
|
||||
}
|
||||
|
||||
// Remove only the OIDC group's link/unlink nodes.
|
||||
nodes.RemoveInGroup(node.OpenIDConnectGroup, "link", "unlink")
|
||||
|
||||
require.Len(t, nodes, 2)
|
||||
// The SAML link node shares the "link" name but a different group: it survives.
|
||||
assert.Contains(t, nodes, samlLink)
|
||||
// The CSRF node (DefaultGroup) is untouched.
|
||||
var sawCSRF bool
|
||||
for _, n := range nodes {
|
||||
if n.Group == node.DefaultGroup {
|
||||
sawCSRF = true
|
||||
}
|
||||
}
|
||||
assert.True(t, sawCSRF, "CSRF node must not be removed")
|
||||
}
|
||||
|
||||
func TestNodeJSON(t *testing.T) {
|
||||
t.Run("idempotent decode", func(t *testing.T) {
|
||||
nodes := make(node.Nodes, 5)
|
||||
for k := range nodes {
|
||||
nodes[k] = new(node.Node)
|
||||
expected := nodes[k]
|
||||
|
||||
require.NoError(t, faker.FakeData(expected))
|
||||
var b bytes.Buffer
|
||||
require.NoError(t, json.NewEncoder(&b).Encode(expected))
|
||||
|
||||
var actual node.Node
|
||||
require.NoError(t, json.NewDecoder(&b).Decode(&actual))
|
||||
|
||||
assert.EqualValues(t, *expected, actual)
|
||||
assert.NotEmpty(t, actual.Type)
|
||||
assert.NotNil(t, actual.Attributes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type mismatch", func(t *testing.T) {
|
||||
n := &node.Node{Type: node.Image, Attributes: new(node.InputAttributes)}
|
||||
var b bytes.Buffer
|
||||
require.EqualError(t, json.NewEncoder(&b).Encode(n), "json: error calling MarshalJSON for type *node.Node: node type and node attributes mismatch: *node.InputAttributes != img")
|
||||
})
|
||||
|
||||
t.Run("type empty", func(t *testing.T) {
|
||||
n := &node.Node{Attributes: new(node.InputAttributes)}
|
||||
var b bytes.Buffer
|
||||
require.NoError(t, json.NewEncoder(&b).Encode(n))
|
||||
assert.EqualValues(t, node.Input, gjson.GetBytes(b.Bytes(), "type").String())
|
||||
})
|
||||
|
||||
t.Run("type decode unknown", func(t *testing.T) {
|
||||
var n node.Node
|
||||
require.EqualError(t, json.NewDecoder(bytes.NewReader(json.RawMessage(`{"type": "foo"}`))).Decode(&n), "unexpected node type: foo")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatchesNode(t *testing.T) {
|
||||
// Test when ID is different
|
||||
node1 := &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}
|
||||
node2 := &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "bar"}}
|
||||
assert.False(t, node1.Matches(node2))
|
||||
|
||||
// Test when Type is different
|
||||
node1 = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}
|
||||
node2 = &node.Node{Type: node.Text, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}
|
||||
assert.False(t, node1.Matches(node2))
|
||||
|
||||
// Test when Group is different
|
||||
node1 = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}
|
||||
node2 = &node.Node{Type: node.Input, Group: node.OpenIDConnectGroup, Attributes: &node.InputAttributes{Name: "foo"}}
|
||||
assert.False(t, node1.Matches(node2))
|
||||
|
||||
// Test when all fields are the same
|
||||
node1 = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}
|
||||
node2 = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}
|
||||
assert.True(t, node1.Matches(node2))
|
||||
}
|
||||
|
||||
func TestRemoveMatchingNodes(t *testing.T) {
|
||||
nodes := node.Nodes{
|
||||
&node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}},
|
||||
&node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "bar"}},
|
||||
&node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "baz"}},
|
||||
}
|
||||
|
||||
// Test when node to remove is present
|
||||
nodeToRemove := &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "bar"}}
|
||||
nodes.RemoveMatching(nodeToRemove)
|
||||
assert.Len(t, nodes, 2)
|
||||
for _, n := range nodes {
|
||||
assert.NotEqual(t, nodeToRemove.ID(), n.ID())
|
||||
}
|
||||
|
||||
// Test when node to remove is not present
|
||||
nodeToRemove = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "qux"}}
|
||||
nodes.RemoveMatching(nodeToRemove)
|
||||
assert.Len(t, nodes, 2) // length should remain the same
|
||||
|
||||
// Test when node to remove is present
|
||||
nodeToRemove = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "baz"}}
|
||||
ui := &container.Container{
|
||||
Nodes: nodes,
|
||||
}
|
||||
|
||||
ui.GetNodes().RemoveMatching(nodeToRemove)
|
||||
assert.Len(t, *ui.GetNodes(), 1)
|
||||
for _, n := range *ui.GetNodes() {
|
||||
assert.NotEqual(t, "bar", n.ID())
|
||||
assert.NotEqual(t, "baz", n.ID())
|
||||
}
|
||||
|
||||
ui.Nodes.Append(node.NewInputField("method", "foo", "bar", node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelContinue()))
|
||||
assert.NotNil(t, ui.Nodes.Find("method"))
|
||||
ui.GetNodes().RemoveMatching(node.NewInputField("method", "foo", "bar", node.InputAttributeTypeSubmit))
|
||||
assert.Nil(t, ui.Nodes.Find("method"))
|
||||
}
|
||||
|
||||
func TestNodeMarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
node *node.Node
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "text node",
|
||||
node: &node.Node{
|
||||
Type: node.Text,
|
||||
Group: node.DefaultGroup,
|
||||
Attributes: &node.TextAttributes{
|
||||
NodeType: node.Text,
|
||||
},
|
||||
Messages: text.Messages{},
|
||||
Meta: &node.Meta{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "input node",
|
||||
node: &node.Node{
|
||||
Type: node.Input,
|
||||
Group: node.DefaultGroup,
|
||||
Attributes: &node.InputAttributes{
|
||||
NodeType: node.Input,
|
||||
Name: "password",
|
||||
Type: "password",
|
||||
FieldValue: "secret",
|
||||
},
|
||||
Messages: text.Messages{},
|
||||
Meta: &node.Meta{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "anchor node",
|
||||
node: &node.Node{
|
||||
Type: node.Anchor,
|
||||
Group: node.DefaultGroup,
|
||||
Attributes: &node.AnchorAttributes{
|
||||
NodeType: node.Anchor,
|
||||
HREF: "https://example.com",
|
||||
},
|
||||
Messages: text.Messages{},
|
||||
Meta: &node.Meta{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image node",
|
||||
node: &node.Node{
|
||||
Type: node.Image,
|
||||
Group: node.DefaultGroup,
|
||||
Attributes: &node.ImageAttributes{
|
||||
NodeType: node.Image,
|
||||
Source: "image.jpg",
|
||||
},
|
||||
Messages: text.Messages{},
|
||||
Meta: &node.Meta{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "script node",
|
||||
node: &node.Node{
|
||||
Type: node.Script,
|
||||
Group: node.DefaultGroup,
|
||||
Attributes: &node.ScriptAttributes{
|
||||
NodeType: node.Script,
|
||||
Source: "script.js",
|
||||
},
|
||||
Messages: text.Messages{},
|
||||
Meta: &node.Meta{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "division node",
|
||||
node: &node.Node{
|
||||
Type: node.Division,
|
||||
Group: node.DefaultGroup,
|
||||
Attributes: &node.DivisionAttributes{
|
||||
NodeType: node.Division,
|
||||
},
|
||||
Messages: text.Messages{},
|
||||
Meta: &node.Meta{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "type mismatch",
|
||||
node: &node.Node{
|
||||
Type: node.Image,
|
||||
Group: node.DefaultGroup,
|
||||
Attributes: &node.InputAttributes{NodeType: node.Input},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "node type and node attributes mismatch",
|
||||
},
|
||||
{
|
||||
name: "empty type inferred from attributes",
|
||||
node: &node.Node{
|
||||
Group: node.DefaultGroup,
|
||||
Attributes: &node.InputAttributes{
|
||||
NodeType: node.Input,
|
||||
Name: "email",
|
||||
},
|
||||
Messages: text.Messages{},
|
||||
Meta: &node.Meta{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil attributes",
|
||||
node: &node.Node{
|
||||
Type: node.Image,
|
||||
Group: node.DefaultGroup,
|
||||
Attributes: nil,
|
||||
Messages: text.Messages{},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "node type and node attributes mismatch",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(tt.node)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use snapshotx for testing serialization
|
||||
snapshotx.SnapshotT(t, json.RawMessage(data))
|
||||
|
||||
// Verify roundtrip
|
||||
var unmarshalled node.Node
|
||||
err = json.Unmarshal(data, &unmarshalled)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Re-marshal for comparison
|
||||
remarshalled, err := json.Marshal(&unmarshalled)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(data), string(remarshalled))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodes_FindAll(t *testing.T) {
|
||||
nodes := node.Nodes{
|
||||
&node.Node{Type: node.Input, Group: node.DefaultGroup, Attributes: &node.InputAttributes{Name: "a"}},
|
||||
&node.Node{Type: node.Input, Group: node.DefaultGroup, Attributes: &node.InputAttributes{Name: "b"}},
|
||||
&node.Node{Type: node.Input, Group: node.DefaultGroup, Attributes: &node.InputAttributes{Name: "a"}},
|
||||
&node.Node{Type: node.Input, Group: node.DefaultGroup, Attributes: &node.InputAttributes{Name: "c"}},
|
||||
}
|
||||
|
||||
t.Run("find matches multiple nodes", func(t *testing.T) {
|
||||
got := nodes.FindAll("a")
|
||||
assert.Equal(t, []node.Node{*nodes[0], *nodes[2]}, got)
|
||||
})
|
||||
|
||||
t.Run("find matches one node", func(t *testing.T) {
|
||||
got := nodes.FindAll("b")
|
||||
assert.Equal(t, []node.Node{*nodes[1]}, got)
|
||||
})
|
||||
|
||||
t.Run("no match returns empty slice", func(t *testing.T) {
|
||||
got := nodes.FindAll("x")
|
||||
assert.Empty(t, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNodes_RemoveGroup(t *testing.T) {
|
||||
nodes := node.Nodes{
|
||||
&node.Node{Type: node.Input, Group: node.DefaultGroup, Attributes: &node.InputAttributes{Name: "default"}},
|
||||
&node.Node{Type: node.Input, Group: node.CaptchaGroup, Attributes: &node.InputAttributes{Name: "captcha1"}},
|
||||
&node.Node{Type: node.Input, Group: node.IdentifierFirstGroup, Attributes: &node.InputAttributes{Name: "idfirst"}},
|
||||
&node.Node{Type: node.Input, Group: node.CaptchaGroup, Attributes: &node.InputAttributes{Name: "captcha2"}},
|
||||
&node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "password"}},
|
||||
nil,
|
||||
}
|
||||
|
||||
t.Run("removes single node", func(t *testing.T) {
|
||||
n := append(node.Nodes(nil), nodes...)
|
||||
n.RemoveGroup(node.IdentifierFirstGroup)
|
||||
assert.Equal(t, node.Nodes{nodes[0], nodes[1], nodes[3], nodes[4]}, n)
|
||||
})
|
||||
|
||||
t.Run("removes multiple nodes", func(t *testing.T) {
|
||||
n := append(node.Nodes(nil), nodes...)
|
||||
n.RemoveGroup(node.CaptchaGroup)
|
||||
assert.Equal(t, node.Nodes{nodes[0], nodes[2], nodes[4]}, n)
|
||||
})
|
||||
|
||||
t.Run("removes no nodes", func(t *testing.T) {
|
||||
n := append(node.Nodes(nil), nodes...)
|
||||
n.RemoveGroup(node.PasskeyGroup)
|
||||
assert.Equal(t, nodes[:5], n)
|
||||
})
|
||||
|
||||
t.Run("removes multiple groups", func(t *testing.T) {
|
||||
n := append(node.Nodes(nil), nodes...)
|
||||
n.RemoveGroups(node.CaptchaGroup, node.PasswordGroup)
|
||||
assert.Equal(t, node.Nodes{nodes[0], nodes[2]}, n)
|
||||
})
|
||||
|
||||
t.Run("removes multiple groups empty", func(t *testing.T) {
|
||||
n := append(node.Nodes(nil), nodes...)
|
||||
n.RemoveGroups()
|
||||
assert.Equal(t, nodes, n)
|
||||
})
|
||||
|
||||
t.Run("removes multiple groups no matching", func(t *testing.T) {
|
||||
n := append(node.Nodes(nil), nodes...)
|
||||
n.RemoveGroups(node.PasskeyGroup)
|
||||
assert.Equal(t, nodes[:5], n)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user