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

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

394
ui/container/container.go Normal file
View 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)
}

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

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

View 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"
}
}
}
}
}

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

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

View File

@@ -0,0 +1,12 @@
{
"type": "a",
"group": "default",
"attributes": {
"href": "https://example.com",
"title": null,
"id": "",
"node_type": "a"
},
"messages": [],
"meta": {}
}

View File

@@ -0,0 +1,10 @@
{
"type": "div",
"group": "default",
"attributes": {
"id": "",
"node_type": "div"
},
"messages": [],
"meta": {}
}

View File

@@ -0,0 +1,12 @@
{
"type": "input",
"group": "default",
"attributes": {
"name": "email",
"type": "",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}

View File

@@ -0,0 +1,13 @@
{
"type": "img",
"group": "default",
"attributes": {
"src": "image.jpg",
"id": "",
"width": 0,
"height": 0,
"node_type": "img"
},
"messages": [],
"meta": {}
}

View File

@@ -0,0 +1,13 @@
{
"type": "input",
"group": "default",
"attributes": {
"name": "password",
"type": "password",
"value": "secret",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}

View 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": {}
}

View 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
View 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
View 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,
}
}

View 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,
},
}
}

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

View 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"]
}
}
}

View 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"
}
}
}
}
}

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

View 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": {}
}
}
}
}
}

View 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": {}
}
]

View 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": {}
}
]

View 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": {}
}
]

View 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"
}
}
}
}
]

View 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
}
]

View 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
}
]

View 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
}
]

View 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"
}
]

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

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

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