mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 00:47:33 +00:00
chore: move the go code into a subfolder
This commit is contained in:
118
apps/api/internal/notification/channels.go
Normal file
118
apps/api/internal/notification/channels.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/email"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/set"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/sms"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
|
||||
"github.com/zitadel/zitadel/internal/notification/handlers"
|
||||
"github.com/zitadel/zitadel/internal/notification/senders"
|
||||
"github.com/zitadel/zitadel/internal/notification/types"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/metrics"
|
||||
)
|
||||
|
||||
var _ types.ChannelChains = (*channels)(nil)
|
||||
|
||||
type counters struct {
|
||||
success deliveryMetrics
|
||||
failed deliveryMetrics
|
||||
}
|
||||
|
||||
type deliveryMetrics struct {
|
||||
email string
|
||||
sms string
|
||||
json string
|
||||
}
|
||||
|
||||
type channels struct {
|
||||
q *handlers.NotificationQueries
|
||||
counters counters
|
||||
}
|
||||
|
||||
func newChannels(q *handlers.NotificationQueries) *channels {
|
||||
c := &channels{
|
||||
q: q,
|
||||
counters: counters{
|
||||
success: deliveryMetrics{
|
||||
email: "successful_deliveries_email",
|
||||
sms: "successful_deliveries_sms",
|
||||
json: "successful_deliveries_json",
|
||||
},
|
||||
failed: deliveryMetrics{
|
||||
email: "failed_deliveries_email",
|
||||
sms: "failed_deliveries_sms",
|
||||
json: "failed_deliveries_json",
|
||||
},
|
||||
},
|
||||
}
|
||||
registerCounter(c.counters.success.email, "Successfully delivered emails")
|
||||
registerCounter(c.counters.failed.email, "Failed email deliveries")
|
||||
registerCounter(c.counters.success.sms, "Successfully delivered SMS")
|
||||
registerCounter(c.counters.failed.sms, "Failed SMS deliveries")
|
||||
registerCounter(c.counters.success.json, "Successfully delivered JSON messages")
|
||||
registerCounter(c.counters.failed.json, "Failed JSON message deliveries")
|
||||
return c
|
||||
}
|
||||
|
||||
func registerCounter(counter, desc string) {
|
||||
err := metrics.RegisterCounter(counter, desc)
|
||||
logging.WithFields("metric", counter).OnError(err).Panic("unable to register counter")
|
||||
}
|
||||
|
||||
func (c *channels) Email(ctx context.Context) (*senders.Chain, *email.Config, error) {
|
||||
emailCfg, err := c.q.GetActiveEmailConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
chain, err := senders.EmailChannels(
|
||||
ctx,
|
||||
emailCfg,
|
||||
c.q.GetFileSystemProvider,
|
||||
c.q.GetLogProvider,
|
||||
c.counters.success.email,
|
||||
c.counters.failed.email,
|
||||
)
|
||||
return chain, emailCfg, err
|
||||
}
|
||||
|
||||
func (c *channels) SMS(ctx context.Context) (*senders.Chain, *sms.Config, error) {
|
||||
smsCfg, err := c.q.GetActiveSMSConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
chain, err := senders.SMSChannels(
|
||||
ctx,
|
||||
smsCfg,
|
||||
c.q.GetFileSystemProvider,
|
||||
c.q.GetLogProvider,
|
||||
c.counters.success.sms,
|
||||
c.counters.failed.sms,
|
||||
)
|
||||
return chain, smsCfg, err
|
||||
}
|
||||
|
||||
func (c *channels) Webhook(ctx context.Context, cfg webhook.Config) (*senders.Chain, error) {
|
||||
return senders.WebhookChannels(
|
||||
ctx,
|
||||
cfg,
|
||||
c.q.GetFileSystemProvider,
|
||||
c.q.GetLogProvider,
|
||||
c.counters.success.json,
|
||||
c.counters.failed.json,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *channels) SecurityTokenEvent(ctx context.Context, cfg set.Config) (*senders.Chain, error) {
|
||||
return senders.SecurityEventTokenChannels(
|
||||
ctx,
|
||||
cfg,
|
||||
c.q.GetFileSystemProvider,
|
||||
c.q.GetLogProvider,
|
||||
c.counters.success.json,
|
||||
c.counters.failed.json,
|
||||
)
|
||||
}
|
20
apps/api/internal/notification/channels/channel.go
Normal file
20
apps/api/internal/notification/channels/channel.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package channels
|
||||
|
||||
import "github.com/zitadel/zitadel/internal/eventstore"
|
||||
|
||||
type Message interface {
|
||||
GetTriggeringEventType() eventstore.EventType
|
||||
GetContent() (string, error)
|
||||
}
|
||||
|
||||
type NotificationChannel interface {
|
||||
HandleMessage(message Message) error
|
||||
}
|
||||
|
||||
var _ NotificationChannel = (HandleMessageFunc)(nil)
|
||||
|
||||
type HandleMessageFunc func(message Message) error
|
||||
|
||||
func (h HandleMessageFunc) HandleMessage(message Message) error {
|
||||
return h(message)
|
||||
}
|
17
apps/api/internal/notification/channels/email/config.go
Normal file
17
apps/api/internal/notification/channels/email/config.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/smtp"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ProviderConfig *Provider
|
||||
SMTPConfig *smtp.Config
|
||||
WebhookConfig *webhook.Config
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
25
apps/api/internal/notification/channels/error.go
Normal file
25
apps/api/internal/notification/channels/error.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package channels
|
||||
|
||||
import "errors"
|
||||
|
||||
type CancelError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *CancelError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func NewCancelError(err error) error {
|
||||
return &CancelError{
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *CancelError) Is(target error) bool {
|
||||
return errors.As(target, &e)
|
||||
}
|
||||
|
||||
func (e *CancelError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
52
apps/api/internal/notification/channels/fs/channel.go
Normal file
52
apps/api/internal/notification/channels/fs/channel.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/k3a/html2text"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/notification/messages"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
func InitFSChannel(config Config) (channels.NotificationChannel, error) {
|
||||
if err := os.MkdirAll(config.Path, os.ModePerm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logging.Debug("successfully initialized filesystem email and sms channel")
|
||||
|
||||
return channels.HandleMessageFunc(func(message channels.Message) error {
|
||||
|
||||
fileName := fmt.Sprintf("%d_", time.Now().Unix())
|
||||
content, err := message.GetContent()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch msg := message.(type) {
|
||||
case *messages.Email:
|
||||
recipients := make([]string, len(msg.Recipients))
|
||||
copy(recipients, msg.Recipients)
|
||||
sort.Strings(recipients)
|
||||
fileName = fileName + "mail_to_" + strings.Join(recipients, "_") + ".html"
|
||||
if config.Compact {
|
||||
content = html2text.HTML2Text(content)
|
||||
}
|
||||
case *messages.SMS:
|
||||
fileName = fileName + "sms_to_" + msg.RecipientPhoneNumber + ".txt"
|
||||
case *messages.JSON:
|
||||
fileName = "message.json"
|
||||
default:
|
||||
return zerrors.ThrowUnimplementedf(nil, "NOTIF-6f9a1", "filesystem provider doesn't support message type %T", message)
|
||||
}
|
||||
|
||||
return os.WriteFile(filepath.Join(config.Path, fileName), []byte(content), 0666)
|
||||
}), nil
|
||||
}
|
7
apps/api/internal/notification/channels/fs/config.go
Normal file
7
apps/api/internal/notification/channels/fs/config.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package fs
|
||||
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
Compact bool
|
||||
Path string
|
||||
}
|
4
apps/api/internal/notification/channels/gen_mock.go
Normal file
4
apps/api/internal/notification/channels/gen_mock.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package channels
|
||||
|
||||
//go:generate mockgen -package mock -destination ./mock/channel.mock.go github.com/zitadel/zitadel/internal/notification/channels NotificationChannel
|
||||
//go:generate mockgen -package mock -destination ./mock/message.mock.go github.com/zitadel/zitadel/internal/notification/channels Message
|
@@ -0,0 +1,26 @@
|
||||
package instrumenting
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
)
|
||||
|
||||
func Wrap(
|
||||
ctx context.Context,
|
||||
channel channels.NotificationChannel,
|
||||
traceSpanName,
|
||||
successMetricName,
|
||||
failureMetricName string,
|
||||
) channels.NotificationChannel {
|
||||
return traceMessages(
|
||||
ctx,
|
||||
countMessages(
|
||||
ctx,
|
||||
logMessages(ctx, channel),
|
||||
successMetricName,
|
||||
failureMetricName,
|
||||
),
|
||||
traceSpanName,
|
||||
)
|
||||
}
|
@@ -0,0 +1,24 @@
|
||||
package instrumenting
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
)
|
||||
|
||||
func logMessages(ctx context.Context, channel channels.NotificationChannel) channels.NotificationChannel {
|
||||
return channels.HandleMessageFunc(func(message channels.Message) error {
|
||||
logEntry := logging.WithFields(
|
||||
"instance", authz.GetInstance(ctx).InstanceID(),
|
||||
"triggering_event_type", message.GetTriggeringEventType(),
|
||||
)
|
||||
logEntry.Debug("sending notification")
|
||||
err := channel.HandleMessage(message)
|
||||
logEntry.OnError(err).Warn("sending notification failed")
|
||||
logEntry.Debug("notification sent")
|
||||
return err
|
||||
})
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
package instrumenting
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/metrics"
|
||||
)
|
||||
|
||||
func countMessages(ctx context.Context, channel channels.NotificationChannel, successMetricName, errorMetricName string) channels.NotificationChannel {
|
||||
return channels.HandleMessageFunc(func(message channels.Message) error {
|
||||
err := channel.HandleMessage(message)
|
||||
metricName := successMetricName
|
||||
if err != nil {
|
||||
metricName = errorMetricName
|
||||
}
|
||||
addCount(ctx, metricName, message)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func addCount(ctx context.Context, metricName string, message channels.Message) {
|
||||
labels := map[string]attribute.Value{
|
||||
"triggering_event_type": attribute.StringValue(string(message.GetTriggeringEventType())),
|
||||
}
|
||||
addCountErr := metrics.AddCount(ctx, metricName, 1, labels)
|
||||
logging.WithFields("name", metricName, "labels", labels).OnError(addCountErr).Error("incrementing counter metric failed")
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
package instrumenting
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
)
|
||||
|
||||
func traceMessages(ctx context.Context, channel channels.NotificationChannel, spanName string) channels.NotificationChannel {
|
||||
return channels.HandleMessageFunc(func(message channels.Message) (err error) {
|
||||
_, span := tracing.NewNamedSpan(ctx, spanName)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
return channel.HandleMessage(message)
|
||||
})
|
||||
}
|
32
apps/api/internal/notification/channels/log/channel.go
Normal file
32
apps/api/internal/notification/channels/log/channel.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/k3a/html2text"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
)
|
||||
|
||||
func InitStdoutChannel(config Config) channels.NotificationChannel {
|
||||
|
||||
logging.WithFields("logID", "NOTIF-D0164").Debug("successfully initialized stdout email and sms channel")
|
||||
|
||||
return channels.HandleMessageFunc(func(message channels.Message) error {
|
||||
|
||||
content, err := message.GetContent()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.Compact {
|
||||
content = html2text.HTML2Text(content)
|
||||
}
|
||||
|
||||
logging.WithFields("logID", "NOTIF-c73ba").WithFields(map[string]interface{}{
|
||||
"type": fmt.Sprintf("%T", message),
|
||||
"content": content,
|
||||
}).Info("handling notification message")
|
||||
return nil
|
||||
})
|
||||
}
|
6
apps/api/internal/notification/channels/log/config.go
Normal file
6
apps/api/internal/notification/channels/log/config.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package log
|
||||
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
Compact bool
|
||||
}
|
54
apps/api/internal/notification/channels/mock/channel.mock.go
Normal file
54
apps/api/internal/notification/channels/mock/channel.mock.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/zitadel/zitadel/internal/notification/channels (interfaces: NotificationChannel)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package mock -destination ./mock/channel.mock.go github.com/zitadel/zitadel/internal/notification/channels NotificationChannel
|
||||
//
|
||||
|
||||
// Package mock is a generated GoMock package.
|
||||
package mock
|
||||
|
||||
import (
|
||||
reflect "reflect"
|
||||
|
||||
channels "github.com/zitadel/zitadel/internal/notification/channels"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockNotificationChannel is a mock of NotificationChannel interface.
|
||||
type MockNotificationChannel struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockNotificationChannelMockRecorder
|
||||
}
|
||||
|
||||
// MockNotificationChannelMockRecorder is the mock recorder for MockNotificationChannel.
|
||||
type MockNotificationChannelMockRecorder struct {
|
||||
mock *MockNotificationChannel
|
||||
}
|
||||
|
||||
// NewMockNotificationChannel creates a new mock instance.
|
||||
func NewMockNotificationChannel(ctrl *gomock.Controller) *MockNotificationChannel {
|
||||
mock := &MockNotificationChannel{ctrl: ctrl}
|
||||
mock.recorder = &MockNotificationChannelMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockNotificationChannel) EXPECT() *MockNotificationChannelMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// HandleMessage mocks base method.
|
||||
func (m *MockNotificationChannel) HandleMessage(arg0 channels.Message) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HandleMessage", arg0)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// HandleMessage indicates an expected call of HandleMessage.
|
||||
func (mr *MockNotificationChannelMockRecorder) HandleMessage(arg0 any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleMessage", reflect.TypeOf((*MockNotificationChannel)(nil).HandleMessage), arg0)
|
||||
}
|
69
apps/api/internal/notification/channels/mock/message.mock.go
Normal file
69
apps/api/internal/notification/channels/mock/message.mock.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/zitadel/zitadel/internal/notification/channels (interfaces: Message)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package mock -destination ./mock/message.mock.go github.com/zitadel/zitadel/internal/notification/channels Message
|
||||
//
|
||||
|
||||
// Package mock is a generated GoMock package.
|
||||
package mock
|
||||
|
||||
import (
|
||||
reflect "reflect"
|
||||
|
||||
eventstore "github.com/zitadel/zitadel/internal/eventstore"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockMessage is a mock of Message interface.
|
||||
type MockMessage struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockMessageMockRecorder
|
||||
}
|
||||
|
||||
// MockMessageMockRecorder is the mock recorder for MockMessage.
|
||||
type MockMessageMockRecorder struct {
|
||||
mock *MockMessage
|
||||
}
|
||||
|
||||
// NewMockMessage creates a new mock instance.
|
||||
func NewMockMessage(ctrl *gomock.Controller) *MockMessage {
|
||||
mock := &MockMessage{ctrl: ctrl}
|
||||
mock.recorder = &MockMessageMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockMessage) EXPECT() *MockMessageMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetContent mocks base method.
|
||||
func (m *MockMessage) GetContent() (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetContent")
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetContent indicates an expected call of GetContent.
|
||||
func (mr *MockMessageMockRecorder) GetContent() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContent", reflect.TypeOf((*MockMessage)(nil).GetContent))
|
||||
}
|
||||
|
||||
// GetTriggeringEvent mocks base method.
|
||||
func (m *MockMessage) GetTriggeringEvent() eventstore.Event {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetTriggeringEvent")
|
||||
ret0, _ := ret[0].(eventstore.Event)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetTriggeringEvent indicates an expected call of GetTriggeringEvent.
|
||||
func (mr *MockMessageMockRecorder) GetTriggeringEvent() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTriggeringEvent", reflect.TypeOf((*MockMessage)(nil).GetTriggeringEvent))
|
||||
}
|
75
apps/api/internal/notification/channels/set/channel.go
Normal file
75
apps/api/internal/notification/channels/set/channel.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package set
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/notification/messages"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
func InitChannel(ctx context.Context, cfg Config) (channels.NotificationChannel, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logging.Debug("successfully initialized security event token json channel")
|
||||
return channels.HandleMessageFunc(func(message channels.Message) error {
|
||||
requestCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
msg, ok := message.(*messages.Form)
|
||||
if !ok {
|
||||
return zerrors.ThrowInternal(nil, "SET-K686U", "message is not SET")
|
||||
}
|
||||
payload, err := msg.GetContent()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, cfg.CallURL, strings.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "calling_url", cfg.CallURL).Debug("security event token called")
|
||||
if resp.StatusCode == http.StatusOK ||
|
||||
resp.StatusCode == http.StatusAccepted ||
|
||||
resp.StatusCode == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
body, err := mapResponse(resp)
|
||||
logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "callURL", cfg.CallURL).
|
||||
OnError(err).Debug("error mapping response")
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "callURL", cfg.CallURL, "status", resp.Status, "body", body).
|
||||
Error("security event token didn't return a success status")
|
||||
return nil
|
||||
}
|
||||
return zerrors.ThrowInternalf(err, "SET-DF3dq", "security event token to %s didn't return a success status: %s (%v)", cfg.CallURL, resp.Status, body)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func mapResponse(resp *http.Response) (map[string]any, error) {
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requestError := make(map[string]any)
|
||||
err = json.Unmarshal(body, &requestError)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return requestError, nil
|
||||
}
|
14
apps/api/internal/notification/channels/set/config.go
Normal file
14
apps/api/internal/notification/channels/set/config.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package set
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
CallURL string
|
||||
}
|
||||
|
||||
func (w *Config) Validate() error {
|
||||
_, err := url.Parse(w.CallURL)
|
||||
return err
|
||||
}
|
17
apps/api/internal/notification/channels/sms/config.go
Normal file
17
apps/api/internal/notification/channels/sms/config.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ProviderConfig *Provider
|
||||
TwilioConfig *twilio.Config
|
||||
WebhookConfig *webhook.Config
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
209
apps/api/internal/notification/channels/smtp/channel.go
Normal file
209
apps/api/internal/notification/channels/smtp/channel.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/smtp"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/notification/messages"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
var _ channels.NotificationChannel = (*Email)(nil)
|
||||
|
||||
type Email struct {
|
||||
smtpClient *smtp.Client
|
||||
senderAddress string
|
||||
senderName string
|
||||
replyToAddress string
|
||||
}
|
||||
|
||||
func InitChannel(cfg *Config) (*Email, error) {
|
||||
client, err := cfg.SMTP.connectToSMTP(cfg.Tls)
|
||||
if err != nil {
|
||||
logging.New().WithError(err).Error("could not connect to smtp")
|
||||
return nil, err
|
||||
}
|
||||
logging.New().Debug("successfully initialized smtp email channel")
|
||||
return &Email{
|
||||
smtpClient: client,
|
||||
senderName: cfg.FromName,
|
||||
senderAddress: cfg.From,
|
||||
replyToAddress: cfg.ReplyToAddress,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (email *Email) HandleMessage(message channels.Message) error {
|
||||
defer email.smtpClient.Close()
|
||||
emailMsg, ok := message.(*messages.Email)
|
||||
if !ok {
|
||||
return zerrors.ThrowInternal(nil, "EMAIL-s8JLs", "Errors.SMTP.NotEmailMessage")
|
||||
}
|
||||
|
||||
if emailMsg.Content == "" || emailMsg.Subject == "" || len(emailMsg.Recipients) == 0 {
|
||||
return zerrors.ThrowInternal(nil, "EMAIL-zGemZ", "Errors.SMTP.RequiredAttributes")
|
||||
}
|
||||
emailMsg.SenderEmail = email.senderAddress
|
||||
emailMsg.SenderName = email.senderName
|
||||
emailMsg.ReplyToAddress = email.replyToAddress
|
||||
// To && From
|
||||
if err := email.smtpClient.Mail(emailMsg.SenderEmail); err != nil {
|
||||
return zerrors.ThrowInternal(err, "EMAIL-s3is3", "Errors.SMTP.CouldNotSetSender")
|
||||
}
|
||||
for _, recp := range append(append(emailMsg.Recipients, emailMsg.CC...), emailMsg.BCC...) {
|
||||
if err := email.smtpClient.Rcpt(recp); err != nil {
|
||||
return zerrors.ThrowInternal(err, "EMAIL-s4is4", "Errors.SMTP.CouldNotSetRecipient")
|
||||
}
|
||||
}
|
||||
|
||||
// Data
|
||||
w, err := email.smtpClient.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := emailMsg.GetContent()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write([]byte(content))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return email.smtpClient.Quit()
|
||||
}
|
||||
|
||||
func (smtpConfig SMTP) connectToSMTP(tlsRequired bool) (client *smtp.Client, err error) {
|
||||
host, _, err := net.SplitHostPort(smtpConfig.Host)
|
||||
if err != nil {
|
||||
return nil, zerrors.ThrowInternal(err, "EMAIL-spR56", "Errors.SMTP.CouldNotSplit")
|
||||
}
|
||||
|
||||
if !tlsRequired {
|
||||
client, err = smtpConfig.getSMTPClient()
|
||||
} else {
|
||||
client, err = smtpConfig.getSMTPClientWithTls(host)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = smtpConfig.smtpAuth(client, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (smtpConfig SMTP) getSMTPClient() (*smtp.Client, error) {
|
||||
client, err := smtp.Dial(smtpConfig.Host)
|
||||
if err != nil {
|
||||
return nil, zerrors.ThrowInternal(err, "EMAIL-skwos", "Errors.SMTP.CouldNotDial")
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (smtpConfig SMTP) getSMTPClientWithTls(host string) (*smtp.Client, error) {
|
||||
conn, err := tls.Dial("tcp", smtpConfig.Host, &tls.Config{})
|
||||
|
||||
if errors.As(err, &tls.RecordHeaderError{}) {
|
||||
logging.OnError(err).Warn("could not connect using normal tls. trying starttls instead...")
|
||||
return smtpConfig.getSMTPClientWithStartTls(host)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, zerrors.ThrowInternal(err, "EMAIL-sl39s", "Errors.SMTP.CouldNotDialTLS")
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return nil, zerrors.ThrowInternal(err, "EMAIL-skwi4", "Errors.SMTP.CouldNotCreateClient")
|
||||
}
|
||||
return client, err
|
||||
}
|
||||
|
||||
func (smtpConfig SMTP) getSMTPClientWithStartTls(host string) (*smtp.Client, error) {
|
||||
client, err := smtpConfig.getSMTPClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := client.StartTLS(&tls.Config{
|
||||
ServerName: host,
|
||||
}); err != nil {
|
||||
return nil, zerrors.ThrowInternal(err, "EMAIL-guvsQ", "Errors.SMTP.CouldNotStartTLS")
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (smtpConfig SMTP) smtpAuth(client *smtp.Client, host string) error {
|
||||
if !smtpConfig.HasAuth() {
|
||||
return nil
|
||||
}
|
||||
// Auth
|
||||
err := client.Auth(PlainOrLoginAuth(smtpConfig.User, smtpConfig.Password, host))
|
||||
if err != nil {
|
||||
return zerrors.ThrowInternal(err, "EMAIL-s9kfs", "Errors.SMTP.CouldNotAuth")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestConfiguration(cfg *Config, testEmail string) error {
|
||||
client, err := cfg.SMTP.connectToSMTP(cfg.Tls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer client.Close()
|
||||
|
||||
message := &messages.Email{
|
||||
Recipients: []string{testEmail},
|
||||
Subject: "Test email",
|
||||
Content: "This is a test email to check if your SMTP provider works fine",
|
||||
SenderEmail: cfg.From,
|
||||
SenderName: cfg.FromName,
|
||||
}
|
||||
|
||||
if err := client.Mail(cfg.From); err != nil {
|
||||
return zerrors.ThrowInternal(err, "EMAIL-s3is3", "Errors.SMTP.CouldNotSetSender")
|
||||
}
|
||||
|
||||
if err := client.Rcpt(testEmail); err != nil {
|
||||
return zerrors.ThrowInternal(err, "EMAIL-s4is4", "Errors.SMTP.CouldNotSetRecipient")
|
||||
}
|
||||
|
||||
// Open data connection
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send content
|
||||
content, err := message.GetContent()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write([]byte(content))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Close IO and quit smtp connection
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
23
apps/api/internal/notification/channels/smtp/config.go
Normal file
23
apps/api/internal/notification/channels/smtp/config.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package smtp
|
||||
|
||||
type Config struct {
|
||||
SMTP SMTP
|
||||
Tls bool
|
||||
From string
|
||||
FromName string
|
||||
ReplyToAddress string
|
||||
}
|
||||
|
||||
type SMTP struct {
|
||||
Host string
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
|
||||
func (smtp *SMTP) HasAuth() bool {
|
||||
return smtp.User != "" && smtp.Password != ""
|
||||
}
|
||||
|
||||
type ConfigHTTP struct {
|
||||
Endpoint string
|
||||
}
|
@@ -0,0 +1,57 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/smtp"
|
||||
"slices"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
// golang net/smtp SMTP AUTH LOGIN or PLAIN Auth Handler
|
||||
// Reference: https://gist.github.com/andelf/5118732?permalink_comment_id=4825669#gistcomment-4825669
|
||||
|
||||
func PlainOrLoginAuth(username, password, host string) smtp.Auth {
|
||||
return &plainOrLoginAuth{username: username, password: password, host: host}
|
||||
}
|
||||
|
||||
type plainOrLoginAuth struct {
|
||||
username string
|
||||
password string
|
||||
host string
|
||||
authMethod string
|
||||
}
|
||||
|
||||
func (a *plainOrLoginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
if server.Name != a.host {
|
||||
return "", nil, zerrors.ThrowInternal(nil, "SMTP-RRi75", "wrong host name")
|
||||
}
|
||||
if !slices.Contains(server.Auth, "PLAIN") {
|
||||
a.authMethod = "LOGIN"
|
||||
return a.authMethod, nil, nil
|
||||
} else {
|
||||
a.authMethod = "PLAIN"
|
||||
resp := []byte("\x00" + a.username + "\x00" + a.password)
|
||||
return a.authMethod, resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *plainOrLoginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if !more {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if a.authMethod == "PLAIN" {
|
||||
// We've already sent everything.
|
||||
return nil, zerrors.ThrowInternal(nil, "SMTP-AAf43", "unexpected server challenge for PLAIN auth method")
|
||||
}
|
||||
|
||||
switch {
|
||||
case bytes.Equal(fromServer, []byte("Username:")):
|
||||
return []byte(a.username), nil
|
||||
case bytes.Equal(fromServer, []byte("Password:")):
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, zerrors.ThrowInternal(nil, "SMTP-HjW21", "unexpected server challenge")
|
||||
}
|
||||
}
|
76
apps/api/internal/notification/channels/twilio/channel.go
Normal file
76
apps/api/internal/notification/channels/twilio/channel.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/twilio/twilio-go"
|
||||
twilioClient "github.com/twilio/twilio-go/client"
|
||||
openapi "github.com/twilio/twilio-go/rest/api/v2010"
|
||||
verify "github.com/twilio/twilio-go/rest/verify/v2"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/notification/messages"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
aggregateTypeNotification = "notification"
|
||||
)
|
||||
|
||||
func InitChannel(config Config) channels.NotificationChannel {
|
||||
client := twilio.NewRestClientWithParams(twilio.ClientParams{Username: config.SID, Password: config.Token})
|
||||
logging.Debug("successfully initialized twilio sms channel")
|
||||
|
||||
return channels.HandleMessageFunc(func(message channels.Message) error {
|
||||
twilioMsg, ok := message.(*messages.SMS)
|
||||
if !ok {
|
||||
return zerrors.ThrowInternal(nil, "TWILI-s0pLc", "message is not SMS")
|
||||
}
|
||||
if config.VerifyServiceSID != "" {
|
||||
params := &verify.CreateVerificationParams{}
|
||||
params.SetTo(twilioMsg.RecipientPhoneNumber)
|
||||
params.SetChannel("sms")
|
||||
|
||||
resp, err := client.VerifyV2.CreateVerification(config.VerifyServiceSID, params)
|
||||
|
||||
// In case of any client error (4xx), we should not retry sending the verification code
|
||||
// as it would be a waste of resources and could potentially result in a rate limit.
|
||||
var twilioErr *twilioClient.TwilioRestError
|
||||
if errors.As(err, &twilioErr) && twilioErr.Status >= 400 && twilioErr.Status < 500 {
|
||||
logging.WithFields(
|
||||
"error", twilioErr.Message,
|
||||
"status", twilioErr.Status,
|
||||
"code", twilioErr.Code,
|
||||
"instanceID", twilioMsg.InstanceID,
|
||||
"jobID", twilioMsg.JobID,
|
||||
"userID", twilioMsg.UserID,
|
||||
).Warn("twilio create verification error")
|
||||
return channels.NewCancelError(twilioErr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return zerrors.ThrowInternal(err, "TWILI-0s9f2", "could not send verification")
|
||||
}
|
||||
logging.WithFields("sid", resp.Sid, "status", resp.Status).Debug("verification sent")
|
||||
|
||||
twilioMsg.VerificationID = resp.Sid
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := twilioMsg.GetContent()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := &openapi.CreateMessageParams{}
|
||||
params.SetTo(twilioMsg.RecipientPhoneNumber)
|
||||
params.SetFrom(twilioMsg.SenderPhoneNumber)
|
||||
params.SetBody(content)
|
||||
m, err := client.Api.CreateMessage(params)
|
||||
if err != nil {
|
||||
return zerrors.ThrowInternal(err, "TWILI-osk3S", "could not send message")
|
||||
}
|
||||
logging.WithFields("message_sid", m.Sid, "status", m.Status).Debug("sms sent")
|
||||
return nil
|
||||
})
|
||||
}
|
40
apps/api/internal/notification/channels/twilio/config.go
Normal file
40
apps/api/internal/notification/channels/twilio/config.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
newTwilio "github.com/twilio/twilio-go"
|
||||
verify "github.com/twilio/twilio-go/rest/verify/v2"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SID string
|
||||
Token string
|
||||
SenderNumber string
|
||||
VerifyServiceSID string
|
||||
}
|
||||
|
||||
func (t *Config) IsValid() bool {
|
||||
return t.SID != "" && t.Token != "" && t.SenderNumber != ""
|
||||
}
|
||||
|
||||
func (t *Config) VerifyCode(verificationID, code string) error {
|
||||
client := newTwilio.NewRestClientWithParams(newTwilio.ClientParams{Username: t.SID, Password: t.Token})
|
||||
checkParams := &verify.CreateVerificationCheckParams{}
|
||||
checkParams.SetVerificationSid(verificationID)
|
||||
checkParams.SetCode(code)
|
||||
resp, err := client.VerifyV2.CreateVerificationCheck(t.VerifyServiceSID, checkParams)
|
||||
if err != nil || resp.Status == nil {
|
||||
return zerrors.ThrowInvalidArgument(err, "TWILI-JK3ta", "Errors.User.Code.NotFound")
|
||||
}
|
||||
switch *resp.Status {
|
||||
case "approved":
|
||||
return nil
|
||||
case "expired":
|
||||
return zerrors.ThrowInvalidArgument(nil, "TWILI-SF3ba", "Errors.User.Code.Expired")
|
||||
case "max_attempts_reached":
|
||||
return zerrors.ThrowInvalidArgument(nil, "TWILI-Ok39a", "Errors.User.Code.NotFound")
|
||||
default:
|
||||
return zerrors.ThrowInvalidArgument(nil, "TWILI-Skwe4", "Errors.User.Code.Invalid")
|
||||
}
|
||||
}
|
55
apps/api/internal/notification/channels/webhook/channel.go
Normal file
55
apps/api/internal/notification/channels/webhook/channel.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/notification/messages"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
func InitChannel(ctx context.Context, cfg Config) (channels.NotificationChannel, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logging.Debug("successfully initialized webhook json channel")
|
||||
return channels.HandleMessageFunc(func(message channels.Message) error {
|
||||
requestCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
msg, ok := message.(*messages.JSON)
|
||||
if !ok {
|
||||
return zerrors.ThrowInternal(nil, "WEBH-K686U", "message is not JSON")
|
||||
}
|
||||
payload, err := msg.GetContent()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(requestCtx, cfg.Method, cfg.CallURL, strings.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Headers != nil {
|
||||
req.Header = cfg.Headers
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = resp.Body.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return zerrors.ThrowUnknown(fmt.Errorf("calling url %s returned %s", cfg.CallURL, resp.Status), "WEBH-LBxU0", "webhook didn't return a success status")
|
||||
}
|
||||
logging.WithFields("calling_url", cfg.CallURL, "method", cfg.Method).Debug("webhook called")
|
||||
return nil
|
||||
}), nil
|
||||
}
|
17
apps/api/internal/notification/channels/webhook/config.go
Normal file
17
apps/api/internal/notification/channels/webhook/config.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
CallURL string
|
||||
Method string
|
||||
Headers http.Header
|
||||
}
|
||||
|
||||
func (w *Config) Validate() error {
|
||||
_, err := url.Parse(w.CallURL)
|
||||
return err
|
||||
}
|
50
apps/api/internal/notification/handlers/already_handled.go
Normal file
50
apps/api/internal/notification/handlers/already_handled.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
)
|
||||
|
||||
type alreadyHandled struct {
|
||||
event eventstore.Event
|
||||
eventTypes []eventstore.EventType
|
||||
data map[string]interface{}
|
||||
|
||||
handled bool
|
||||
}
|
||||
|
||||
func (a *alreadyHandled) Reduce() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *alreadyHandled) AppendEvents(event ...eventstore.Event) {
|
||||
if len(event) > 0 {
|
||||
a.handled = true
|
||||
}
|
||||
}
|
||||
|
||||
func (a *alreadyHandled) Query() *eventstore.SearchQueryBuilder {
|
||||
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
|
||||
InstanceID(a.event.Aggregate().InstanceID).
|
||||
SequenceGreater(a.event.Sequence()).
|
||||
AddQuery().
|
||||
AggregateTypes(a.event.Aggregate().Type).
|
||||
AggregateIDs(a.event.Aggregate().ID).
|
||||
EventTypes(a.eventTypes...).
|
||||
EventData(a.data).
|
||||
Builder()
|
||||
}
|
||||
|
||||
func (n *NotificationQueries) IsAlreadyHandled(ctx context.Context, event eventstore.Event, data map[string]interface{}, eventTypes ...eventstore.EventType) (bool, error) {
|
||||
already := &alreadyHandled{
|
||||
event: event,
|
||||
eventTypes: eventTypes,
|
||||
data: data,
|
||||
}
|
||||
err := n.es.FilterToQueryReducer(ctx, already)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return already.handled, nil
|
||||
}
|
250
apps/api/internal/notification/handlers/back_channel_logout.go
Normal file
250
apps/api/internal/notification/handlers/back_channel_logout.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/oidc/v3/pkg/crypto"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
zoidc "github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
zcrypto "github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
|
||||
"github.com/zitadel/zitadel/internal/id"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/set"
|
||||
_ "github.com/zitadel/zitadel/internal/notification/statik"
|
||||
"github.com/zitadel/zitadel/internal/notification/types"
|
||||
"github.com/zitadel/zitadel/internal/repository/session"
|
||||
"github.com/zitadel/zitadel/internal/repository/sessionlogout"
|
||||
"github.com/zitadel/zitadel/internal/repository/user"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
BackChannelLogoutNotificationsProjectionTable = "projections.notifications_back_channel_logout"
|
||||
)
|
||||
|
||||
type backChannelLogoutNotifier struct {
|
||||
commands *command.Commands
|
||||
queries *NotificationQueries
|
||||
eventstore *eventstore.Eventstore
|
||||
keyEncryptionAlg zcrypto.EncryptionAlgorithm
|
||||
channels types.ChannelChains
|
||||
idGenerator id.Generator
|
||||
tokenLifetime time.Duration
|
||||
}
|
||||
|
||||
func NewBackChannelLogoutNotifier(
|
||||
ctx context.Context,
|
||||
config handler.Config,
|
||||
commands *command.Commands,
|
||||
queries *NotificationQueries,
|
||||
es *eventstore.Eventstore,
|
||||
keyEncryptionAlg zcrypto.EncryptionAlgorithm,
|
||||
channels types.ChannelChains,
|
||||
tokenLifetime time.Duration,
|
||||
) *handler.Handler {
|
||||
return handler.NewHandler(ctx, &config, &backChannelLogoutNotifier{
|
||||
commands: commands,
|
||||
queries: queries,
|
||||
eventstore: es,
|
||||
keyEncryptionAlg: keyEncryptionAlg,
|
||||
channels: channels,
|
||||
tokenLifetime: tokenLifetime,
|
||||
idGenerator: id.SonyFlakeGenerator(),
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func (*backChannelLogoutNotifier) Name() string {
|
||||
return BackChannelLogoutNotificationsProjectionTable
|
||||
}
|
||||
|
||||
func (u *backChannelLogoutNotifier) Reducers() []handler.AggregateReducer {
|
||||
return []handler.AggregateReducer{
|
||||
{
|
||||
Aggregate: session.AggregateType,
|
||||
EventReducers: []handler.EventReducer{
|
||||
{
|
||||
Event: session.TerminateType,
|
||||
Reduce: u.reduceSessionTerminated,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
Aggregate: user.AggregateType,
|
||||
EventReducers: []handler.EventReducer{
|
||||
{
|
||||
Event: user.HumanSignedOutType,
|
||||
Reduce: u.reduceUserSignedOut,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (u *backChannelLogoutNotifier) reduceUserSignedOut(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanSignedOutEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Gr63h", "reduce.wrong.event.type %s", user.HumanSignedOutType)
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx, err := u.queries.HandlerContext(ctx, event.Aggregate())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !authz.GetFeatures(ctx).EnableBackChannelLogout {
|
||||
return nil
|
||||
}
|
||||
if e.SessionID == "" {
|
||||
return nil
|
||||
}
|
||||
return u.terminateSession(ctx, e.SessionID, e)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *backChannelLogoutNotifier) reduceSessionTerminated(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*session.TerminateEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-D6H2h", "reduce.wrong.event.type %s", session.TerminateType)
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx, err := u.queries.HandlerContext(ctx, event.Aggregate())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !authz.GetFeatures(ctx).EnableBackChannelLogout {
|
||||
return nil
|
||||
}
|
||||
return u.terminateSession(ctx, e.Aggregate().ID, e)
|
||||
}), nil
|
||||
}
|
||||
|
||||
type backChannelLogoutSession struct {
|
||||
sessionID string
|
||||
|
||||
// sessions contain a map of oidc session IDs and their corresponding clientID
|
||||
sessions []backChannelLogoutOIDCSessions
|
||||
}
|
||||
|
||||
func (u *backChannelLogoutNotifier) terminateSession(ctx context.Context, id string, e eventstore.Event) error {
|
||||
sessions := &backChannelLogoutSession{sessionID: id}
|
||||
err := u.eventstore.FilterToQueryReducer(ctx, sessions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
getSigner := zoidc.GetSignerOnce(u.queries.GetActiveSigningWebKey)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(sessions.sessions))
|
||||
errs := make([]error, 0, len(sessions.sessions))
|
||||
for _, oidcSession := range sessions.sessions {
|
||||
go func(oidcSession *backChannelLogoutOIDCSessions) {
|
||||
defer wg.Done()
|
||||
err := u.sendLogoutToken(ctx, oidcSession, e, getSigner)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
return
|
||||
}
|
||||
err = u.commands.BackChannelLogoutSent(ctx, oidcSession.SessionID, oidcSession.OIDCSessionID, e.Aggregate().InstanceID)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}(&oidcSession)
|
||||
}
|
||||
wg.Wait()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (u *backChannelLogoutNotifier) sendLogoutToken(ctx context.Context, oidcSession *backChannelLogoutOIDCSessions, e eventstore.Event, getSigner zoidc.SignerFunc) error {
|
||||
token, err := u.logoutToken(ctx, oidcSession, getSigner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = types.SendSecurityTokenEvent(ctx, set.Config{CallURL: oidcSession.BackChannelLogoutURI}, u.channels, &LogoutTokenMessage{LogoutToken: token}, e.Type()).WithoutTemplate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *backChannelLogoutNotifier) logoutToken(ctx context.Context, oidcSession *backChannelLogoutOIDCSessions, getSigner zoidc.SignerFunc) (string, error) {
|
||||
jwtID, err := u.idGenerator.Next()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := oidc.NewLogoutTokenClaims(
|
||||
http_utils.DomainContext(ctx).Origin(),
|
||||
oidcSession.UserID,
|
||||
oidc.Audience{oidcSession.ClientID},
|
||||
time.Now().Add(u.tokenLifetime),
|
||||
jwtID,
|
||||
oidcSession.SessionID,
|
||||
time.Second,
|
||||
)
|
||||
signer, _, err := getSigner(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return crypto.Sign(token, signer)
|
||||
}
|
||||
|
||||
type LogoutTokenMessage struct {
|
||||
LogoutToken string `schema:"logout_token"`
|
||||
}
|
||||
|
||||
type backChannelLogoutOIDCSessions struct {
|
||||
SessionID string
|
||||
OIDCSessionID string
|
||||
UserID string
|
||||
ClientID string
|
||||
BackChannelLogoutURI string
|
||||
}
|
||||
|
||||
func (b *backChannelLogoutSession) Reduce() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backChannelLogoutSession) AppendEvents(events ...eventstore.Event) {
|
||||
for _, event := range events {
|
||||
switch e := event.(type) {
|
||||
case *sessionlogout.BackChannelLogoutRegisteredEvent:
|
||||
b.sessions = append(b.sessions, backChannelLogoutOIDCSessions{
|
||||
SessionID: b.sessionID,
|
||||
OIDCSessionID: e.OIDCSessionID,
|
||||
UserID: e.UserID,
|
||||
ClientID: e.ClientID,
|
||||
BackChannelLogoutURI: e.BackChannelLogoutURI,
|
||||
})
|
||||
case *sessionlogout.BackChannelLogoutSentEvent:
|
||||
b.sessions = slices.DeleteFunc(b.sessions, func(session backChannelLogoutOIDCSessions) bool {
|
||||
return session.OIDCSessionID == e.OIDCSessionID
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backChannelLogoutSession) Query() *eventstore.SearchQueryBuilder {
|
||||
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
|
||||
AddQuery().
|
||||
AggregateTypes(sessionlogout.AggregateType).
|
||||
AggregateIDs(b.sessionID).
|
||||
EventTypes(
|
||||
sessionlogout.BackChannelLogoutRegisteredType,
|
||||
sessionlogout.BackChannelLogoutSentType).
|
||||
Builder()
|
||||
}
|
26
apps/api/internal/notification/handlers/commands.go
Normal file
26
apps/api/internal/notification/handlers/commands.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/notification/senders"
|
||||
"github.com/zitadel/zitadel/internal/repository/milestone"
|
||||
"github.com/zitadel/zitadel/internal/repository/quota"
|
||||
)
|
||||
|
||||
type Commands interface {
|
||||
HumanInitCodeSent(ctx context.Context, orgID, userID string) error
|
||||
HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error
|
||||
PasswordCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error
|
||||
HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error
|
||||
HumanOTPEmailCodeSent(ctx context.Context, userID, resourceOwner string) error
|
||||
OTPSMSSent(ctx context.Context, sessionID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error
|
||||
OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error
|
||||
UserDomainClaimedSent(ctx context.Context, orgID, userID string) error
|
||||
HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error
|
||||
PasswordChangeSent(ctx context.Context, orgID, userID string) error
|
||||
HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error
|
||||
InviteCodeSent(ctx context.Context, orgID, userID string) error
|
||||
UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error
|
||||
MilestonePushed(ctx context.Context, instanceID string, msType milestone.Type, endpoints []string) error
|
||||
}
|
59
apps/api/internal/notification/handlers/config_email.go
Normal file
59
apps/api/internal/notification/handlers/config_email.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/email"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/smtp"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
// GetSMTPConfig reads the iam SMTP provider config
|
||||
func (n *NotificationQueries) GetActiveEmailConfig(ctx context.Context) (*email.Config, error) {
|
||||
config, err := n.SMTPConfigActive(ctx, authz.GetInstance(ctx).InstanceID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
provider := &email.Provider{
|
||||
ID: config.ID,
|
||||
Description: config.Description,
|
||||
}
|
||||
if config.SMTPConfig != nil {
|
||||
if config.SMTPConfig.Password == nil {
|
||||
return nil, zerrors.ThrowNotFound(err, "QUERY-Wrs3gw", "Errors.SMTPConfig.NotFound")
|
||||
}
|
||||
password, err := crypto.DecryptString(config.SMTPConfig.Password, n.SMTPPasswordCrypto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &email.Config{
|
||||
ProviderConfig: provider,
|
||||
SMTPConfig: &smtp.Config{
|
||||
From: config.SMTPConfig.SenderAddress,
|
||||
FromName: config.SMTPConfig.SenderName,
|
||||
ReplyToAddress: config.SMTPConfig.ReplyToAddress,
|
||||
Tls: config.SMTPConfig.TLS,
|
||||
SMTP: smtp.SMTP{
|
||||
Host: config.SMTPConfig.Host,
|
||||
User: config.SMTPConfig.User,
|
||||
Password: password,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
if config.HTTPConfig != nil {
|
||||
return &email.Config{
|
||||
ProviderConfig: provider,
|
||||
WebhookConfig: &webhook.Config{
|
||||
CallURL: config.HTTPConfig.Endpoint,
|
||||
Method: http.MethodPost,
|
||||
Headers: nil,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return nil, zerrors.ThrowNotFound(err, "QUERY-KPQleOckOV", "Errors.SMTPConfig.NotFound")
|
||||
}
|
21
apps/api/internal/notification/handlers/config_filesystem.go
Normal file
21
apps/api/internal/notification/handlers/config_filesystem.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/fs"
|
||||
)
|
||||
|
||||
// GetFileSystemProvider reads the iam filesystem provider config
|
||||
func (n *NotificationQueries) GetFileSystemProvider(ctx context.Context) (*fs.Config, error) {
|
||||
config, err := n.NotificationProviderByIDAndType(ctx, authz.GetInstance(ctx).InstanceID(), domain.NotificationProviderTypeFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fs.Config{
|
||||
Compact: config.Compact,
|
||||
Path: n.fileSystemPath,
|
||||
}, nil
|
||||
}
|
20
apps/api/internal/notification/handlers/config_log.go
Normal file
20
apps/api/internal/notification/handlers/config_log.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/log"
|
||||
)
|
||||
|
||||
// GetLogProvider reads the iam log provider config
|
||||
func (n *NotificationQueries) GetLogProvider(ctx context.Context) (*log.Config, error) {
|
||||
config, err := n.NotificationProviderByIDAndType(ctx, authz.GetInstance(ctx).InstanceID(), domain.NotificationProviderTypeLog)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &log.Config{
|
||||
Compact: config.Compact,
|
||||
}, nil
|
||||
}
|
56
apps/api/internal/notification/handlers/config_sms.go
Normal file
56
apps/api/internal/notification/handlers/config_sms.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/sms"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
// GetActiveSMSConfig reads the active iam sms provider config
|
||||
func (n *NotificationQueries) GetActiveSMSConfig(ctx context.Context) (*sms.Config, error) {
|
||||
config, err := n.SMSProviderConfigActive(ctx, authz.GetInstance(ctx).InstanceID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider := &sms.Provider{
|
||||
ID: config.ID,
|
||||
Description: config.Description,
|
||||
}
|
||||
if config.TwilioConfig != nil {
|
||||
if config.TwilioConfig.Token == nil {
|
||||
return nil, zerrors.ThrowNotFound(err, "QUERY-SFefsd", "Errors.SMS.Twilio.NotFound")
|
||||
}
|
||||
token, err := crypto.DecryptString(config.TwilioConfig.Token, n.SMSTokenCrypto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sms.Config{
|
||||
ProviderConfig: provider,
|
||||
TwilioConfig: &twilio.Config{
|
||||
SID: config.TwilioConfig.SID,
|
||||
Token: token,
|
||||
SenderNumber: config.TwilioConfig.SenderNumber,
|
||||
VerifyServiceSID: config.TwilioConfig.VerifyServiceSID,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
if config.HTTPConfig != nil {
|
||||
return &sms.Config{
|
||||
ProviderConfig: provider,
|
||||
WebhookConfig: &webhook.Config{
|
||||
CallURL: config.HTTPConfig.Endpoint,
|
||||
Method: http.MethodPost,
|
||||
Headers: nil,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, zerrors.ThrowNotFound(nil, "HANDLER-8nfow", "Errors.SMS.Twilio.NotFound")
|
||||
}
|
28
apps/api/internal/notification/handlers/ctx.go
Normal file
28
apps/api/internal/notification/handlers/ctx.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
)
|
||||
|
||||
const NotifyUserID = "NOTIFICATION" //TODO: system?
|
||||
|
||||
func HandlerContext(parent context.Context, event *eventstore.Aggregate) context.Context {
|
||||
ctx := authz.WithInstanceID(parent, event.InstanceID)
|
||||
return authz.SetCtxData(ctx, authz.CtxData{UserID: NotifyUserID, OrgID: event.ResourceOwner})
|
||||
}
|
||||
|
||||
func ContextWithNotifier(ctx context.Context, aggregate *eventstore.Aggregate) context.Context {
|
||||
return authz.WithInstanceID(authz.SetCtxData(ctx, authz.CtxData{UserID: NotifyUserID, OrgID: aggregate.ResourceOwner}), aggregate.InstanceID)
|
||||
}
|
||||
|
||||
func (n *NotificationQueries) HandlerContext(parent context.Context, event *eventstore.Aggregate) (context.Context, error) {
|
||||
instance, err := n.InstanceByID(parent, event.InstanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx := authz.WithInstance(parent, instance)
|
||||
return authz.SetCtxData(ctx, authz.CtxData{UserID: NotifyUserID, OrgID: event.ResourceOwner}), nil
|
||||
}
|
5
apps/api/internal/notification/handlers/gen_mock.go
Normal file
5
apps/api/internal/notification/handlers/gen_mock.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package handlers
|
||||
|
||||
//go:generate mockgen -package mock -destination ./mock/queries.mock.go github.com/zitadel/zitadel/internal/notification/handlers Queries
|
||||
//go:generate mockgen -package mock -destination ./mock/commands.mock.go github.com/zitadel/zitadel/internal/notification/handlers Commands
|
||||
//go:generate mockgen -package mock -destination ./mock/queue.mock.go github.com/zitadel/zitadel/internal/notification/handlers Queue
|
@@ -0,0 +1,23 @@
|
||||
//go:build integration
|
||||
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
CTX context.Context
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(func() int {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
CTX = ctx
|
||||
return m.Run()
|
||||
}())
|
||||
}
|
@@ -0,0 +1,159 @@
|
||||
//go:build integration
|
||||
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/internal/integration/sink"
|
||||
"github.com/zitadel/zitadel/internal/repository/milestone"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/app"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/object"
|
||||
oidc_v2 "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/project"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/system"
|
||||
)
|
||||
|
||||
func TestServer_TelemetryPushMilestones(t *testing.T) {
|
||||
sub := sink.Subscribe(CTX, sink.ChannelMilestone)
|
||||
defer sub.Close()
|
||||
|
||||
instance := integration.NewInstance(CTX)
|
||||
iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
t.Log("testing against instance", instance.ID())
|
||||
awaitMilestone(t, sub, instance.ID(), milestone.InstanceCreated)
|
||||
|
||||
projectAdded, err := instance.Client.Mgmt.AddProject(iamOwnerCtx, &management.AddProjectRequest{Name: "integration"})
|
||||
require.NoError(t, err)
|
||||
awaitMilestone(t, sub, instance.ID(), milestone.ProjectCreated)
|
||||
|
||||
redirectURI := "http://localhost:8888"
|
||||
application, err := instance.Client.Mgmt.AddOIDCApp(iamOwnerCtx, &management.AddOIDCAppRequest{
|
||||
ProjectId: projectAdded.GetId(),
|
||||
Name: "integration",
|
||||
RedirectUris: []string{redirectURI},
|
||||
ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE},
|
||||
GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE},
|
||||
AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB,
|
||||
AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE,
|
||||
DevMode: true,
|
||||
AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
awaitMilestone(t, sub, instance.ID(), milestone.ApplicationCreated)
|
||||
|
||||
// create the session to be used for the authN of the clients
|
||||
sessionID, sessionToken, _, _ := instance.CreatePasswordSession(t, iamOwnerCtx, instance.AdminUserID, "Password1!")
|
||||
|
||||
console := consoleOIDCConfig(t, instance)
|
||||
loginToClient(t, instance, console.GetClientId(), console.GetRedirectUris()[0], sessionID, sessionToken)
|
||||
awaitMilestone(t, sub, instance.ID(), milestone.AuthenticationSucceededOnInstance)
|
||||
|
||||
// make sure the client has been projected
|
||||
require.EventuallyWithT(t, func(collectT *assert.CollectT) {
|
||||
_, err := instance.Client.Mgmt.GetAppByID(iamOwnerCtx, &management.GetAppByIDRequest{
|
||||
ProjectId: projectAdded.GetId(),
|
||||
AppId: application.GetAppId(),
|
||||
})
|
||||
assert.NoError(collectT, err)
|
||||
}, time.Minute, time.Second, "app not found")
|
||||
loginToClient(t, instance, application.GetClientId(), redirectURI, sessionID, sessionToken)
|
||||
awaitMilestone(t, sub, instance.ID(), milestone.AuthenticationSucceededOnApplication)
|
||||
|
||||
_, err = integration.SystemClient().RemoveInstance(CTX, &system.RemoveInstanceRequest{InstanceId: instance.ID()})
|
||||
require.NoError(t, err)
|
||||
awaitMilestone(t, sub, instance.ID(), milestone.InstanceDeleted)
|
||||
}
|
||||
|
||||
func loginToClient(t *testing.T, instance *integration.Instance, clientID, redirectURI, sessionID, sessionToken string) {
|
||||
iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
|
||||
_, authRequestID, err := instance.CreateOIDCAuthRequestWithDomain(iamOwnerCtx, instance.Domain, clientID, instance.Users.Get(integration.UserTypeIAMOwner).ID, redirectURI, "openid")
|
||||
require.NoError(t, err)
|
||||
callback, err := instance.Client.OIDCv2.CreateCallback(iamOwnerCtx, &oidc_v2.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_v2.CreateCallbackRequest_Session{Session: &oidc_v2.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
provider, err := instance.CreateRelyingPartyForDomain(iamOwnerCtx, instance.Domain, clientID, redirectURI, instance.Users.Get(integration.UserTypeLogin).Username)
|
||||
require.NoError(t, err)
|
||||
callbackURL, err := url.Parse(callback.GetCallbackUrl())
|
||||
require.NoError(t, err)
|
||||
code := callbackURL.Query().Get("code")
|
||||
_, err = rp.CodeExchange[*oidc.IDTokenClaims](iamOwnerCtx, code, provider, rp.WithCodeVerifier(integration.CodeVerifier))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func consoleOIDCConfig(t *testing.T, instance *integration.Instance) *app.OIDCConfig {
|
||||
iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
|
||||
projects, err := instance.Client.Mgmt.ListProjects(iamOwnerCtx, &management.ListProjectsRequest{
|
||||
Queries: []*project.ProjectQuery{
|
||||
{
|
||||
Query: &project.ProjectQuery_NameQuery{
|
||||
NameQuery: &project.ProjectNameQuery{
|
||||
Name: "ZITADEL",
|
||||
Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, projects.GetResult(), 1)
|
||||
apps, err := instance.Client.Mgmt.ListApps(iamOwnerCtx, &management.ListAppsRequest{
|
||||
ProjectId: projects.GetResult()[0].GetId(),
|
||||
Queries: []*app.AppQuery{
|
||||
{
|
||||
Query: &app.AppQuery_NameQuery{
|
||||
NameQuery: &app.AppNameQuery{
|
||||
Name: "Console",
|
||||
Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, apps.GetResult(), 1)
|
||||
return apps.GetResult()[0].GetOidcConfig()
|
||||
}
|
||||
|
||||
func awaitMilestone(t *testing.T, sub *sink.Subscription, instanceID string, expectMilestoneType milestone.Type) {
|
||||
for {
|
||||
select {
|
||||
case req := <-sub.Recv():
|
||||
plain := new(bytes.Buffer)
|
||||
if err := json.Indent(plain, req.Body, "", " "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Log("received milestone", plain.String())
|
||||
milestone := struct {
|
||||
InstanceID string `json:"instanceId"`
|
||||
Type milestone.Type `json:"type"`
|
||||
}{}
|
||||
if err := json.Unmarshal(req.Body, &milestone); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if milestone.Type == expectMilestoneType && milestone.InstanceID == instanceID {
|
||||
return
|
||||
}
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatalf("timed out waiting for milestone %s for instance %s", expectMilestoneType, instanceID)
|
||||
}
|
||||
}
|
||||
}
|
240
apps/api/internal/notification/handlers/mock/commands.mock.go
Normal file
240
apps/api/internal/notification/handlers/mock/commands.mock.go
Normal file
@@ -0,0 +1,240 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/zitadel/zitadel/internal/notification/handlers (interfaces: Commands)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package mock -destination ./mock/commands.mock.go github.com/zitadel/zitadel/internal/notification/handlers Commands
|
||||
//
|
||||
|
||||
// Package mock is a generated GoMock package.
|
||||
package mock
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
senders "github.com/zitadel/zitadel/internal/notification/senders"
|
||||
milestone "github.com/zitadel/zitadel/internal/repository/milestone"
|
||||
quota "github.com/zitadel/zitadel/internal/repository/quota"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockCommands is a mock of Commands interface.
|
||||
type MockCommands struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockCommandsMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockCommandsMockRecorder is the mock recorder for MockCommands.
|
||||
type MockCommandsMockRecorder struct {
|
||||
mock *MockCommands
|
||||
}
|
||||
|
||||
// NewMockCommands creates a new mock instance.
|
||||
func NewMockCommands(ctrl *gomock.Controller) *MockCommands {
|
||||
mock := &MockCommands{ctrl: ctrl}
|
||||
mock.recorder = &MockCommandsMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockCommands) EXPECT() *MockCommandsMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// HumanEmailVerificationCodeSent mocks base method.
|
||||
func (m *MockCommands) HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", ctx, orgID, userID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent.
|
||||
func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(ctx, orgID, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), ctx, orgID, userID)
|
||||
}
|
||||
|
||||
// HumanInitCodeSent mocks base method.
|
||||
func (m *MockCommands) HumanInitCodeSent(ctx context.Context, orgID, userID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HumanInitCodeSent", ctx, orgID, userID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// HumanInitCodeSent indicates an expected call of HumanInitCodeSent.
|
||||
func (mr *MockCommandsMockRecorder) HumanInitCodeSent(ctx, orgID, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), ctx, orgID, userID)
|
||||
}
|
||||
|
||||
// HumanOTPEmailCodeSent mocks base method.
|
||||
func (m *MockCommands) HumanOTPEmailCodeSent(ctx context.Context, userID, resourceOwner string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", ctx, userID, resourceOwner)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent.
|
||||
func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(ctx, userID, resourceOwner any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), ctx, userID, resourceOwner)
|
||||
}
|
||||
|
||||
// HumanOTPSMSCodeSent mocks base method.
|
||||
func (m *MockCommands) HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", ctx, userID, resourceOwner, generatorInfo)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent.
|
||||
func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(ctx, userID, resourceOwner, generatorInfo any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), ctx, userID, resourceOwner, generatorInfo)
|
||||
}
|
||||
|
||||
// HumanPasswordlessInitCodeSent mocks base method.
|
||||
func (m *MockCommands) HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", ctx, userID, resourceOwner, codeID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent.
|
||||
func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(ctx, userID, resourceOwner, codeID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), ctx, userID, resourceOwner, codeID)
|
||||
}
|
||||
|
||||
// HumanPhoneVerificationCodeSent mocks base method.
|
||||
func (m *MockCommands) HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", ctx, orgID, userID, generatorInfo)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent.
|
||||
func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(ctx, orgID, userID, generatorInfo any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), ctx, orgID, userID, generatorInfo)
|
||||
}
|
||||
|
||||
// InviteCodeSent mocks base method.
|
||||
func (m *MockCommands) InviteCodeSent(ctx context.Context, orgID, userID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "InviteCodeSent", ctx, orgID, userID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// InviteCodeSent indicates an expected call of InviteCodeSent.
|
||||
func (mr *MockCommandsMockRecorder) InviteCodeSent(ctx, orgID, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), ctx, orgID, userID)
|
||||
}
|
||||
|
||||
// MilestonePushed mocks base method.
|
||||
func (m *MockCommands) MilestonePushed(ctx context.Context, instanceID string, msType milestone.Type, endpoints []string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "MilestonePushed", ctx, instanceID, msType, endpoints)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// MilestonePushed indicates an expected call of MilestonePushed.
|
||||
func (mr *MockCommandsMockRecorder) MilestonePushed(ctx, instanceID, msType, endpoints any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), ctx, instanceID, msType, endpoints)
|
||||
}
|
||||
|
||||
// OTPEmailSent mocks base method.
|
||||
func (m *MockCommands) OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "OTPEmailSent", ctx, sessionID, resourceOwner)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// OTPEmailSent indicates an expected call of OTPEmailSent.
|
||||
func (mr *MockCommandsMockRecorder) OTPEmailSent(ctx, sessionID, resourceOwner any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), ctx, sessionID, resourceOwner)
|
||||
}
|
||||
|
||||
// OTPSMSSent mocks base method.
|
||||
func (m *MockCommands) OTPSMSSent(ctx context.Context, sessionID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "OTPSMSSent", ctx, sessionID, resourceOwner, generatorInfo)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// OTPSMSSent indicates an expected call of OTPSMSSent.
|
||||
func (mr *MockCommandsMockRecorder) OTPSMSSent(ctx, sessionID, resourceOwner, generatorInfo any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), ctx, sessionID, resourceOwner, generatorInfo)
|
||||
}
|
||||
|
||||
// PasswordChangeSent mocks base method.
|
||||
func (m *MockCommands) PasswordChangeSent(ctx context.Context, orgID, userID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "PasswordChangeSent", ctx, orgID, userID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// PasswordChangeSent indicates an expected call of PasswordChangeSent.
|
||||
func (mr *MockCommandsMockRecorder) PasswordChangeSent(ctx, orgID, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), ctx, orgID, userID)
|
||||
}
|
||||
|
||||
// PasswordCodeSent mocks base method.
|
||||
func (m *MockCommands) PasswordCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "PasswordCodeSent", ctx, orgID, userID, generatorInfo)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// PasswordCodeSent indicates an expected call of PasswordCodeSent.
|
||||
func (mr *MockCommandsMockRecorder) PasswordCodeSent(ctx, orgID, userID, generatorInfo any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), ctx, orgID, userID, generatorInfo)
|
||||
}
|
||||
|
||||
// UsageNotificationSent mocks base method.
|
||||
func (m *MockCommands) UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UsageNotificationSent", ctx, dueEvent)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UsageNotificationSent indicates an expected call of UsageNotificationSent.
|
||||
func (mr *MockCommandsMockRecorder) UsageNotificationSent(ctx, dueEvent any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), ctx, dueEvent)
|
||||
}
|
||||
|
||||
// UserDomainClaimedSent mocks base method.
|
||||
func (m *MockCommands) UserDomainClaimedSent(ctx context.Context, orgID, userID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UserDomainClaimedSent", ctx, orgID, userID)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent.
|
||||
func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(ctx, orgID, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), ctx, orgID, userID)
|
||||
}
|
284
apps/api/internal/notification/handlers/mock/queries.mock.go
Normal file
284
apps/api/internal/notification/handlers/mock/queries.mock.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/zitadel/zitadel/internal/notification/handlers (interfaces: Queries)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package mock -destination ./mock/queries.mock.go github.com/zitadel/zitadel/internal/notification/handlers Queries
|
||||
//
|
||||
|
||||
// Package mock is a generated GoMock package.
|
||||
package mock
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
authz "github.com/zitadel/zitadel/internal/api/authz"
|
||||
domain "github.com/zitadel/zitadel/internal/domain"
|
||||
query "github.com/zitadel/zitadel/internal/query"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
language "golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// MockQueries is a mock of Queries interface.
|
||||
type MockQueries struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockQueriesMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockQueriesMockRecorder is the mock recorder for MockQueries.
|
||||
type MockQueriesMockRecorder struct {
|
||||
mock *MockQueries
|
||||
}
|
||||
|
||||
// NewMockQueries creates a new mock instance.
|
||||
func NewMockQueries(ctrl *gomock.Controller) *MockQueries {
|
||||
mock := &MockQueries{ctrl: ctrl}
|
||||
mock.recorder = &MockQueriesMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockQueries) EXPECT() *MockQueriesMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// ActiveInstances mocks base method.
|
||||
func (m *MockQueries) ActiveInstances() []string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ActiveInstances")
|
||||
ret0, _ := ret[0].([]string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ActiveInstances indicates an expected call of ActiveInstances.
|
||||
func (mr *MockQueriesMockRecorder) ActiveInstances() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveInstances", reflect.TypeOf((*MockQueries)(nil).ActiveInstances))
|
||||
}
|
||||
|
||||
// ActiveLabelPolicyByOrg mocks base method.
|
||||
func (m *MockQueries) ActiveLabelPolicyByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.LabelPolicy, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ActiveLabelPolicyByOrg", ctx, orgID, withOwnerRemoved)
|
||||
ret0, _ := ret[0].(*query.LabelPolicy)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ActiveLabelPolicyByOrg indicates an expected call of ActiveLabelPolicyByOrg.
|
||||
func (mr *MockQueriesMockRecorder) ActiveLabelPolicyByOrg(ctx, orgID, withOwnerRemoved any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveLabelPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).ActiveLabelPolicyByOrg), ctx, orgID, withOwnerRemoved)
|
||||
}
|
||||
|
||||
// CustomTextListByTemplate mocks base method.
|
||||
func (m *MockQueries) CustomTextListByTemplate(ctx context.Context, aggregateID, template string, withOwnerRemoved bool) (*query.CustomTexts, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CustomTextListByTemplate", ctx, aggregateID, template, withOwnerRemoved)
|
||||
ret0, _ := ret[0].(*query.CustomTexts)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CustomTextListByTemplate indicates an expected call of CustomTextListByTemplate.
|
||||
func (mr *MockQueriesMockRecorder) CustomTextListByTemplate(ctx, aggregateID, template, withOwnerRemoved any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomTextListByTemplate", reflect.TypeOf((*MockQueries)(nil).CustomTextListByTemplate), ctx, aggregateID, template, withOwnerRemoved)
|
||||
}
|
||||
|
||||
// GetActiveSigningWebKey mocks base method.
|
||||
func (m *MockQueries) GetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetActiveSigningWebKey", ctx)
|
||||
ret0, _ := ret[0].(*jose.JSONWebKey)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetActiveSigningWebKey indicates an expected call of GetActiveSigningWebKey.
|
||||
func (mr *MockQueriesMockRecorder) GetActiveSigningWebKey(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveSigningWebKey", reflect.TypeOf((*MockQueries)(nil).GetActiveSigningWebKey), ctx)
|
||||
}
|
||||
|
||||
// GetDefaultLanguage mocks base method.
|
||||
func (m *MockQueries) GetDefaultLanguage(ctx context.Context) language.Tag {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetDefaultLanguage", ctx)
|
||||
ret0, _ := ret[0].(language.Tag)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetDefaultLanguage indicates an expected call of GetDefaultLanguage.
|
||||
func (mr *MockQueriesMockRecorder) GetDefaultLanguage(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultLanguage", reflect.TypeOf((*MockQueries)(nil).GetDefaultLanguage), ctx)
|
||||
}
|
||||
|
||||
// GetInstanceRestrictions mocks base method.
|
||||
func (m *MockQueries) GetInstanceRestrictions(ctx context.Context) (query.Restrictions, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetInstanceRestrictions", ctx)
|
||||
ret0, _ := ret[0].(query.Restrictions)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetInstanceRestrictions indicates an expected call of GetInstanceRestrictions.
|
||||
func (mr *MockQueriesMockRecorder) GetInstanceRestrictions(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceRestrictions", reflect.TypeOf((*MockQueries)(nil).GetInstanceRestrictions), ctx)
|
||||
}
|
||||
|
||||
// GetNotifyUserByID mocks base method.
|
||||
func (m *MockQueries) GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string) (*query.NotifyUser, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetNotifyUserByID", ctx, shouldTriggered, userID)
|
||||
ret0, _ := ret[0].(*query.NotifyUser)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetNotifyUserByID indicates an expected call of GetNotifyUserByID.
|
||||
func (mr *MockQueriesMockRecorder) GetNotifyUserByID(ctx, shouldTriggered, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), ctx, shouldTriggered, userID)
|
||||
}
|
||||
|
||||
// InstanceByID mocks base method.
|
||||
func (m *MockQueries) InstanceByID(ctx context.Context, id string) (authz.Instance, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "InstanceByID", ctx, id)
|
||||
ret0, _ := ret[0].(authz.Instance)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// InstanceByID indicates an expected call of InstanceByID.
|
||||
func (mr *MockQueriesMockRecorder) InstanceByID(ctx, id any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceByID", reflect.TypeOf((*MockQueries)(nil).InstanceByID), ctx, id)
|
||||
}
|
||||
|
||||
// MailTemplateByOrg mocks base method.
|
||||
func (m *MockQueries) MailTemplateByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.MailTemplate, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "MailTemplateByOrg", ctx, orgID, withOwnerRemoved)
|
||||
ret0, _ := ret[0].(*query.MailTemplate)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// MailTemplateByOrg indicates an expected call of MailTemplateByOrg.
|
||||
func (mr *MockQueriesMockRecorder) MailTemplateByOrg(ctx, orgID, withOwnerRemoved any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MailTemplateByOrg", reflect.TypeOf((*MockQueries)(nil).MailTemplateByOrg), ctx, orgID, withOwnerRemoved)
|
||||
}
|
||||
|
||||
// NotificationPolicyByOrg mocks base method.
|
||||
func (m *MockQueries) NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "NotificationPolicyByOrg", ctx, shouldTriggerBulk, orgID, withOwnerRemoved)
|
||||
ret0, _ := ret[0].(*query.NotificationPolicy)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// NotificationPolicyByOrg indicates an expected call of NotificationPolicyByOrg.
|
||||
func (mr *MockQueriesMockRecorder) NotificationPolicyByOrg(ctx, shouldTriggerBulk, orgID, withOwnerRemoved any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).NotificationPolicyByOrg), ctx, shouldTriggerBulk, orgID, withOwnerRemoved)
|
||||
}
|
||||
|
||||
// NotificationProviderByIDAndType mocks base method.
|
||||
func (m *MockQueries) NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "NotificationProviderByIDAndType", ctx, aggID, providerType)
|
||||
ret0, _ := ret[0].(*query.DebugNotificationProvider)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// NotificationProviderByIDAndType indicates an expected call of NotificationProviderByIDAndType.
|
||||
func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(ctx, aggID, providerType any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), ctx, aggID, providerType)
|
||||
}
|
||||
|
||||
// SMSProviderConfigActive mocks base method.
|
||||
func (m *MockQueries) SMSProviderConfigActive(ctx context.Context, resourceOwner string) (*query.SMSConfig, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SMSProviderConfigActive", ctx, resourceOwner)
|
||||
ret0, _ := ret[0].(*query.SMSConfig)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SMSProviderConfigActive indicates an expected call of SMSProviderConfigActive.
|
||||
func (mr *MockQueriesMockRecorder) SMSProviderConfigActive(ctx, resourceOwner any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfigActive", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfigActive), ctx, resourceOwner)
|
||||
}
|
||||
|
||||
// SMTPConfigActive mocks base method.
|
||||
func (m *MockQueries) SMTPConfigActive(ctx context.Context, resourceOwner string) (*query.SMTPConfig, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SMTPConfigActive", ctx, resourceOwner)
|
||||
ret0, _ := ret[0].(*query.SMTPConfig)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SMTPConfigActive indicates an expected call of SMTPConfigActive.
|
||||
func (mr *MockQueriesMockRecorder) SMTPConfigActive(ctx, resourceOwner any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMTPConfigActive", reflect.TypeOf((*MockQueries)(nil).SMTPConfigActive), ctx, resourceOwner)
|
||||
}
|
||||
|
||||
// SearchInstanceDomains mocks base method.
|
||||
func (m *MockQueries) SearchInstanceDomains(ctx context.Context, queries *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SearchInstanceDomains", ctx, queries)
|
||||
ret0, _ := ret[0].(*query.InstanceDomains)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SearchInstanceDomains indicates an expected call of SearchInstanceDomains.
|
||||
func (mr *MockQueriesMockRecorder) SearchInstanceDomains(ctx, queries any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstanceDomains", reflect.TypeOf((*MockQueries)(nil).SearchInstanceDomains), ctx, queries)
|
||||
}
|
||||
|
||||
// SearchMilestones mocks base method.
|
||||
func (m *MockQueries) SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SearchMilestones", ctx, instanceIDs, queries)
|
||||
ret0, _ := ret[0].(*query.Milestones)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SearchMilestones indicates an expected call of SearchMilestones.
|
||||
func (mr *MockQueriesMockRecorder) SearchMilestones(ctx, instanceIDs, queries any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMilestones", reflect.TypeOf((*MockQueries)(nil).SearchMilestones), ctx, instanceIDs, queries)
|
||||
}
|
||||
|
||||
// SessionByID mocks base method.
|
||||
func (m *MockQueries) SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string, check domain.PermissionCheck) (*query.Session, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SessionByID", ctx, shouldTriggerBulk, id, sessionToken, check)
|
||||
ret0, _ := ret[0].(*query.Session)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SessionByID indicates an expected call of SessionByID.
|
||||
func (mr *MockQueriesMockRecorder) SessionByID(ctx, shouldTriggerBulk, id, sessionToken, check any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionByID", reflect.TypeOf((*MockQueries)(nil).SessionByID), ctx, shouldTriggerBulk, id, sessionToken, check)
|
||||
}
|
62
apps/api/internal/notification/handlers/mock/queue.mock.go
Normal file
62
apps/api/internal/notification/handlers/mock/queue.mock.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/zitadel/zitadel/internal/notification/handlers (interfaces: Queue)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package mock -destination ./mock/queue.mock.go github.com/zitadel/zitadel/internal/notification/handlers Queue
|
||||
//
|
||||
|
||||
// Package mock is a generated GoMock package.
|
||||
package mock
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
river "github.com/riverqueue/river"
|
||||
queue "github.com/zitadel/zitadel/internal/queue"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockQueue is a mock of Queue interface.
|
||||
type MockQueue struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockQueueMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockQueueMockRecorder is the mock recorder for MockQueue.
|
||||
type MockQueueMockRecorder struct {
|
||||
mock *MockQueue
|
||||
}
|
||||
|
||||
// NewMockQueue creates a new mock instance.
|
||||
func NewMockQueue(ctrl *gomock.Controller) *MockQueue {
|
||||
mock := &MockQueue{ctrl: ctrl}
|
||||
mock.recorder = &MockQueueMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockQueue) EXPECT() *MockQueueMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Insert mocks base method.
|
||||
func (m *MockQueue) Insert(ctx context.Context, args river.JobArgs, opts ...queue.InsertOpt) error {
|
||||
m.ctrl.T.Helper()
|
||||
varargs := []any{ctx, args}
|
||||
for _, a := range opts {
|
||||
varargs = append(varargs, a)
|
||||
}
|
||||
ret := m.ctrl.Call(m, "Insert", varargs...)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Insert indicates an expected call of Insert.
|
||||
func (mr *MockQueueMockRecorder) Insert(ctx, args any, opts ...any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
varargs := append([]any{ctx, args}, opts...)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockQueue)(nil).Insert), varargs...)
|
||||
}
|
186
apps/api/internal/notification/handlers/notification_worker.go
Normal file
186
apps/api/internal/notification/handlers/notification_worker.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/riverqueue/river"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/notification/senders"
|
||||
"github.com/zitadel/zitadel/internal/notification/types"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/repository/notification"
|
||||
)
|
||||
|
||||
const (
|
||||
Code = "Code"
|
||||
OTP = "OTP"
|
||||
)
|
||||
|
||||
type NotificationWorker struct {
|
||||
river.WorkerDefaults[*notification.Request]
|
||||
|
||||
commands Commands
|
||||
queries *NotificationQueries
|
||||
channels types.ChannelChains
|
||||
config WorkerConfig
|
||||
now nowFunc
|
||||
}
|
||||
|
||||
// Timeout implements the Timeout-function of [river.Worker].
|
||||
// Maximum time a job can run before the context gets cancelled.
|
||||
func (w *NotificationWorker) Timeout(*river.Job[*notification.Request]) time.Duration {
|
||||
return w.config.TransactionDuration
|
||||
}
|
||||
|
||||
// Work implements [river.Worker].
|
||||
func (w *NotificationWorker) Work(ctx context.Context, job *river.Job[*notification.Request]) error {
|
||||
ctx = ContextWithNotifier(ctx, job.Args.Aggregate)
|
||||
|
||||
// if the notification is too old, we can directly cancel
|
||||
if job.CreatedAt.Add(w.config.MaxTtl).Before(w.now()) {
|
||||
return river.JobCancel(errors.New("notification is too old"))
|
||||
}
|
||||
|
||||
// We do not trigger the projection to reduce load on the database. By the time the notification is processed,
|
||||
// the user should be projected anyway. If not, it will just wait for the next run.
|
||||
// We are aware that the user can change during the time the notification is in the queue.
|
||||
notifyUser, err := w.queries.GetNotifyUserByID(ctx, false, job.Args.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The domain claimed event requires the domain as argument, but lacks the user when creating the request event.
|
||||
// Since we set it into the request arguments, it will be passed into a potential retry event.
|
||||
if job.Args.RequiresPreviousDomain && job.Args.Args != nil && job.Args.Args.Domain == "" {
|
||||
index := strings.LastIndex(notifyUser.LastEmail, "@")
|
||||
job.Args.Args.Domain = notifyUser.LastEmail[index+1:]
|
||||
}
|
||||
|
||||
err = w.sendNotificationQueue(ctx, job.Args, strconv.Itoa(int(job.ID)), notifyUser)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// if the error explicitly specifies, we cancel the notification
|
||||
if errors.Is(err, &channels.CancelError{}) {
|
||||
return river.JobCancel(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type WorkerConfig struct {
|
||||
LegacyEnabled bool
|
||||
Workers uint8
|
||||
TransactionDuration time.Duration
|
||||
MaxTtl time.Duration
|
||||
MaxAttempts uint8
|
||||
}
|
||||
|
||||
// nowFunc makes [time.Now] mockable
|
||||
type nowFunc func() time.Time
|
||||
|
||||
type Sent func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error
|
||||
|
||||
var sentHandlers map[eventstore.EventType]Sent
|
||||
|
||||
func RegisterSentHandler(eventType eventstore.EventType, sent Sent) {
|
||||
if sentHandlers == nil {
|
||||
sentHandlers = make(map[eventstore.EventType]Sent)
|
||||
}
|
||||
sentHandlers[eventType] = sent
|
||||
}
|
||||
|
||||
func NewNotificationWorker(
|
||||
config WorkerConfig,
|
||||
commands Commands,
|
||||
queries *NotificationQueries,
|
||||
channels types.ChannelChains,
|
||||
) *NotificationWorker {
|
||||
return &NotificationWorker{
|
||||
config: config,
|
||||
commands: commands,
|
||||
queries: queries,
|
||||
channels: channels,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
var _ river.Worker[*notification.Request] = (*NotificationWorker)(nil)
|
||||
|
||||
func (w *NotificationWorker) Register(workers *river.Workers, queues map[string]river.QueueConfig) {
|
||||
river.AddWorker(workers, w)
|
||||
queues[notification.QueueName] = river.QueueConfig{
|
||||
MaxWorkers: int(w.config.Workers),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *NotificationWorker) sendNotificationQueue(ctx context.Context, request *notification.Request, jobID string, notifyUser *query.NotifyUser) error {
|
||||
// check early that a "sent" handler exists, otherwise we can cancel early
|
||||
sentHandler, ok := sentHandlers[request.EventType]
|
||||
if !ok {
|
||||
logging.Errorf(`no "sent" handler registered for %s`, request.EventType)
|
||||
return channels.NewCancelError(fmt.Errorf("no sent handler registered for %s", request.EventType))
|
||||
}
|
||||
|
||||
ctx, err := enrichCtx(ctx, request.TriggeredAtOrigin)
|
||||
if err != nil {
|
||||
return channels.NewCancelError(err)
|
||||
}
|
||||
|
||||
var code string
|
||||
if request.Code != nil {
|
||||
code, err = crypto.DecryptString(request.Code, w.queries.UserDataCrypto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
colors, err := w.queries.ActiveLabelPolicyByOrg(ctx, request.UserResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
translator, err := w.queries.GetTranslatorWithOrgTexts(ctx, request.UserResourceOwner, request.MessageType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
generatorInfo := new(senders.CodeGeneratorInfo)
|
||||
var notify types.Notify
|
||||
switch request.NotificationType {
|
||||
case domain.NotificationTypeEmail:
|
||||
template, err := w.queries.MailTemplateByOrg(ctx, notifyUser.ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notify = types.SendEmail(ctx, w.channels, string(template.Template), translator, notifyUser, colors, request.EventType)
|
||||
case domain.NotificationTypeSms:
|
||||
notify = types.SendSMS(ctx, w.channels, translator, notifyUser, colors, request.EventType, request.Aggregate.InstanceID, jobID, generatorInfo)
|
||||
}
|
||||
|
||||
args := request.Args.ToMap()
|
||||
args[Code] = code
|
||||
// existing notifications use `OTP` as argument for the code
|
||||
if request.IsOTP {
|
||||
args[OTP] = code
|
||||
}
|
||||
|
||||
if err = notify(request.URLTemplate, args, request.MessageType, request.UnverifiedNotificationChannel); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = sentHandler(authz.WithInstanceID(ctx, request.Aggregate.InstanceID), w.commands, request.Aggregate.ID, request.Aggregate.ResourceOwner, generatorInfo, args)
|
||||
logging.WithFields("instanceID", request.Aggregate.InstanceID, "notification", request.Aggregate.ID).
|
||||
OnError(err).Error("could not set notification event on aggregate")
|
||||
return nil
|
||||
}
|
@@ -0,0 +1,477 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/riverqueue/river"
|
||||
"github.com/riverqueue/river/rivertype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/email"
|
||||
channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/sms"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/smtp"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
|
||||
"github.com/zitadel/zitadel/internal/notification/handlers/mock"
|
||||
"github.com/zitadel/zitadel/internal/notification/messages"
|
||||
"github.com/zitadel/zitadel/internal/notification/senders"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/repository/notification"
|
||||
"github.com/zitadel/zitadel/internal/repository/session"
|
||||
"github.com/zitadel/zitadel/internal/repository/user"
|
||||
)
|
||||
|
||||
const (
|
||||
notificationID = "notificationID"
|
||||
)
|
||||
|
||||
func Test_userNotifier_reduceNotificationRequested(t *testing.T) {
|
||||
testNow := time.Now
|
||||
testBackOff := func(current time.Duration) time.Duration {
|
||||
return time.Second
|
||||
}
|
||||
sendError := errors.New("send error")
|
||||
tests := []struct {
|
||||
name string
|
||||
test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fieldsWorker, argsWorker, wantWorker)
|
||||
}{
|
||||
{
|
||||
name: "too old",
|
||||
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
|
||||
codeAlg, code := cryptoValue(t, ctrl, "testcode")
|
||||
return fieldsWorker{
|
||||
queries: queries,
|
||||
commands: commands,
|
||||
es: eventstore.NewEventstore(&eventstore.Config{
|
||||
Querier: es_repo_mock.NewRepo(t).MockQuerier,
|
||||
}),
|
||||
userDataCrypto: codeAlg,
|
||||
now: testNow,
|
||||
},
|
||||
argsWorker{
|
||||
job: &river.Job[*notification.Request]{
|
||||
JobRow: &rivertype.JobRow{
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
},
|
||||
Args: ¬ification.Request{
|
||||
Aggregate: &eventstore.Aggregate{
|
||||
InstanceID: instanceID,
|
||||
ID: notificationID,
|
||||
ResourceOwner: instanceID,
|
||||
},
|
||||
UserID: userID,
|
||||
UserResourceOwner: orgID,
|
||||
TriggeredAtOrigin: eventOrigin,
|
||||
EventType: user.HumanInviteCodeAddedType,
|
||||
MessageType: domain.InviteUserMessageType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
|
||||
CodeExpiry: 1 * time.Hour,
|
||||
Code: code,
|
||||
UnverifiedNotificationChannel: true,
|
||||
IsOTP: false,
|
||||
RequiresPreviousDomain: false,
|
||||
Args: &domain.NotificationArguments{
|
||||
ApplicationName: "APP",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantWorker{
|
||||
err: func(tt assert.TestingT, err error, i ...interface{}) bool {
|
||||
return errors.Is(err, new(river.JobCancelError))
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send ok (email)",
|
||||
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
|
||||
givenTemplate := "{{.LogoURL}}"
|
||||
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
|
||||
w.message = &messages.Email{
|
||||
Recipients: []string{lastEmail},
|
||||
Subject: "Invitation to APP",
|
||||
Content: expectContent,
|
||||
TriggeringEventType: user.HumanInviteCodeAddedType,
|
||||
}
|
||||
codeAlg, code := cryptoValue(t, ctrl, "testcode")
|
||||
expectTemplateWithNotifyUserQueries(queries, givenTemplate)
|
||||
commands.EXPECT().InviteCodeSent(gomock.Any(), orgID, userID).Return(nil)
|
||||
return fieldsWorker{
|
||||
queries: queries,
|
||||
commands: commands,
|
||||
es: eventstore.NewEventstore(&eventstore.Config{
|
||||
Querier: es_repo_mock.NewRepo(t).MockQuerier,
|
||||
}),
|
||||
userDataCrypto: codeAlg,
|
||||
now: testNow,
|
||||
},
|
||||
argsWorker{
|
||||
job: &river.Job[*notification.Request]{
|
||||
JobRow: &rivertype.JobRow{
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
Args: ¬ification.Request{
|
||||
Aggregate: &eventstore.Aggregate{
|
||||
InstanceID: instanceID,
|
||||
ID: userID,
|
||||
ResourceOwner: orgID,
|
||||
},
|
||||
UserID: userID,
|
||||
UserResourceOwner: orgID,
|
||||
TriggeredAtOrigin: eventOrigin,
|
||||
EventType: user.HumanInviteCodeAddedType,
|
||||
MessageType: domain.InviteUserMessageType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
|
||||
CodeExpiry: 1 * time.Hour,
|
||||
Code: code,
|
||||
UnverifiedNotificationChannel: true,
|
||||
IsOTP: false,
|
||||
RequiresPreviousDomain: false,
|
||||
Args: &domain.NotificationArguments{
|
||||
ApplicationName: "APP",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
w
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send ok (sms with external provider)",
|
||||
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
|
||||
expiry := 0 * time.Hour
|
||||
testCode := ""
|
||||
expectContent := fmt.Sprintf(`%[1]s is your one-time password for %[2]s. Use it within the next %[3]s.
|
||||
@%[2]s #%[1]s`, testCode, eventOriginDomain, expiry)
|
||||
w.messageSMS = &messages.SMS{
|
||||
SenderPhoneNumber: "senderNumber",
|
||||
RecipientPhoneNumber: verifiedPhone,
|
||||
Content: expectContent,
|
||||
TriggeringEventType: session.OTPSMSChallengedType,
|
||||
InstanceID: instanceID,
|
||||
JobID: "1",
|
||||
UserID: userID,
|
||||
}
|
||||
codeAlg, code := cryptoValue(t, ctrl, testCode)
|
||||
expectTemplateWithNotifyUserQueriesSMS(queries)
|
||||
commands.EXPECT().OTPSMSSent(gomock.Any(), sessionID, instanceID, &senders.CodeGeneratorInfo{
|
||||
ID: smsProviderID,
|
||||
VerificationID: verificationID,
|
||||
}).Return(nil)
|
||||
return fieldsWorker{
|
||||
queries: queries,
|
||||
commands: commands,
|
||||
es: eventstore.NewEventstore(&eventstore.Config{
|
||||
Querier: es_repo_mock.NewRepo(t).MockQuerier,
|
||||
}),
|
||||
userDataCrypto: codeAlg,
|
||||
now: testNow,
|
||||
},
|
||||
argsWorker{
|
||||
job: &river.Job[*notification.Request]{
|
||||
JobRow: &rivertype.JobRow{
|
||||
CreatedAt: time.Now(),
|
||||
ID: 1,
|
||||
},
|
||||
Args: ¬ification.Request{
|
||||
Aggregate: &eventstore.Aggregate{
|
||||
InstanceID: instanceID,
|
||||
ID: sessionID,
|
||||
ResourceOwner: instanceID,
|
||||
},
|
||||
UserID: userID,
|
||||
UserResourceOwner: orgID,
|
||||
TriggeredAtOrigin: eventOrigin,
|
||||
EventType: session.OTPSMSChallengedType,
|
||||
MessageType: domain.VerifySMSOTPMessageType,
|
||||
NotificationType: domain.NotificationTypeSms,
|
||||
URLTemplate: "",
|
||||
CodeExpiry: expiry,
|
||||
Code: code,
|
||||
UnverifiedNotificationChannel: false,
|
||||
IsOTP: true,
|
||||
RequiresPreviousDomain: false,
|
||||
Args: &domain.NotificationArguments{
|
||||
Origin: eventOrigin,
|
||||
Domain: eventOriginDomain,
|
||||
Expiry: expiry,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, w
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "previous domain",
|
||||
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
|
||||
givenTemplate := "{{.LogoURL}}"
|
||||
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
|
||||
w.message = &messages.Email{
|
||||
Recipients: []string{verifiedEmail},
|
||||
Subject: "Domain has been claimed",
|
||||
Content: expectContent,
|
||||
TriggeringEventType: user.UserDomainClaimedType,
|
||||
}
|
||||
expectTemplateWithNotifyUserQueries(queries, givenTemplate)
|
||||
commands.EXPECT().UserDomainClaimedSent(gomock.Any(), orgID, userID).Return(nil)
|
||||
return fieldsWorker{
|
||||
queries: queries,
|
||||
commands: commands,
|
||||
es: eventstore.NewEventstore(&eventstore.Config{
|
||||
Querier: es_repo_mock.NewRepo(t).MockQuerier,
|
||||
}),
|
||||
userDataCrypto: nil,
|
||||
now: testNow,
|
||||
},
|
||||
argsWorker{
|
||||
job: &river.Job[*notification.Request]{
|
||||
JobRow: &rivertype.JobRow{
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
Args: ¬ification.Request{
|
||||
Aggregate: &eventstore.Aggregate{
|
||||
InstanceID: instanceID,
|
||||
ID: userID,
|
||||
ResourceOwner: orgID,
|
||||
},
|
||||
UserID: userID,
|
||||
UserResourceOwner: orgID,
|
||||
TriggeredAtOrigin: eventOrigin,
|
||||
EventType: user.UserDomainClaimedType,
|
||||
MessageType: domain.DomainClaimedMessageType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
URLTemplate: login.LoginLink(eventOrigin, orgID),
|
||||
CodeExpiry: 0,
|
||||
Code: nil,
|
||||
UnverifiedNotificationChannel: false,
|
||||
IsOTP: false,
|
||||
RequiresPreviousDomain: true,
|
||||
Args: &domain.NotificationArguments{
|
||||
TempUsername: "tempUsername",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, w
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send failed, retry",
|
||||
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
|
||||
givenTemplate := "{{.LogoURL}}"
|
||||
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
|
||||
w.message = &messages.Email{
|
||||
Recipients: []string{lastEmail},
|
||||
Subject: "Invitation to APP",
|
||||
Content: expectContent,
|
||||
TriggeringEventType: user.HumanInviteCodeAddedType,
|
||||
}
|
||||
w.sendError = sendError
|
||||
w.err = func(tt assert.TestingT, err error, i ...interface{}) bool {
|
||||
return errors.Is(err, sendError)
|
||||
}
|
||||
codeAlg, code := cryptoValue(t, ctrl, "testcode")
|
||||
expectTemplateWithNotifyUserQueries(queries, givenTemplate)
|
||||
return fieldsWorker{
|
||||
queries: queries,
|
||||
commands: commands,
|
||||
es: eventstore.NewEventstore(&eventstore.Config{
|
||||
Querier: es_repo_mock.NewRepo(t).MockQuerier,
|
||||
}),
|
||||
userDataCrypto: codeAlg,
|
||||
now: testNow,
|
||||
backOff: testBackOff,
|
||||
},
|
||||
argsWorker{
|
||||
job: &river.Job[*notification.Request]{
|
||||
JobRow: &rivertype.JobRow{
|
||||
ID: 1,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
Args: ¬ification.Request{
|
||||
Aggregate: &eventstore.Aggregate{
|
||||
InstanceID: instanceID,
|
||||
ID: notificationID,
|
||||
ResourceOwner: instanceID,
|
||||
},
|
||||
UserID: userID,
|
||||
UserResourceOwner: orgID,
|
||||
TriggeredAtOrigin: eventOrigin,
|
||||
EventType: user.HumanInviteCodeAddedType,
|
||||
MessageType: domain.InviteUserMessageType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
|
||||
CodeExpiry: 1 * time.Hour,
|
||||
Code: code,
|
||||
UnverifiedNotificationChannel: true,
|
||||
IsOTP: false,
|
||||
RequiresPreviousDomain: false,
|
||||
Args: &domain.NotificationArguments{
|
||||
ApplicationName: "APP",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
w
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send failed (max attempts), cancel",
|
||||
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
|
||||
givenTemplate := "{{.LogoURL}}"
|
||||
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
|
||||
w.message = &messages.Email{
|
||||
Recipients: []string{lastEmail},
|
||||
Subject: "Invitation to APP",
|
||||
Content: expectContent,
|
||||
TriggeringEventType: user.HumanInviteCodeAddedType,
|
||||
}
|
||||
w.sendError = sendError
|
||||
w.err = func(tt assert.TestingT, err error, i ...interface{}) bool {
|
||||
return err != nil
|
||||
}
|
||||
|
||||
codeAlg, code := cryptoValue(t, ctrl, "testcode")
|
||||
expectTemplateWithNotifyUserQueries(queries, givenTemplate)
|
||||
return fieldsWorker{
|
||||
queries: queries,
|
||||
commands: commands,
|
||||
es: eventstore.NewEventstore(&eventstore.Config{
|
||||
Querier: es_repo_mock.NewRepo(t).MockQuerier,
|
||||
}),
|
||||
userDataCrypto: codeAlg,
|
||||
now: testNow,
|
||||
backOff: testBackOff,
|
||||
},
|
||||
argsWorker{
|
||||
job: &river.Job[*notification.Request]{
|
||||
JobRow: &rivertype.JobRow{
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
Args: ¬ification.Request{
|
||||
Aggregate: &eventstore.Aggregate{
|
||||
InstanceID: instanceID,
|
||||
ID: userID,
|
||||
ResourceOwner: orgID,
|
||||
},
|
||||
UserID: userID,
|
||||
UserResourceOwner: orgID,
|
||||
TriggeredAtOrigin: eventOrigin,
|
||||
EventType: user.HumanInviteCodeAddedType,
|
||||
MessageType: domain.InviteUserMessageType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
|
||||
CodeExpiry: 1 * time.Hour,
|
||||
Code: code,
|
||||
UnverifiedNotificationChannel: true,
|
||||
IsOTP: false,
|
||||
RequiresPreviousDomain: false,
|
||||
Args: &domain.NotificationArguments{
|
||||
ApplicationName: "APP",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
w
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
queries := mock.NewMockQueries(ctrl)
|
||||
commands := mock.NewMockCommands(ctrl)
|
||||
f, a, w := tt.test(ctrl, queries, commands)
|
||||
err := newNotificationWorker(t, ctrl, queries, f, w).Work(
|
||||
authz.WithInstanceID(context.Background(), instanceID),
|
||||
a.job,
|
||||
)
|
||||
if w.err != nil {
|
||||
w.err(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newNotificationWorker(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQueries, f fieldsWorker, w wantWorker) *NotificationWorker {
|
||||
queries.EXPECT().NotificationProviderByIDAndType(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(&query.DebugNotificationProvider{}, nil)
|
||||
smtpAlg, _ := cryptoValue(t, ctrl, "smtppw")
|
||||
channel := channel_mock.NewMockNotificationChannel(ctrl)
|
||||
if w.message != nil {
|
||||
channel.EXPECT().HandleMessage(w.message).Return(w.sendError)
|
||||
}
|
||||
if w.messageSMS != nil {
|
||||
channel.EXPECT().HandleMessage(w.messageSMS).DoAndReturn(func(message *messages.SMS) error {
|
||||
message.VerificationID = gu.Ptr(verificationID)
|
||||
return w.sendError
|
||||
})
|
||||
}
|
||||
return &NotificationWorker{
|
||||
commands: f.commands,
|
||||
queries: NewNotificationQueries(
|
||||
f.queries,
|
||||
f.es,
|
||||
externalDomain,
|
||||
externalPort,
|
||||
externalSecure,
|
||||
"",
|
||||
f.userDataCrypto,
|
||||
smtpAlg,
|
||||
f.SMSTokenCrypto,
|
||||
),
|
||||
channels: ¬ificationChannels{
|
||||
Chain: *senders.ChainChannels(channel),
|
||||
EmailConfig: &email.Config{
|
||||
ProviderConfig: &email.Provider{
|
||||
ID: "emailProviderID",
|
||||
Description: "description",
|
||||
},
|
||||
SMTPConfig: &smtp.Config{
|
||||
SMTP: smtp.SMTP{
|
||||
Host: "host",
|
||||
User: "user",
|
||||
Password: "password",
|
||||
},
|
||||
Tls: true,
|
||||
From: "from",
|
||||
FromName: "fromName",
|
||||
ReplyToAddress: "replyToAddress",
|
||||
},
|
||||
WebhookConfig: nil,
|
||||
},
|
||||
SMSConfig: &sms.Config{
|
||||
ProviderConfig: &sms.Provider{
|
||||
ID: "smsProviderID",
|
||||
Description: "description",
|
||||
},
|
||||
TwilioConfig: &twilio.Config{
|
||||
SID: "sid",
|
||||
Token: "token",
|
||||
SenderNumber: "senderNumber",
|
||||
VerifyServiceSID: "verifyServiceSID",
|
||||
},
|
||||
},
|
||||
},
|
||||
config: WorkerConfig{
|
||||
Workers: 1,
|
||||
TransactionDuration: 5 * time.Second,
|
||||
MaxTtl: 5 * time.Minute,
|
||||
},
|
||||
now: f.now,
|
||||
}
|
||||
}
|
61
apps/api/internal/notification/handlers/origin.go
Normal file
61
apps/api/internal/notification/handlers/origin.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
type OriginEvent interface {
|
||||
eventstore.Event
|
||||
TriggerOrigin() string
|
||||
}
|
||||
|
||||
func (n *NotificationQueries) Origin(ctx context.Context, e eventstore.Event) (context.Context, error) {
|
||||
var origin string
|
||||
originEvent, ok := e.(OriginEvent)
|
||||
if !ok {
|
||||
logging.Errorf("event of type %T doesn't implement OriginEvent", e)
|
||||
} else {
|
||||
origin = originEvent.TriggerOrigin()
|
||||
}
|
||||
if origin != "" {
|
||||
return enrichCtx(ctx, origin)
|
||||
}
|
||||
primary, err := query.NewInstanceDomainPrimarySearchQuery(true)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
domains, err := n.SearchInstanceDomains(ctx, &query.InstanceDomainSearchQueries{
|
||||
Queries: []query.SearchQuery{primary},
|
||||
})
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
if len(domains.Domains) < 1 {
|
||||
return ctx, zerrors.ThrowInternal(nil, "NOTIF-Ef3r1", "Errors.Notification.NoDomain")
|
||||
}
|
||||
return enrichCtx(
|
||||
ctx,
|
||||
http_utils.BuildHTTP(domains.Domains[0].Domain, n.externalPort, n.externalSecure),
|
||||
)
|
||||
}
|
||||
|
||||
func enrichCtx(ctx context.Context, origin string) (context.Context, error) {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx = http_utils.WithDomainContext(ctx, &http_utils.DomainCtx{
|
||||
InstanceHost: u.Host,
|
||||
PublicHost: u.Host,
|
||||
Protocol: u.Scheme,
|
||||
})
|
||||
return ctx, nil
|
||||
}
|
70
apps/api/internal/notification/handlers/queries.go
Normal file
70
apps/api/internal/notification/handlers/queries.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
type Queries interface {
|
||||
ActiveLabelPolicyByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.LabelPolicy, error)
|
||||
MailTemplateByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.MailTemplate, error)
|
||||
GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string) (*query.NotifyUser, error)
|
||||
CustomTextListByTemplate(ctx context.Context, aggregateID, template string, withOwnerRemoved bool) (*query.CustomTexts, error)
|
||||
SearchInstanceDomains(ctx context.Context, queries *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error)
|
||||
SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string, check domain.PermissionCheck) (*query.Session, error)
|
||||
NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error)
|
||||
SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error)
|
||||
NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error)
|
||||
SMSProviderConfigActive(ctx context.Context, resourceOwner string) (config *query.SMSConfig, err error)
|
||||
SMTPConfigActive(ctx context.Context, resourceOwner string) (*query.SMTPConfig, error)
|
||||
GetDefaultLanguage(ctx context.Context) language.Tag
|
||||
GetInstanceRestrictions(ctx context.Context) (restrictions query.Restrictions, err error)
|
||||
InstanceByID(ctx context.Context, id string) (instance authz.Instance, err error)
|
||||
GetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error)
|
||||
|
||||
ActiveInstances() []string
|
||||
}
|
||||
|
||||
type NotificationQueries struct {
|
||||
Queries
|
||||
es *eventstore.Eventstore
|
||||
externalDomain string
|
||||
externalPort uint16
|
||||
externalSecure bool
|
||||
fileSystemPath string
|
||||
UserDataCrypto crypto.EncryptionAlgorithm
|
||||
SMTPPasswordCrypto crypto.EncryptionAlgorithm
|
||||
SMSTokenCrypto crypto.EncryptionAlgorithm
|
||||
}
|
||||
|
||||
func NewNotificationQueries(
|
||||
baseQueries Queries,
|
||||
es *eventstore.Eventstore,
|
||||
externalDomain string,
|
||||
externalPort uint16,
|
||||
externalSecure bool,
|
||||
fileSystemPath string,
|
||||
userDataCrypto crypto.EncryptionAlgorithm,
|
||||
smtpPasswordCrypto crypto.EncryptionAlgorithm,
|
||||
smsTokenCrypto crypto.EncryptionAlgorithm,
|
||||
) *NotificationQueries {
|
||||
return &NotificationQueries{
|
||||
Queries: baseQueries,
|
||||
es: es,
|
||||
externalDomain: externalDomain,
|
||||
externalPort: externalPort,
|
||||
externalSecure: externalSecure,
|
||||
fileSystemPath: fileSystemPath,
|
||||
UserDataCrypto: userDataCrypto,
|
||||
SMTPPasswordCrypto: smtpPasswordCrypto,
|
||||
SMSTokenCrypto: smsTokenCrypto,
|
||||
}
|
||||
}
|
13
apps/api/internal/notification/handlers/queue.go
Normal file
13
apps/api/internal/notification/handlers/queue.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/riverqueue/river"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/queue"
|
||||
)
|
||||
|
||||
type Queue interface {
|
||||
Insert(ctx context.Context, args river.JobArgs, opts ...queue.InsertOpt) error
|
||||
}
|
80
apps/api/internal/notification/handlers/quota_notifier.go
Normal file
80
apps/api/internal/notification/handlers/quota_notifier.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
|
||||
_ "github.com/zitadel/zitadel/internal/notification/statik"
|
||||
"github.com/zitadel/zitadel/internal/notification/types"
|
||||
"github.com/zitadel/zitadel/internal/repository/quota"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
QuotaNotificationsProjectionTable = "projections.notifications_quota"
|
||||
)
|
||||
|
||||
type quotaNotifier struct {
|
||||
commands *command.Commands
|
||||
queries *NotificationQueries
|
||||
channels types.ChannelChains
|
||||
}
|
||||
|
||||
func NewQuotaNotifier(
|
||||
ctx context.Context,
|
||||
config handler.Config,
|
||||
commands *command.Commands,
|
||||
queries *NotificationQueries,
|
||||
channels types.ChannelChains,
|
||||
) *handler.Handler {
|
||||
return handler.NewHandler(ctx, &config, "aNotifier{
|
||||
commands: commands,
|
||||
queries: queries,
|
||||
channels: channels,
|
||||
})
|
||||
}
|
||||
|
||||
func (*quotaNotifier) Name() string {
|
||||
return QuotaNotificationsProjectionTable
|
||||
}
|
||||
|
||||
func (u *quotaNotifier) Reducers() []handler.AggregateReducer {
|
||||
return []handler.AggregateReducer{
|
||||
{
|
||||
Aggregate: quota.AggregateType,
|
||||
EventReducers: []handler.EventReducer{
|
||||
{
|
||||
Event: quota.NotificationDueEventType,
|
||||
Reduce: u.reduceNotificationDue,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (u *quotaNotifier) reduceNotificationDue(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*quota.NotificationDueEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-DLxdE", "reduce.wrong.event.type %s", quota.NotificationDueEventType)
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.queries.IsAlreadyHandled(ctx, event, map[string]interface{}{"dueEventID": e.ID}, quota.NotifiedEventType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
err = types.SendJSON(ctx, webhook.Config{CallURL: e.CallURL, Method: http.MethodPost}, u.channels, e, e.Type()).WithoutTemplate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.commands.UsageNotificationSent(ctx, e)
|
||||
}), nil
|
||||
}
|
111
apps/api/internal/notification/handlers/telemetry_pusher.go
Normal file
111
apps/api/internal/notification/handlers/telemetry_pusher.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
|
||||
_ "github.com/zitadel/zitadel/internal/notification/statik"
|
||||
"github.com/zitadel/zitadel/internal/notification/types"
|
||||
"github.com/zitadel/zitadel/internal/repository/milestone"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
TelemetryProjectionTable = "projections.telemetry"
|
||||
)
|
||||
|
||||
type TelemetryPusherConfig struct {
|
||||
Enabled bool
|
||||
Endpoints []string
|
||||
Headers http.Header
|
||||
}
|
||||
|
||||
type telemetryPusher struct {
|
||||
cfg TelemetryPusherConfig
|
||||
commands *command.Commands
|
||||
queries *NotificationQueries
|
||||
channels types.ChannelChains
|
||||
}
|
||||
|
||||
func NewTelemetryPusher(
|
||||
ctx context.Context,
|
||||
telemetryCfg TelemetryPusherConfig,
|
||||
handlerCfg handler.Config,
|
||||
commands *command.Commands,
|
||||
queries *NotificationQueries,
|
||||
channels types.ChannelChains,
|
||||
) *handler.Handler {
|
||||
pusher := &telemetryPusher{
|
||||
cfg: telemetryCfg,
|
||||
commands: commands,
|
||||
queries: queries,
|
||||
channels: channels,
|
||||
}
|
||||
return handler.NewHandler(
|
||||
ctx,
|
||||
&handlerCfg,
|
||||
pusher,
|
||||
)
|
||||
}
|
||||
|
||||
func (u *telemetryPusher) Name() string {
|
||||
return TelemetryProjectionTable
|
||||
}
|
||||
|
||||
func (t *telemetryPusher) Reducers() []handler.AggregateReducer {
|
||||
return []handler.AggregateReducer{{
|
||||
Aggregate: milestone.AggregateType,
|
||||
EventReducers: []handler.EventReducer{{
|
||||
Event: milestone.ReachedEventType,
|
||||
Reduce: t.pushMilestones,
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
func (t *telemetryPusher) pushMilestones(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*milestone.ReachedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-lDTs5", "reduce.wrong.event.type %s", event.Type())
|
||||
}
|
||||
return handler.NewStatement(event, func(ctx context.Context, _ handler.Executer, _ string) error {
|
||||
// Do not push the milestone again if this was a migration event.
|
||||
if e.ReachedDate != nil {
|
||||
return nil
|
||||
}
|
||||
return t.pushMilestone(ctx, e)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (t *telemetryPusher) pushMilestone(ctx context.Context, e *milestone.ReachedEvent) error {
|
||||
for _, endpoint := range t.cfg.Endpoints {
|
||||
if err := types.SendJSON(
|
||||
ctx,
|
||||
webhook.Config{
|
||||
CallURL: endpoint,
|
||||
Method: http.MethodPost,
|
||||
Headers: t.cfg.Headers,
|
||||
},
|
||||
t.channels,
|
||||
&struct {
|
||||
InstanceID string `json:"instanceId"`
|
||||
ExternalDomain string `json:"externalDomain"`
|
||||
Type milestone.Type `json:"type"`
|
||||
ReachedDate time.Time `json:"reached"`
|
||||
}{
|
||||
InstanceID: e.Agg.InstanceID,
|
||||
ExternalDomain: t.queries.externalDomain,
|
||||
Type: e.MilestoneType,
|
||||
ReachedDate: e.GetReachedDate(),
|
||||
},
|
||||
e.EventType,
|
||||
).WithoutTemplate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return t.commands.MilestonePushed(ctx, e.Agg.InstanceID, e.MilestoneType, t.cfg.Endpoints)
|
||||
}
|
43
apps/api/internal/notification/handlers/translator.go
Normal file
43
apps/api/internal/notification/handlers/translator.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/i18n"
|
||||
)
|
||||
|
||||
func (n *NotificationQueries) GetTranslatorWithOrgTexts(ctx context.Context, orgID, textType string) (*i18n.Translator, error) {
|
||||
restrictions, err := n.Queries.GetInstanceRestrictions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
translator, err := i18n.NewNotificationTranslator(n.GetDefaultLanguage(ctx), restrictions.AllowedLanguages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allCustomTexts, err := n.CustomTextListByTemplate(ctx, authz.GetInstance(ctx).InstanceID(), textType, false)
|
||||
if err != nil {
|
||||
return translator, nil
|
||||
}
|
||||
customTexts, err := n.CustomTextListByTemplate(ctx, orgID, textType, false)
|
||||
if err != nil {
|
||||
return translator, nil
|
||||
}
|
||||
allCustomTexts.CustomTexts = append(allCustomTexts.CustomTexts, customTexts.CustomTexts...)
|
||||
|
||||
for _, text := range allCustomTexts.CustomTexts {
|
||||
msg := i18n.Message{
|
||||
ID: text.Template + "." + text.Key,
|
||||
Text: text.Text,
|
||||
}
|
||||
err = translator.AddMessages(text.Language, msg)
|
||||
logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "orgID", orgID, "messageType", textType, "messageID", msg.ID).
|
||||
OnError(err).
|
||||
Warn("could not add translation message")
|
||||
}
|
||||
return translator, nil
|
||||
}
|
828
apps/api/internal/notification/handlers/user_notifier.go
Normal file
828
apps/api/internal/notification/handlers/user_notifier.go
Normal file
@@ -0,0 +1,828 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/console"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
|
||||
"github.com/zitadel/zitadel/internal/notification/senders"
|
||||
"github.com/zitadel/zitadel/internal/notification/types"
|
||||
"github.com/zitadel/zitadel/internal/queue"
|
||||
"github.com/zitadel/zitadel/internal/repository/notification"
|
||||
"github.com/zitadel/zitadel/internal/repository/session"
|
||||
"github.com/zitadel/zitadel/internal/repository/user"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterSentHandler(user.HumanInitialCodeAddedType,
|
||||
func(ctx context.Context, commands Commands, id, orgID string, _ *senders.CodeGeneratorInfo, args map[string]any) error {
|
||||
return commands.HumanInitCodeSent(ctx, orgID, id)
|
||||
},
|
||||
)
|
||||
RegisterSentHandler(user.HumanEmailCodeAddedType,
|
||||
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
|
||||
return commands.HumanEmailVerificationCodeSent(ctx, orgID, id)
|
||||
},
|
||||
)
|
||||
RegisterSentHandler(user.HumanPasswordCodeAddedType,
|
||||
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
|
||||
return commands.PasswordCodeSent(ctx, orgID, id, generatorInfo)
|
||||
},
|
||||
)
|
||||
RegisterSentHandler(user.HumanOTPSMSCodeAddedType,
|
||||
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
|
||||
return commands.HumanOTPSMSCodeSent(ctx, id, orgID, generatorInfo)
|
||||
},
|
||||
)
|
||||
RegisterSentHandler(session.OTPSMSChallengedType,
|
||||
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
|
||||
return commands.OTPSMSSent(ctx, id, orgID, generatorInfo)
|
||||
},
|
||||
)
|
||||
RegisterSentHandler(user.HumanOTPEmailCodeAddedType,
|
||||
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
|
||||
return commands.HumanOTPEmailCodeSent(ctx, id, orgID)
|
||||
},
|
||||
)
|
||||
RegisterSentHandler(session.OTPEmailChallengedType,
|
||||
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
|
||||
return commands.OTPEmailSent(ctx, id, orgID)
|
||||
},
|
||||
)
|
||||
RegisterSentHandler(user.UserDomainClaimedType,
|
||||
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
|
||||
return commands.UserDomainClaimedSent(ctx, orgID, id)
|
||||
},
|
||||
)
|
||||
RegisterSentHandler(user.HumanPasswordlessInitCodeRequestedType,
|
||||
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
|
||||
return commands.HumanPasswordlessInitCodeSent(ctx, id, orgID, args["CodeID"].(string))
|
||||
},
|
||||
)
|
||||
RegisterSentHandler(user.HumanPasswordChangedType,
|
||||
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
|
||||
return commands.PasswordChangeSent(ctx, orgID, id)
|
||||
},
|
||||
)
|
||||
RegisterSentHandler(user.HumanPhoneCodeAddedType,
|
||||
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
|
||||
return commands.HumanPhoneVerificationCodeSent(ctx, orgID, id, generatorInfo)
|
||||
},
|
||||
)
|
||||
RegisterSentHandler(user.HumanInviteCodeAddedType,
|
||||
func(ctx context.Context, commands Commands, id, orgID string, _ *senders.CodeGeneratorInfo, args map[string]any) error {
|
||||
return commands.InviteCodeSent(ctx, orgID, id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const (
|
||||
UserNotificationsProjectionTable = "projections.notifications"
|
||||
)
|
||||
|
||||
type userNotifier struct {
|
||||
queries *NotificationQueries
|
||||
otpEmailTmpl string
|
||||
|
||||
queue Queue
|
||||
maxAttempts uint8
|
||||
}
|
||||
|
||||
func NewUserNotifier(
|
||||
ctx context.Context,
|
||||
config handler.Config,
|
||||
commands Commands,
|
||||
queries *NotificationQueries,
|
||||
channels types.ChannelChains,
|
||||
otpEmailTmpl string,
|
||||
workerConfig WorkerConfig,
|
||||
queue Queue,
|
||||
) *handler.Handler {
|
||||
if workerConfig.LegacyEnabled {
|
||||
return NewUserNotifierLegacy(ctx, config, commands, queries, channels, otpEmailTmpl)
|
||||
}
|
||||
return handler.NewHandler(ctx, &config, &userNotifier{
|
||||
queries: queries,
|
||||
otpEmailTmpl: otpEmailTmpl,
|
||||
queue: queue,
|
||||
maxAttempts: workerConfig.MaxAttempts,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *userNotifier) Name() string {
|
||||
return UserNotificationsProjectionTable
|
||||
}
|
||||
|
||||
func (u *userNotifier) Reducers() []handler.AggregateReducer {
|
||||
return []handler.AggregateReducer{
|
||||
{
|
||||
Aggregate: user.AggregateType,
|
||||
EventReducers: []handler.EventReducer{
|
||||
{
|
||||
Event: user.UserV1InitialCodeAddedType,
|
||||
Reduce: u.reduceInitCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanInitialCodeAddedType,
|
||||
Reduce: u.reduceInitCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.UserV1EmailCodeAddedType,
|
||||
Reduce: u.reduceEmailCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanEmailCodeAddedType,
|
||||
Reduce: u.reduceEmailCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.UserV1PasswordCodeAddedType,
|
||||
Reduce: u.reducePasswordCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanPasswordCodeAddedType,
|
||||
Reduce: u.reducePasswordCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.UserDomainClaimedType,
|
||||
Reduce: u.reduceDomainClaimed,
|
||||
},
|
||||
{
|
||||
Event: user.HumanPasswordlessInitCodeRequestedType,
|
||||
Reduce: u.reducePasswordlessCodeRequested,
|
||||
},
|
||||
{
|
||||
Event: user.UserV1PhoneCodeAddedType,
|
||||
Reduce: u.reducePhoneCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanPhoneCodeAddedType,
|
||||
Reduce: u.reducePhoneCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanPasswordChangedType,
|
||||
Reduce: u.reducePasswordChanged,
|
||||
},
|
||||
{
|
||||
Event: user.HumanOTPSMSCodeAddedType,
|
||||
Reduce: u.reduceOTPSMSCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanOTPEmailCodeAddedType,
|
||||
Reduce: u.reduceOTPEmailCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanInviteCodeAddedType,
|
||||
Reduce: u.reduceInviteCodeAdded,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Aggregate: session.AggregateType,
|
||||
EventReducers: []handler.EventReducer{
|
||||
{
|
||||
Event: session.OTPSMSChallengedType,
|
||||
Reduce: u.reduceSessionOTPSMSChallenged,
|
||||
},
|
||||
{
|
||||
Event: session.OTPEmailChallengedType,
|
||||
Reduce: u.reduceSessionOTPEmailChallenged,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (u *userNotifier) reduceInitCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanInitialCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-EFe2f", "reduce.wrong.event.type %s", user.HumanInitialCodeAddedType)
|
||||
}
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
user.UserV1InitialCodeAddedType, user.UserV1InitialCodeSentType,
|
||||
user.HumanInitialCodeAddedType, user.HumanInitialCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
origin := http_util.DomainContext(ctx).Origin()
|
||||
return u.queue.Insert(ctx,
|
||||
¬ification.Request{
|
||||
Aggregate: e.Aggregate(),
|
||||
UserID: e.Aggregate().ID,
|
||||
UserResourceOwner: e.Aggregate().ResourceOwner,
|
||||
TriggeredAtOrigin: origin,
|
||||
EventType: e.EventType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
MessageType: domain.InitCodeMessageType,
|
||||
Code: e.Code,
|
||||
CodeExpiry: e.Expiry,
|
||||
IsOTP: false,
|
||||
UnverifiedNotificationChannel: true,
|
||||
URLTemplate: login.InitUserLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID),
|
||||
Args: &domain.NotificationArguments{
|
||||
AuthRequestID: e.AuthRequestID,
|
||||
},
|
||||
},
|
||||
queue.WithQueueName(notification.QueueName),
|
||||
queue.WithMaxAttempts(u.maxAttempts),
|
||||
)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanEmailCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-SWf3g", "reduce.wrong.event.type %s", user.HumanEmailCodeAddedType)
|
||||
}
|
||||
|
||||
if e.CodeReturned {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
user.UserV1EmailCodeAddedType, user.UserV1EmailCodeSentType,
|
||||
user.HumanEmailCodeAddedType, user.HumanEmailCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
origin := http_util.DomainContext(ctx).Origin()
|
||||
return u.queue.Insert(ctx,
|
||||
¬ification.Request{
|
||||
Aggregate: e.Aggregate(),
|
||||
UserID: e.Aggregate().ID,
|
||||
UserResourceOwner: e.Aggregate().ResourceOwner,
|
||||
TriggeredAtOrigin: origin,
|
||||
EventType: e.EventType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
MessageType: domain.VerifyEmailMessageType,
|
||||
Code: e.Code,
|
||||
CodeExpiry: e.Expiry,
|
||||
IsOTP: false,
|
||||
UnverifiedNotificationChannel: true,
|
||||
URLTemplate: u.emailCodeTemplate(origin, e),
|
||||
Args: &domain.NotificationArguments{
|
||||
AuthRequestID: e.AuthRequestID,
|
||||
},
|
||||
},
|
||||
queue.WithQueueName(notification.QueueName),
|
||||
queue.WithMaxAttempts(u.maxAttempts),
|
||||
)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifier) emailCodeTemplate(origin string, e *user.HumanEmailCodeAddedEvent) string {
|
||||
if e.URLTemplate != "" {
|
||||
return e.URLTemplate
|
||||
}
|
||||
return login.MailVerificationLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID)
|
||||
}
|
||||
|
||||
func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanPasswordCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanPasswordCodeAddedType)
|
||||
}
|
||||
if e.CodeReturned {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
user.UserV1PasswordCodeAddedType, user.UserV1PasswordCodeSentType,
|
||||
user.HumanPasswordCodeAddedType, user.HumanPasswordCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
origin := http_util.DomainContext(ctx).Origin()
|
||||
return u.queue.Insert(ctx,
|
||||
¬ification.Request{
|
||||
Aggregate: e.Aggregate(),
|
||||
UserID: e.Aggregate().ID,
|
||||
UserResourceOwner: e.Aggregate().ResourceOwner,
|
||||
TriggeredAtOrigin: origin,
|
||||
EventType: e.EventType,
|
||||
NotificationType: e.NotificationType,
|
||||
MessageType: domain.PasswordResetMessageType,
|
||||
Code: e.Code,
|
||||
CodeExpiry: e.Expiry,
|
||||
IsOTP: false,
|
||||
UnverifiedNotificationChannel: true,
|
||||
URLTemplate: u.passwordCodeTemplate(origin, e),
|
||||
Args: &domain.NotificationArguments{
|
||||
AuthRequestID: e.AuthRequestID,
|
||||
},
|
||||
},
|
||||
queue.WithQueueName(notification.QueueName),
|
||||
queue.WithMaxAttempts(u.maxAttempts),
|
||||
)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifier) passwordCodeTemplate(origin string, e *user.HumanPasswordCodeAddedEvent) string {
|
||||
if e.URLTemplate != "" {
|
||||
return e.URLTemplate
|
||||
}
|
||||
return login.InitPasswordLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID)
|
||||
}
|
||||
|
||||
func (u *userNotifier) reduceOTPSMSCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanOTPSMSCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ASF3g", "reduce.wrong.event.type %s", user.HumanOTPSMSCodeAddedType)
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
user.HumanOTPSMSCodeAddedType,
|
||||
user.HumanOTPSMSCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.queue.Insert(ctx,
|
||||
¬ification.Request{
|
||||
Aggregate: e.Aggregate(),
|
||||
UserID: e.Aggregate().ID,
|
||||
UserResourceOwner: e.Aggregate().ResourceOwner,
|
||||
TriggeredAtOrigin: http_util.DomainContext(ctx).Origin(),
|
||||
EventType: e.EventType,
|
||||
NotificationType: domain.NotificationTypeSms,
|
||||
MessageType: domain.VerifySMSOTPMessageType,
|
||||
Code: e.Code,
|
||||
CodeExpiry: e.Expiry,
|
||||
IsOTP: true,
|
||||
Args: otpArgs(ctx, e.Expiry),
|
||||
},
|
||||
queue.WithQueueName(notification.QueueName),
|
||||
queue.WithMaxAttempts(u.maxAttempts),
|
||||
)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifier) reduceSessionOTPSMSChallenged(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*session.OTPSMSChallengedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Sk32L", "reduce.wrong.event.type %s", session.OTPSMSChallengedType)
|
||||
}
|
||||
if e.CodeReturned {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
session.OTPSMSChallengedType,
|
||||
session.OTPSMSSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args := otpArgs(ctx, e.Expiry)
|
||||
args.SessionID = e.Aggregate().ID
|
||||
return u.queue.Insert(ctx,
|
||||
¬ification.Request{
|
||||
Aggregate: e.Aggregate(),
|
||||
UserID: s.UserFactor.UserID,
|
||||
UserResourceOwner: s.UserFactor.ResourceOwner,
|
||||
TriggeredAtOrigin: http_util.DomainContext(ctx).Origin(),
|
||||
EventType: e.EventType,
|
||||
NotificationType: domain.NotificationTypeSms,
|
||||
MessageType: domain.VerifySMSOTPMessageType,
|
||||
Code: e.Code,
|
||||
CodeExpiry: e.Expiry,
|
||||
IsOTP: true,
|
||||
Args: args,
|
||||
},
|
||||
queue.WithQueueName(notification.QueueName),
|
||||
queue.WithMaxAttempts(u.maxAttempts),
|
||||
)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanOTPEmailCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-JL3hw", "reduce.wrong.event.type %s", user.HumanOTPEmailCodeAddedType)
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
user.HumanOTPEmailCodeAddedType,
|
||||
user.HumanOTPEmailCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
origin := http_util.DomainContext(ctx).Origin()
|
||||
var authRequestID string
|
||||
if e.AuthRequestInfo != nil {
|
||||
authRequestID = e.AuthRequestInfo.ID
|
||||
}
|
||||
args := otpArgs(ctx, e.Expiry)
|
||||
args.AuthRequestID = authRequestID
|
||||
return u.queue.Insert(ctx,
|
||||
¬ification.Request{
|
||||
Aggregate: e.Aggregate(),
|
||||
UserID: e.Aggregate().ID,
|
||||
UserResourceOwner: e.Aggregate().ResourceOwner,
|
||||
TriggeredAtOrigin: origin,
|
||||
EventType: e.EventType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
MessageType: domain.VerifyEmailOTPMessageType,
|
||||
Code: e.Code,
|
||||
CodeExpiry: e.Expiry,
|
||||
IsOTP: true,
|
||||
URLTemplate: login.OTPLinkTemplate(origin, authRequestID, domain.MFATypeOTPEmail),
|
||||
Args: args,
|
||||
},
|
||||
queue.WithQueueName(notification.QueueName),
|
||||
queue.WithMaxAttempts(u.maxAttempts),
|
||||
)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifier) reduceSessionOTPEmailChallenged(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*session.OTPEmailChallengedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-zbsgt", "reduce.wrong.event.type %s", session.OTPEmailChallengedType)
|
||||
}
|
||||
if e.ReturnCode {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
session.OTPEmailChallengedType,
|
||||
session.OTPEmailSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
origin := http_util.DomainContext(ctx).Origin()
|
||||
|
||||
args := otpArgs(ctx, e.Expiry)
|
||||
args.SessionID = e.Aggregate().ID
|
||||
return u.queue.Insert(ctx,
|
||||
¬ification.Request{
|
||||
Aggregate: e.Aggregate(),
|
||||
UserID: s.UserFactor.UserID,
|
||||
UserResourceOwner: s.UserFactor.ResourceOwner,
|
||||
TriggeredAtOrigin: origin,
|
||||
EventType: e.EventType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
MessageType: domain.VerifyEmailOTPMessageType,
|
||||
Code: e.Code,
|
||||
CodeExpiry: e.Expiry,
|
||||
IsOTP: true,
|
||||
URLTemplate: u.otpEmailTemplate(origin, e),
|
||||
Args: args,
|
||||
},
|
||||
queue.WithQueueName(notification.QueueName),
|
||||
queue.WithMaxAttempts(u.maxAttempts),
|
||||
)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifier) otpEmailTemplate(origin string, e *session.OTPEmailChallengedEvent) string {
|
||||
if e.URLTmpl != "" {
|
||||
return e.URLTmpl
|
||||
}
|
||||
return origin + u.otpEmailTmpl
|
||||
}
|
||||
|
||||
func otpArgs(ctx context.Context, expiry time.Duration) *domain.NotificationArguments {
|
||||
domainCtx := http_util.DomainContext(ctx)
|
||||
return &domain.NotificationArguments{
|
||||
Origin: domainCtx.Origin(),
|
||||
Domain: domainCtx.RequestedDomain(),
|
||||
Expiry: expiry,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *userNotifier) reduceDomainClaimed(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.DomainClaimedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Drh5w", "reduce.wrong.event.type %s", user.UserDomainClaimedType)
|
||||
}
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.queries.IsAlreadyHandled(ctx, event, nil,
|
||||
user.UserDomainClaimedType, user.UserDomainClaimedSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
origin := http_util.DomainContext(ctx).Origin()
|
||||
return u.queue.Insert(ctx,
|
||||
¬ification.Request{
|
||||
Aggregate: e.Aggregate(),
|
||||
UserID: e.Aggregate().ID,
|
||||
UserResourceOwner: e.Aggregate().ResourceOwner,
|
||||
TriggeredAtOrigin: origin,
|
||||
EventType: e.EventType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
MessageType: domain.DomainClaimedMessageType,
|
||||
URLTemplate: login.LoginLink(origin, e.Aggregate().ResourceOwner),
|
||||
UnverifiedNotificationChannel: true,
|
||||
Args: &domain.NotificationArguments{
|
||||
TempUsername: e.UserName,
|
||||
},
|
||||
RequiresPreviousDomain: true,
|
||||
},
|
||||
queue.WithQueueName(notification.QueueName),
|
||||
queue.WithMaxAttempts(u.maxAttempts),
|
||||
)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanPasswordlessInitCodeRequestedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-EDtjd", "reduce.wrong.event.type %s", user.HumanPasswordlessInitCodeAddedType)
|
||||
}
|
||||
if e.CodeReturned {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, map[string]interface{}{"id": e.ID}, user.HumanPasswordlessInitCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
origin := http_util.DomainContext(ctx).Origin()
|
||||
return u.queue.Insert(ctx,
|
||||
¬ification.Request{
|
||||
Aggregate: e.Aggregate(),
|
||||
UserID: e.Aggregate().ID,
|
||||
UserResourceOwner: e.Aggregate().ResourceOwner,
|
||||
TriggeredAtOrigin: origin,
|
||||
EventType: e.EventType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
MessageType: domain.PasswordlessRegistrationMessageType,
|
||||
URLTemplate: u.passwordlessCodeTemplate(origin, e),
|
||||
Args: &domain.NotificationArguments{
|
||||
CodeID: e.ID,
|
||||
},
|
||||
CodeExpiry: e.Expiry,
|
||||
Code: e.Code,
|
||||
},
|
||||
queue.WithQueueName(notification.QueueName),
|
||||
queue.WithMaxAttempts(u.maxAttempts),
|
||||
)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifier) passwordlessCodeTemplate(origin string, e *user.HumanPasswordlessInitCodeRequestedEvent) string {
|
||||
if e.URLTemplate != "" {
|
||||
return e.URLTemplate
|
||||
}
|
||||
return domain.PasswordlessInitCodeLinkTemplate(origin+login.HandlerPrefix+login.EndpointPasswordlessRegistration, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.ID)
|
||||
}
|
||||
|
||||
func (u *userNotifier) reducePasswordChanged(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanPasswordChangedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Yko2z8", "reduce.wrong.event.type %s", user.HumanPasswordChangedType)
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.queries.IsAlreadyHandled(ctx, event, nil, user.HumanPasswordChangeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
|
||||
notificationPolicy, err := u.queries.NotificationPolicyByOrg(ctx, true, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil && !zerrors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if !notificationPolicy.PasswordChange {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
origin := http_util.DomainContext(ctx).Origin()
|
||||
|
||||
return u.queue.Insert(ctx,
|
||||
¬ification.Request{
|
||||
Aggregate: e.Aggregate(),
|
||||
UserID: e.Aggregate().ID,
|
||||
UserResourceOwner: e.Aggregate().ResourceOwner,
|
||||
TriggeredAtOrigin: origin,
|
||||
EventType: e.EventType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
MessageType: domain.PasswordChangeMessageType,
|
||||
URLTemplate: console.LoginHintLink(origin, "{{.PreferredLoginName}}"),
|
||||
UnverifiedNotificationChannel: true,
|
||||
},
|
||||
queue.WithQueueName(notification.QueueName),
|
||||
queue.WithMaxAttempts(u.maxAttempts),
|
||||
)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanPhoneCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-He83g", "reduce.wrong.event.type %s", user.HumanPhoneCodeAddedType)
|
||||
}
|
||||
if e.CodeReturned {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
user.UserV1PhoneCodeAddedType, user.UserV1PhoneCodeSentType,
|
||||
user.HumanPhoneCodeAddedType, user.HumanPhoneCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return u.queue.Insert(ctx,
|
||||
¬ification.Request{
|
||||
Aggregate: e.Aggregate(),
|
||||
UserID: e.Aggregate().ID,
|
||||
UserResourceOwner: e.Aggregate().ResourceOwner,
|
||||
TriggeredAtOrigin: http_util.DomainContext(ctx).Origin(),
|
||||
EventType: e.EventType,
|
||||
NotificationType: domain.NotificationTypeSms,
|
||||
MessageType: domain.VerifyPhoneMessageType,
|
||||
CodeExpiry: e.Expiry,
|
||||
Code: e.Code,
|
||||
UnverifiedNotificationChannel: true,
|
||||
Args: &domain.NotificationArguments{
|
||||
Domain: http_util.DomainContext(ctx).RequestedDomain(),
|
||||
},
|
||||
},
|
||||
queue.WithQueueName(notification.QueueName),
|
||||
queue.WithMaxAttempts(u.maxAttempts),
|
||||
)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifier) reduceInviteCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanInviteCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanInviteCodeAddedType)
|
||||
}
|
||||
if e.CodeReturned {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
user.HumanInviteCodeAddedType, user.HumanInviteCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
origin := http_util.DomainContext(ctx).Origin()
|
||||
|
||||
applicationName := e.ApplicationName
|
||||
if applicationName == "" {
|
||||
applicationName = "ZITADEL"
|
||||
}
|
||||
return u.queue.Insert(ctx,
|
||||
¬ification.Request{
|
||||
Aggregate: e.Aggregate(),
|
||||
UserID: e.Aggregate().ID,
|
||||
UserResourceOwner: e.Aggregate().ResourceOwner,
|
||||
TriggeredAtOrigin: origin,
|
||||
EventType: e.EventType,
|
||||
NotificationType: domain.NotificationTypeEmail,
|
||||
MessageType: domain.InviteUserMessageType,
|
||||
CodeExpiry: e.Expiry,
|
||||
Code: e.Code,
|
||||
UnverifiedNotificationChannel: true,
|
||||
URLTemplate: u.inviteCodeTemplate(origin, e),
|
||||
Args: &domain.NotificationArguments{
|
||||
AuthRequestID: e.AuthRequestID,
|
||||
ApplicationName: applicationName,
|
||||
},
|
||||
},
|
||||
queue.WithQueueName(notification.QueueName),
|
||||
queue.WithMaxAttempts(u.maxAttempts),
|
||||
)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifier) inviteCodeTemplate(origin string, e *user.HumanInviteCodeAddedEvent) string {
|
||||
if e.URLTemplate != "" {
|
||||
return e.URLTemplate
|
||||
}
|
||||
return login.InviteUserLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID)
|
||||
}
|
||||
|
||||
func (u *userNotifier) checkIfCodeAlreadyHandledOrExpired(ctx context.Context, event eventstore.Event, expiry time.Duration, data map[string]interface{}, eventTypes ...eventstore.EventType) (bool, error) {
|
||||
if expiry > 0 && event.CreatedAt().Add(expiry).Before(time.Now().UTC()) {
|
||||
return true, nil
|
||||
}
|
||||
return u.queries.IsAlreadyHandled(ctx, event, data, eventTypes...)
|
||||
}
|
835
apps/api/internal/notification/handlers/user_notifier_legacy.go
Normal file
835
apps/api/internal/notification/handlers/user_notifier_legacy.go
Normal file
@@ -0,0 +1,835 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/notification/senders"
|
||||
"github.com/zitadel/zitadel/internal/notification/types"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/repository/session"
|
||||
"github.com/zitadel/zitadel/internal/repository/user"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
type userNotifierLegacy struct {
|
||||
commands Commands
|
||||
queries *NotificationQueries
|
||||
channels types.ChannelChains
|
||||
otpEmailTmpl string
|
||||
}
|
||||
|
||||
func NewUserNotifierLegacy(
|
||||
ctx context.Context,
|
||||
config handler.Config,
|
||||
commands Commands,
|
||||
queries *NotificationQueries,
|
||||
channels types.ChannelChains,
|
||||
otpEmailTmpl string,
|
||||
) *handler.Handler {
|
||||
return handler.NewHandler(ctx, &config, &userNotifierLegacy{
|
||||
commands: commands,
|
||||
queries: queries,
|
||||
otpEmailTmpl: otpEmailTmpl,
|
||||
channels: channels,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) Name() string {
|
||||
return UserNotificationsProjectionTable
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) Reducers() []handler.AggregateReducer {
|
||||
return []handler.AggregateReducer{
|
||||
{
|
||||
Aggregate: user.AggregateType,
|
||||
EventReducers: []handler.EventReducer{
|
||||
{
|
||||
Event: user.UserV1InitialCodeAddedType,
|
||||
Reduce: u.reduceInitCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanInitialCodeAddedType,
|
||||
Reduce: u.reduceInitCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.UserV1EmailCodeAddedType,
|
||||
Reduce: u.reduceEmailCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanEmailCodeAddedType,
|
||||
Reduce: u.reduceEmailCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.UserV1PasswordCodeAddedType,
|
||||
Reduce: u.reducePasswordCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanPasswordCodeAddedType,
|
||||
Reduce: u.reducePasswordCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.UserDomainClaimedType,
|
||||
Reduce: u.reduceDomainClaimed,
|
||||
},
|
||||
{
|
||||
Event: user.HumanPasswordlessInitCodeRequestedType,
|
||||
Reduce: u.reducePasswordlessCodeRequested,
|
||||
},
|
||||
{
|
||||
Event: user.UserV1PhoneCodeAddedType,
|
||||
Reduce: u.reducePhoneCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanPhoneCodeAddedType,
|
||||
Reduce: u.reducePhoneCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanPasswordChangedType,
|
||||
Reduce: u.reducePasswordChanged,
|
||||
},
|
||||
{
|
||||
Event: user.HumanOTPSMSCodeAddedType,
|
||||
Reduce: u.reduceOTPSMSCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanOTPEmailCodeAddedType,
|
||||
Reduce: u.reduceOTPEmailCodeAdded,
|
||||
},
|
||||
{
|
||||
Event: user.HumanInviteCodeAddedType,
|
||||
Reduce: u.reduceInviteCodeAdded,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Aggregate: session.AggregateType,
|
||||
EventReducers: []handler.EventReducer{
|
||||
{
|
||||
Event: session.OTPSMSChallengedType,
|
||||
Reduce: u.reduceSessionOTPSMSChallenged,
|
||||
},
|
||||
{
|
||||
Event: session.OTPEmailChallengedType,
|
||||
Reduce: u.reduceSessionOTPEmailChallenged,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reduceInitCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanInitialCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-EFe2f", "reduce.wrong.event.type %s", user.HumanInitialCodeAddedType)
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
user.UserV1InitialCodeAddedType, user.UserV1InitialCodeSentType,
|
||||
user.HumanInitialCodeAddedType, user.HumanInitialCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.InitCodeMessageType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e.Type()).
|
||||
SendUserInitCode(ctx, notifyUser, code, e.AuthRequestID)
|
||||
if err != nil {
|
||||
if errors.Is(err, &channels.CancelError{}) {
|
||||
// if the notification was canceled, we don't want to return the error, so there is no retry
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return u.commands.HumanInitCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reduceEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanEmailCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-SWf3g", "reduce.wrong.event.type %s", user.HumanEmailCodeAddedType)
|
||||
}
|
||||
|
||||
if e.CodeReturned {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
user.UserV1EmailCodeAddedType, user.UserV1EmailCodeSentType,
|
||||
user.HumanEmailCodeAddedType, user.HumanEmailCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifyEmailMessageType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type()).
|
||||
SendEmailVerificationCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID)
|
||||
if err != nil {
|
||||
if errors.Is(err, &channels.CancelError{}) {
|
||||
// if the notification was canceled, we don't want to return the error, so there is no retry
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return u.commands.HumanEmailVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reducePasswordCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanPasswordCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanPasswordCodeAddedType)
|
||||
}
|
||||
if e.CodeReturned {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
user.UserV1PasswordCodeAddedType, user.UserV1PasswordCodeSentType,
|
||||
user.HumanPasswordCodeAddedType, user.HumanPasswordCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
var code string
|
||||
if e.Code != nil {
|
||||
code, err = crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordResetMessageType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
generatorInfo := new(senders.CodeGeneratorInfo)
|
||||
notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type())
|
||||
if e.NotificationType == domain.NotificationTypeSms {
|
||||
notify = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e.Type(), e.Aggregate().InstanceID, e.ID, generatorInfo)
|
||||
}
|
||||
err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID)
|
||||
if err != nil {
|
||||
if errors.Is(err, &channels.CancelError{}) {
|
||||
// if the notification was canceled, we don't want to return the error, so there is no retry
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return u.commands.PasswordCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reduceOTPSMSCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanOTPSMSCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ASF3g", "reduce.wrong.event.type %s", user.HumanOTPSMSCodeAddedType)
|
||||
}
|
||||
return u.reduceOTPSMS(
|
||||
e,
|
||||
e.Code,
|
||||
e.Expiry,
|
||||
e.Aggregate().ID,
|
||||
e.Aggregate().ResourceOwner,
|
||||
u.commands.HumanOTPSMSCodeSent,
|
||||
user.HumanOTPSMSCodeAddedType,
|
||||
user.HumanOTPSMSCodeSentType,
|
||||
)
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reduceSessionOTPSMSChallenged(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*session.OTPSMSChallengedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Sk32L", "reduce.wrong.event.type %s", session.OTPSMSChallengedType)
|
||||
}
|
||||
if e.CodeReturned {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
ctx := HandlerContext(context.Background(), event.Aggregate())
|
||||
s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u.reduceOTPSMS(
|
||||
e,
|
||||
e.Code,
|
||||
e.Expiry,
|
||||
s.UserFactor.UserID,
|
||||
s.UserFactor.ResourceOwner,
|
||||
u.commands.OTPSMSSent,
|
||||
session.OTPSMSChallengedType,
|
||||
session.OTPSMSSentType,
|
||||
)
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reduceOTPSMS(
|
||||
event eventstore.Event,
|
||||
code *crypto.CryptoValue,
|
||||
expiry time.Duration,
|
||||
userID,
|
||||
resourceOwner string,
|
||||
sentCommand func(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) (err error),
|
||||
eventTypes ...eventstore.EventType,
|
||||
) (*handler.Statement, error) {
|
||||
ctx := HandlerContext(context.Background(), event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, expiry, nil, eventTypes...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return handler.NewNoOpStatement(event), nil
|
||||
}
|
||||
var plainCode string
|
||||
if code != nil {
|
||||
plainCode, err = crypto.DecryptString(code, u.queries.UserDataCrypto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifySMSOTPMessageType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, err = u.queries.Origin(ctx, event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
generatorInfo := new(senders.CodeGeneratorInfo)
|
||||
notify := types.SendSMS(ctx, u.channels, translator, notifyUser, colors, event.Type(), event.Aggregate().InstanceID, event.Aggregate().ID, generatorInfo)
|
||||
err = notify.SendOTPSMSCode(ctx, plainCode, expiry)
|
||||
if err != nil {
|
||||
if errors.Is(err, &channels.CancelError{}) {
|
||||
// if the notification was canceled, we don't want to return the error, so there is no retry
|
||||
return handler.NewNoOpStatement(event), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner, generatorInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler.NewNoOpStatement(event), nil
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanOTPEmailCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-JL3hw", "reduce.wrong.event.type %s", user.HumanOTPEmailCodeAddedType)
|
||||
}
|
||||
var authRequestID string
|
||||
if e.AuthRequestInfo != nil {
|
||||
authRequestID = e.AuthRequestInfo.ID
|
||||
}
|
||||
url := func(code, origin string, _ *query.NotifyUser) (string, error) {
|
||||
return login.OTPLink(origin, authRequestID, code, domain.MFATypeOTPEmail), nil
|
||||
}
|
||||
return u.reduceOTPEmail(
|
||||
e,
|
||||
e.Code,
|
||||
e.Expiry,
|
||||
e.Aggregate().ID,
|
||||
e.Aggregate().ResourceOwner,
|
||||
url,
|
||||
u.commands.HumanOTPEmailCodeSent,
|
||||
user.HumanOTPEmailCodeAddedType,
|
||||
user.HumanOTPEmailCodeSentType,
|
||||
)
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reduceSessionOTPEmailChallenged(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*session.OTPEmailChallengedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-zbsgt", "reduce.wrong.event.type %s", session.OTPEmailChallengedType)
|
||||
}
|
||||
if e.ReturnCode {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
ctx := HandlerContext(context.Background(), event.Aggregate())
|
||||
s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := func(code, origin string, user *query.NotifyUser) (string, error) {
|
||||
var buf strings.Builder
|
||||
urlTmpl := origin + u.otpEmailTmpl
|
||||
if e.URLTmpl != "" {
|
||||
urlTmpl = e.URLTmpl
|
||||
}
|
||||
if err := domain.RenderOTPEmailURLTemplate(&buf, urlTmpl, code, user.ID, user.PreferredLoginName, user.DisplayName, e.Aggregate().ID, user.PreferredLanguage); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
return u.reduceOTPEmail(
|
||||
e,
|
||||
e.Code,
|
||||
e.Expiry,
|
||||
s.UserFactor.UserID,
|
||||
s.UserFactor.ResourceOwner,
|
||||
url,
|
||||
u.commands.OTPEmailSent,
|
||||
user.HumanOTPEmailCodeAddedType,
|
||||
user.HumanOTPEmailCodeSentType,
|
||||
)
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reduceOTPEmail(
|
||||
event eventstore.Event,
|
||||
code *crypto.CryptoValue,
|
||||
expiry time.Duration,
|
||||
userID,
|
||||
resourceOwner string,
|
||||
urlTmpl func(code, origin string, user *query.NotifyUser) (string, error),
|
||||
sentCommand func(ctx context.Context, userID string, resourceOwner string) (err error),
|
||||
eventTypes ...eventstore.EventType,
|
||||
) (*handler.Statement, error) {
|
||||
ctx := HandlerContext(context.Background(), event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, expiry, nil, eventTypes...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return handler.NewNoOpStatement(event), nil
|
||||
}
|
||||
plainCode, err := crypto.DecryptString(code, u.queries.UserDataCrypto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
template, err := u.queries.MailTemplateByOrg(ctx, resourceOwner, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, resourceOwner, domain.VerifyEmailOTPMessageType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, err = u.queries.Origin(ctx, event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url, err := urlTmpl(plainCode, http_util.DomainContext(ctx).Origin(), notifyUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type())
|
||||
err = notify.SendOTPEmailCode(ctx, url, plainCode, expiry)
|
||||
if err != nil {
|
||||
if errors.Is(err, &channels.CancelError{}) {
|
||||
// if the notification was canceled, we don't want to return the error, so there is no retry
|
||||
return handler.NewNoOpStatement(event), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler.NewNoOpStatement(event), nil
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reduceDomainClaimed(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.DomainClaimedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Drh5w", "reduce.wrong.event.type %s", user.UserDomainClaimedType)
|
||||
}
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.queries.IsAlreadyHandled(ctx, event, nil,
|
||||
user.UserDomainClaimedType, user.UserDomainClaimedSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.DomainClaimedMessageType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type()).
|
||||
SendDomainClaimed(ctx, notifyUser, e.UserName)
|
||||
if err != nil {
|
||||
if errors.Is(err, &channels.CancelError{}) {
|
||||
// if the notification was canceled, we don't want to return the error, so there is no retry
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return u.commands.UserDomainClaimedSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reducePasswordlessCodeRequested(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanPasswordlessInitCodeRequestedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-EDtjd", "reduce.wrong.event.type %s", user.HumanPasswordlessInitCodeAddedType)
|
||||
}
|
||||
if e.CodeReturned {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, map[string]interface{}{"id": e.ID}, user.HumanPasswordlessInitCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordlessRegistrationMessageType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type()).
|
||||
SendPasswordlessRegistrationLink(ctx, notifyUser, code, e.ID, e.URLTemplate)
|
||||
if err != nil {
|
||||
if errors.Is(err, &channels.CancelError{}) {
|
||||
// if the notification was canceled, we don't want to return the error, so there is no retry
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return u.commands.HumanPasswordlessInitCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.ID)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reducePasswordChanged(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanPasswordChangedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Yko2z8", "reduce.wrong.event.type %s", user.HumanPasswordChangedType)
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.queries.IsAlreadyHandled(ctx, event, nil, user.HumanPasswordChangeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
|
||||
notificationPolicy, err := u.queries.NotificationPolicyByOrg(ctx, true, e.Aggregate().ResourceOwner, false)
|
||||
if zerrors.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !notificationPolicy.PasswordChange {
|
||||
return nil
|
||||
}
|
||||
|
||||
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordChangeMessageType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type()).
|
||||
SendPasswordChange(ctx, notifyUser)
|
||||
if err != nil {
|
||||
if errors.Is(err, &channels.CancelError{}) {
|
||||
// if the notification was canceled, we don't want to return the error, so there is no retry
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return u.commands.PasswordChangeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reducePhoneCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanPhoneCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-He83g", "reduce.wrong.event.type %s", user.HumanPhoneCodeAddedType)
|
||||
}
|
||||
if e.CodeReturned {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
user.UserV1PhoneCodeAddedType, user.UserV1PhoneCodeSentType,
|
||||
user.HumanPhoneCodeAddedType, user.HumanPhoneCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
var code string
|
||||
if e.Code != nil {
|
||||
code, err = crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifyPhoneMessageType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
generatorInfo := new(senders.CodeGeneratorInfo)
|
||||
if err = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e.Type(), e.Aggregate().InstanceID, e.ID, generatorInfo).
|
||||
SendPhoneVerificationCode(ctx, code); err != nil {
|
||||
if errors.Is(err, &channels.CancelError{}) {
|
||||
// if the notification was canceled, we don't want to return the error, so there is no retry
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return u.commands.HumanPhoneVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) reduceInviteCodeAdded(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*user.HumanInviteCodeAddedEvent)
|
||||
if !ok {
|
||||
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanInviteCodeAddedType)
|
||||
}
|
||||
if e.CodeReturned {
|
||||
return handler.NewNoOpStatement(e), nil
|
||||
}
|
||||
|
||||
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error {
|
||||
ctx = HandlerContext(ctx, event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
|
||||
user.HumanInviteCodeAddedType, user.HumanInviteCodeSentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadyHandled {
|
||||
return nil
|
||||
}
|
||||
code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.InviteUserMessageType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err = u.queries.Origin(ctx, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event.Type())
|
||||
err = notify.SendInviteCode(ctx, notifyUser, code, e.ApplicationName, e.URLTemplate, e.AuthRequestID)
|
||||
if err != nil {
|
||||
if errors.Is(err, &channels.CancelError{}) {
|
||||
// if the notification was canceled, we don't want to return the error, so there is no retry
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return u.commands.InviteCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (u *userNotifierLegacy) checkIfCodeAlreadyHandledOrExpired(ctx context.Context, event eventstore.Event, expiry time.Duration, data map[string]interface{}, eventTypes ...eventstore.EventType) (bool, error) {
|
||||
if expiry > 0 && event.CreatedAt().Add(expiry).Before(time.Now().UTC()) {
|
||||
return true, nil
|
||||
}
|
||||
return u.queries.IsAlreadyHandled(ctx, event, data, eventTypes...)
|
||||
}
|
2010
apps/api/internal/notification/handlers/user_notifier_legacy_test.go
Normal file
2010
apps/api/internal/notification/handlers/user_notifier_legacy_test.go
Normal file
File diff suppressed because it is too large
Load Diff
2038
apps/api/internal/notification/handlers/user_notifier_test.go
Normal file
2038
apps/api/internal/notification/handlers/user_notifier_test.go
Normal file
File diff suppressed because it is too large
Load Diff
75
apps/api/internal/notification/messages/email.go
Normal file
75
apps/api/internal/notification/messages/email.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package messages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
)
|
||||
|
||||
var (
|
||||
isHTMLRgx = regexp.MustCompile(`.*<html.*>.*`)
|
||||
lineBreak = "\r\n"
|
||||
)
|
||||
|
||||
var _ channels.Message = (*Email)(nil)
|
||||
|
||||
type Email struct {
|
||||
Recipients []string
|
||||
BCC []string
|
||||
CC []string
|
||||
SenderEmail string
|
||||
SenderName string
|
||||
ReplyToAddress string
|
||||
Subject string
|
||||
Content string
|
||||
TriggeringEventType eventstore.EventType
|
||||
}
|
||||
|
||||
func (msg *Email) GetContent() (string, error) {
|
||||
headers := make(map[string]string)
|
||||
from := msg.SenderEmail
|
||||
if msg.SenderName != "" {
|
||||
from = fmt.Sprintf("%s <%s>", bEncodeWord(msg.SenderName), msg.SenderEmail)
|
||||
}
|
||||
headers["From"] = from
|
||||
if msg.ReplyToAddress != "" {
|
||||
headers["Reply-to"] = msg.ReplyToAddress
|
||||
}
|
||||
headers["Return-Path"] = msg.SenderEmail
|
||||
headers["To"] = strings.Join(msg.Recipients, ", ")
|
||||
headers["Cc"] = strings.Join(msg.CC, ", ")
|
||||
headers["Date"] = time.Now().Format(time.RFC1123Z)
|
||||
|
||||
message := ""
|
||||
for k, v := range headers {
|
||||
message += fmt.Sprintf("%s: %s"+lineBreak, k, v)
|
||||
}
|
||||
|
||||
//default mime-type is html
|
||||
mime := "MIME-Version: 1.0" + lineBreak + "Content-Type: text/html; charset=\"UTF-8\"" + lineBreak + lineBreak
|
||||
if !isHTML(msg.Content) {
|
||||
mime = "MIME-Version: 1.0" + lineBreak + "Content-Type: text/plain; charset=\"UTF-8\"" + lineBreak + lineBreak
|
||||
}
|
||||
subject := "Subject: " + bEncodeWord(msg.Subject) + lineBreak
|
||||
message += subject + mime + lineBreak + msg.Content
|
||||
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func (msg *Email) GetTriggeringEventType() eventstore.EventType {
|
||||
return msg.TriggeringEventType
|
||||
}
|
||||
|
||||
func isHTML(input string) bool {
|
||||
return isHTMLRgx.MatchString(input)
|
||||
}
|
||||
|
||||
// returns a RFC1342 "B" encoded string to allow non-ascii characters
|
||||
func bEncodeWord(word string) string {
|
||||
return mime.BEncoding.Encode("UTF-8", word)
|
||||
}
|
27
apps/api/internal/notification/messages/form.go
Normal file
27
apps/api/internal/notification/messages/form.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package messages
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/zitadel/schema"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
)
|
||||
|
||||
var _ channels.Message = (*Form)(nil)
|
||||
|
||||
type Form struct {
|
||||
Serializable any
|
||||
TriggeringEventType eventstore.EventType
|
||||
}
|
||||
|
||||
func (msg *Form) GetContent() (string, error) {
|
||||
values := make(url.Values)
|
||||
err := schema.NewEncoder().Encode(msg.Serializable, values)
|
||||
return values.Encode(), err
|
||||
}
|
||||
|
||||
func (msg *Form) GetTriggeringEventType() eventstore.EventType {
|
||||
return msg.TriggeringEventType
|
||||
}
|
24
apps/api/internal/notification/messages/json.go
Normal file
24
apps/api/internal/notification/messages/json.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package messages
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
)
|
||||
|
||||
var _ channels.Message = (*JSON)(nil)
|
||||
|
||||
type JSON struct {
|
||||
Serializable interface{}
|
||||
TriggeringEventType eventstore.EventType
|
||||
}
|
||||
|
||||
func (msg *JSON) GetContent() (string, error) {
|
||||
bytes, err := json.Marshal(msg.Serializable)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
func (msg *JSON) GetTriggeringEventType() eventstore.EventType {
|
||||
return msg.TriggeringEventType
|
||||
}
|
29
apps/api/internal/notification/messages/sms.go
Normal file
29
apps/api/internal/notification/messages/sms.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package messages
|
||||
|
||||
import (
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
)
|
||||
|
||||
var _ channels.Message = (*SMS)(nil)
|
||||
|
||||
type SMS struct {
|
||||
SenderPhoneNumber string
|
||||
RecipientPhoneNumber string
|
||||
Content string
|
||||
TriggeringEventType eventstore.EventType
|
||||
|
||||
// VerificationID is set by the sender
|
||||
VerificationID *string
|
||||
InstanceID string
|
||||
JobID string
|
||||
UserID string
|
||||
}
|
||||
|
||||
func (msg *SMS) GetContent() (string, error) {
|
||||
return msg.Content, nil
|
||||
}
|
||||
|
||||
func (msg *SMS) GetTriggeringEventType() eventstore.EventType {
|
||||
return msg.TriggeringEventType
|
||||
}
|
111
apps/api/internal/notification/projections.go
Normal file
111
apps/api/internal/notification/projections.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
|
||||
"github.com/zitadel/zitadel/internal/notification/handlers"
|
||||
_ "github.com/zitadel/zitadel/internal/notification/statik"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/query/projection"
|
||||
"github.com/zitadel/zitadel/internal/queue"
|
||||
)
|
||||
|
||||
var (
|
||||
projections []*handler.Handler
|
||||
)
|
||||
|
||||
func Register(
|
||||
ctx context.Context,
|
||||
userHandlerCustomConfig, quotaHandlerCustomConfig, telemetryHandlerCustomConfig, backChannelLogoutHandlerCustomConfig projection.CustomConfig,
|
||||
notificationWorkerConfig handlers.WorkerConfig,
|
||||
telemetryCfg handlers.TelemetryPusherConfig,
|
||||
externalDomain string,
|
||||
externalPort uint16,
|
||||
externalSecure bool,
|
||||
commands *command.Commands,
|
||||
queries *query.Queries,
|
||||
es *eventstore.Eventstore,
|
||||
otpEmailTmpl, fileSystemPath string,
|
||||
userEncryption, smtpEncryption, smsEncryption, keysEncryptionAlg crypto.EncryptionAlgorithm,
|
||||
tokenLifetime time.Duration,
|
||||
queue *queue.Queue,
|
||||
) {
|
||||
if !notificationWorkerConfig.LegacyEnabled {
|
||||
queue.ShouldStart()
|
||||
}
|
||||
|
||||
// make sure the slice does not contain old values
|
||||
projections = nil
|
||||
|
||||
q := handlers.NewNotificationQueries(queries, es, externalDomain, externalPort, externalSecure, fileSystemPath, userEncryption, smtpEncryption, smsEncryption)
|
||||
c := newChannels(q)
|
||||
projections = append(projections, handlers.NewUserNotifier(ctx, projection.ApplyCustomConfig(userHandlerCustomConfig), commands, q, c, otpEmailTmpl, notificationWorkerConfig, queue))
|
||||
projections = append(projections, handlers.NewQuotaNotifier(ctx, projection.ApplyCustomConfig(quotaHandlerCustomConfig), commands, q, c))
|
||||
projections = append(projections, handlers.NewBackChannelLogoutNotifier(
|
||||
ctx,
|
||||
projection.ApplyCustomConfig(backChannelLogoutHandlerCustomConfig),
|
||||
commands,
|
||||
q,
|
||||
es,
|
||||
keysEncryptionAlg,
|
||||
c,
|
||||
tokenLifetime,
|
||||
))
|
||||
if telemetryCfg.Enabled {
|
||||
projections = append(projections, handlers.NewTelemetryPusher(ctx, telemetryCfg, projection.ApplyCustomConfig(telemetryHandlerCustomConfig), commands, q, c))
|
||||
}
|
||||
if !notificationWorkerConfig.LegacyEnabled {
|
||||
queue.AddWorkers(handlers.NewNotificationWorker(notificationWorkerConfig, commands, q, c))
|
||||
}
|
||||
}
|
||||
|
||||
func Start(ctx context.Context) {
|
||||
for _, projection := range projections {
|
||||
projection.Start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func SetCurrentState(ctx context.Context, es *eventstore.Eventstore) error {
|
||||
if len(projections) == 0 {
|
||||
return nil
|
||||
}
|
||||
position, err := es.LatestPosition(ctx, eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition).InstanceID(authz.GetInstance(ctx).InstanceID()).OrderDesc().Limit(1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, projection := range projections {
|
||||
logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("set current state of notification projection")
|
||||
_, err = projection.Trigger(ctx, handler.WithMinPosition(position))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("current state of notification projection set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ProjectInstance(ctx context.Context) error {
|
||||
for i, projection := range projections {
|
||||
logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("starting notification projection")
|
||||
_, err := projection.Trigger(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("notification projection done")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Projections() []*handler.Handler {
|
||||
return projections
|
||||
}
|
28
apps/api/internal/notification/senders/chain.go
Normal file
28
apps/api/internal/notification/senders/chain.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package senders
|
||||
|
||||
import "github.com/zitadel/zitadel/internal/notification/channels"
|
||||
|
||||
var _ channels.NotificationChannel = (*Chain)(nil)
|
||||
|
||||
type Chain struct {
|
||||
channels []channels.NotificationChannel
|
||||
}
|
||||
|
||||
func ChainChannels(channel ...channels.NotificationChannel) *Chain {
|
||||
return &Chain{channels: channel}
|
||||
}
|
||||
|
||||
// HandleMessage returns a non nil error from a provider immediately if any occurs
|
||||
// messages are sent to channels in the same order they were provided to ChainChannels()
|
||||
func (c *Chain) HandleMessage(message channels.Message) error {
|
||||
for i := range c.channels {
|
||||
if err := c.channels[i].HandleMessage(message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Chain) Len() int {
|
||||
return len(c.channels)
|
||||
}
|
24
apps/api/internal/notification/senders/code_verifier.go
Normal file
24
apps/api/internal/notification/senders/code_verifier.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package senders
|
||||
|
||||
type CodeGenerator interface {
|
||||
VerifyCode(verificationID, code string) error
|
||||
}
|
||||
|
||||
type CodeGeneratorInfo struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
VerificationID string `json:"verificationId,omitempty"`
|
||||
}
|
||||
|
||||
func (c *CodeGeneratorInfo) GetID() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.ID
|
||||
}
|
||||
|
||||
func (c *CodeGeneratorInfo) GetVerificationID() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.VerificationID
|
||||
}
|
28
apps/api/internal/notification/senders/debug.go
Normal file
28
apps/api/internal/notification/senders/debug.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package senders
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/fs"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/log"
|
||||
)
|
||||
|
||||
func debugChannels(ctx context.Context, getFileSystemProvider func(ctx context.Context) (*fs.Config, error), getLogProvider func(ctx context.Context) (*log.Config, error)) []channels.NotificationChannel {
|
||||
var (
|
||||
providers []channels.NotificationChannel
|
||||
)
|
||||
|
||||
if fsProvider, err := getFileSystemProvider(ctx); err == nil {
|
||||
p, err := fs.InitFSChannel(*fsProvider)
|
||||
if err == nil {
|
||||
providers = append(providers, p)
|
||||
}
|
||||
}
|
||||
|
||||
if logProvider, err := getLogProvider(ctx); err == nil {
|
||||
providers = append(providers, log.InitStdoutChannel(*logProvider))
|
||||
}
|
||||
|
||||
return providers
|
||||
}
|
68
apps/api/internal/notification/senders/email.go
Normal file
68
apps/api/internal/notification/senders/email.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package senders
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/email"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/fs"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/instrumenting"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/log"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/smtp"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
|
||||
)
|
||||
|
||||
const smtpSpanName = "smtp.NotificationChannel"
|
||||
|
||||
func EmailChannels(
|
||||
ctx context.Context,
|
||||
emailConfig *email.Config,
|
||||
getFileSystemProvider func(ctx context.Context) (*fs.Config, error),
|
||||
getLogProvider func(ctx context.Context) (*log.Config, error),
|
||||
successMetricName,
|
||||
failureMetricName string,
|
||||
) (chain *Chain, err error) {
|
||||
channels := make([]channels.NotificationChannel, 0, 3)
|
||||
if emailConfig.SMTPConfig != nil {
|
||||
p, err := smtp.InitChannel(emailConfig.SMTPConfig)
|
||||
logging.WithFields(
|
||||
"instance", authz.GetInstance(ctx).InstanceID(),
|
||||
).OnError(err).Debug("initializing SMTP channel failed")
|
||||
if err == nil {
|
||||
channels = append(
|
||||
channels,
|
||||
instrumenting.Wrap(
|
||||
ctx,
|
||||
p,
|
||||
smtpSpanName,
|
||||
successMetricName,
|
||||
failureMetricName,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if emailConfig.WebhookConfig != nil {
|
||||
webhookChannel, err := webhook.InitChannel(ctx, *emailConfig.WebhookConfig)
|
||||
logging.WithFields(
|
||||
"instance", authz.GetInstance(ctx).InstanceID(),
|
||||
"callurl", emailConfig.WebhookConfig.CallURL,
|
||||
).OnError(err).Debug("initializing JSON channel failed")
|
||||
if err == nil {
|
||||
channels = append(
|
||||
channels,
|
||||
instrumenting.Wrap(
|
||||
ctx,
|
||||
webhookChannel,
|
||||
webhookSpanName,
|
||||
successMetricName,
|
||||
failureMetricName,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...)
|
||||
return ChainChannels(channels...), nil
|
||||
}
|
3
apps/api/internal/notification/senders/gen_mock.go
Normal file
3
apps/api/internal/notification/senders/gen_mock.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package senders
|
||||
|
||||
//go:generate mockgen -package mock -destination ./mock/code_generator.mock.go github.com/zitadel/zitadel/internal/notification/senders CodeGenerator
|
@@ -0,0 +1,53 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/zitadel/zitadel/internal/notification/senders (interfaces: CodeGenerator)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -package mock -destination ./mock/code_generator.mock.go github.com/zitadel/zitadel/internal/notification/senders CodeGenerator
|
||||
//
|
||||
|
||||
// Package mock is a generated GoMock package.
|
||||
package mock
|
||||
|
||||
import (
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockCodeGenerator is a mock of CodeGenerator interface.
|
||||
type MockCodeGenerator struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockCodeGeneratorMockRecorder
|
||||
}
|
||||
|
||||
// MockCodeGeneratorMockRecorder is the mock recorder for MockCodeGenerator.
|
||||
type MockCodeGeneratorMockRecorder struct {
|
||||
mock *MockCodeGenerator
|
||||
}
|
||||
|
||||
// NewMockCodeGenerator creates a new mock instance.
|
||||
func NewMockCodeGenerator(ctrl *gomock.Controller) *MockCodeGenerator {
|
||||
mock := &MockCodeGenerator{ctrl: ctrl}
|
||||
mock.recorder = &MockCodeGeneratorMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockCodeGenerator) EXPECT() *MockCodeGeneratorMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// VerifyCode mocks base method.
|
||||
func (m *MockCodeGenerator) VerifyCode(arg0, arg1 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "VerifyCode", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// VerifyCode indicates an expected call of VerifyCode.
|
||||
func (mr *MockCodeGeneratorMockRecorder) VerifyCode(arg0, arg1 any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyCode", reflect.TypeOf((*MockCodeGenerator)(nil).VerifyCode), arg0, arg1)
|
||||
}
|
@@ -0,0 +1,49 @@
|
||||
package senders
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/fs"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/instrumenting"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/log"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/set"
|
||||
)
|
||||
|
||||
const setSpanName = "security_event_token.NotificationChannel"
|
||||
|
||||
func SecurityEventTokenChannels(
|
||||
ctx context.Context,
|
||||
setConfig set.Config,
|
||||
getFileSystemProvider func(ctx context.Context) (*fs.Config, error),
|
||||
getLogProvider func(ctx context.Context) (*log.Config, error),
|
||||
successMetricName,
|
||||
failureMetricName string,
|
||||
) (*Chain, error) {
|
||||
if err := setConfig.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channels := make([]channels.NotificationChannel, 0, 3)
|
||||
setChannel, err := set.InitChannel(ctx, setConfig)
|
||||
logging.WithFields(
|
||||
"instance", authz.GetInstance(ctx).InstanceID(),
|
||||
"callurl", setConfig.CallURL,
|
||||
).OnError(err).Debug("initializing SET channel failed")
|
||||
if err == nil {
|
||||
channels = append(
|
||||
channels,
|
||||
instrumenting.Wrap(
|
||||
ctx,
|
||||
setChannel,
|
||||
setSpanName,
|
||||
successMetricName,
|
||||
failureMetricName,
|
||||
),
|
||||
)
|
||||
}
|
||||
channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...)
|
||||
return ChainChannels(channels...), nil
|
||||
}
|
62
apps/api/internal/notification/senders/sms.go
Normal file
62
apps/api/internal/notification/senders/sms.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package senders
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/fs"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/instrumenting"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/log"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/sms"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
|
||||
)
|
||||
|
||||
const twilioSpanName = "twilio.NotificationChannel"
|
||||
|
||||
func SMSChannels(
|
||||
ctx context.Context,
|
||||
smsConfig *sms.Config,
|
||||
getFileSystemProvider func(ctx context.Context) (*fs.Config, error),
|
||||
getLogProvider func(ctx context.Context) (*log.Config, error),
|
||||
successMetricName,
|
||||
failureMetricName string,
|
||||
) (chain *Chain, err error) {
|
||||
channels := make([]channels.NotificationChannel, 0, 3)
|
||||
if smsConfig.TwilioConfig != nil {
|
||||
channels = append(
|
||||
channels,
|
||||
instrumenting.Wrap(
|
||||
ctx,
|
||||
twilio.InitChannel(*smsConfig.TwilioConfig),
|
||||
twilioSpanName,
|
||||
successMetricName,
|
||||
failureMetricName,
|
||||
),
|
||||
)
|
||||
}
|
||||
if smsConfig.WebhookConfig != nil {
|
||||
webhookChannel, err := webhook.InitChannel(ctx, *smsConfig.WebhookConfig)
|
||||
logging.WithFields(
|
||||
"instance", authz.GetInstance(ctx).InstanceID(),
|
||||
"callurl", smsConfig.WebhookConfig.CallURL,
|
||||
).OnError(err).Debug("initializing JSON channel failed")
|
||||
if err == nil {
|
||||
channels = append(
|
||||
channels,
|
||||
instrumenting.Wrap(
|
||||
ctx,
|
||||
webhookChannel,
|
||||
webhookSpanName,
|
||||
successMetricName,
|
||||
failureMetricName,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...)
|
||||
return ChainChannels(channels...), nil
|
||||
}
|
49
apps/api/internal/notification/senders/webhook.go
Normal file
49
apps/api/internal/notification/senders/webhook.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package senders
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/fs"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/instrumenting"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/log"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
|
||||
)
|
||||
|
||||
const webhookSpanName = "webhook.NotificationChannel"
|
||||
|
||||
func WebhookChannels(
|
||||
ctx context.Context,
|
||||
webhookConfig webhook.Config,
|
||||
getFileSystemProvider func(ctx context.Context) (*fs.Config, error),
|
||||
getLogProvider func(ctx context.Context) (*log.Config, error),
|
||||
successMetricName,
|
||||
failureMetricName string,
|
||||
) (*Chain, error) {
|
||||
if err := webhookConfig.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channels := make([]channels.NotificationChannel, 0, 3)
|
||||
webhookChannel, err := webhook.InitChannel(ctx, webhookConfig)
|
||||
logging.WithFields(
|
||||
"instance", authz.GetInstance(ctx).InstanceID(),
|
||||
"callurl", webhookConfig.CallURL,
|
||||
).OnError(err).Debug("initializing JSON channel failed")
|
||||
if err == nil {
|
||||
channels = append(
|
||||
channels,
|
||||
instrumenting.Wrap(
|
||||
ctx,
|
||||
webhookChannel,
|
||||
webhookSpanName,
|
||||
successMetricName,
|
||||
failureMetricName,
|
||||
),
|
||||
)
|
||||
}
|
||||
channels = append(channels, debugChannels(ctx, getFileSystemProvider, getLogProvider)...)
|
||||
return ChainChannels(channels...), nil
|
||||
}
|
78
apps/api/internal/notification/static/i18n/bg.yaml
Normal file
78
apps/api/internal/notification/static/i18n/bg.yaml
Normal file
@@ -0,0 +1,78 @@
|
||||
InitCode:
|
||||
Title: ZITADEL - Инициализиране на потребител
|
||||
PreHeader: Инициализиране на потребителя
|
||||
Subject: Инициализиране на потребителя
|
||||
Greeting: 'Здравейте {{.DisplayName}},'
|
||||
Text: >-
|
||||
Този потребител е създаден в ZITADEL. {{.PreferredLoginName}} за да
|
||||
влезете. {{.Code}}) Ако не сте поискали този имейл, моля, игнорирайте го.
|
||||
ButtonText: Завършете инициализацията
|
||||
PasswordReset:
|
||||
Title: ZITADEL - Нулиране на парола
|
||||
PreHeader: Нулирайте паролата
|
||||
Subject: Нулирайте паролата
|
||||
Greeting: 'Здравейте {{.DisplayName}},'
|
||||
Text: >-
|
||||
Получихме заявка за повторно задаване на парола. {{.Code}}) Ако не сте
|
||||
поискали този имейл, моля, игнорирайте го.
|
||||
ButtonText: Нулирайте паролата
|
||||
VerifyEmail:
|
||||
Title: ZITADEL - Потвърдете имейл
|
||||
PreHeader: Потвърди Имейл
|
||||
Subject: Потвърди Имейл
|
||||
Greeting: 'Здравейте {{.DisplayName}},'
|
||||
Text: >-
|
||||
Добавен е нов имейл. {{.Code}}) Ако не сте добавили нов имейл, моля,
|
||||
игнорирайте този имейл.
|
||||
ButtonText: Потвърди Имейл
|
||||
VerifyPhone:
|
||||
Title: ZITADEL - Потвърдете телефона
|
||||
PreHeader: Потвърдете телефона
|
||||
Subject: Потвърдете телефона
|
||||
Greeting: 'Здравейте {{.DisplayName}},'
|
||||
Text: 'Добавен е нов телефонен номер. {{.Code}}'
|
||||
ButtonText: Потвърдете телефона
|
||||
VerifyEmailOTP:
|
||||
Title: ZITADEL - Потвърди временната парола
|
||||
PreHeader: Потвърди временната парола
|
||||
Subject: Потвърди временната парола
|
||||
Greeting: Здравей, {{.DisplayName}},
|
||||
Text: Моля, използвай бутона 'Удостовери' или копирай временната парола {{.OTP}} и я постави на екрана за удостоверяване, за да се удостовериш в ZITADEL в рамките на следващите пет минути.
|
||||
ButtonText: Удостовери
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} е вашата еднократна парола за {{ .Domain }}. Използвайте го в рамките на следващия {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: ZITADEL - Домейнът е заявен
|
||||
PreHeader: Промяна на имейл/потребителско име
|
||||
Subject: Домейнът е заявен
|
||||
Greeting: 'Здравейте {{.DisplayName}},'
|
||||
Text: >-
|
||||
Домейнът {{.Domain}} е заявено от организация. {{.Username}} не е част от
|
||||
тази организация. {{.TempUsername}}) за това влизане.
|
||||
ButtonText: Влизам
|
||||
PasswordlessRegistration:
|
||||
Title: ZITADEL - Добавете вход без парола
|
||||
PreHeader: Добавете вход без парола
|
||||
Subject: Добавете вход без парола
|
||||
Greeting: 'Здравейте {{.DisplayName}},'
|
||||
Text: 'Получихме заявка за добавяне на токен за влизане без парола. '
|
||||
ButtonText: Добавете вход без парола
|
||||
PasswordChange:
|
||||
Title: ZITADEL - Паролата на потребителя е променена
|
||||
PreHeader: Промяна на паролата
|
||||
Subject: Паролата на потребителя е променена
|
||||
Greeting: 'Здравейте {{.DisplayName}},'
|
||||
Text: >-
|
||||
Паролата на вашия потребител е променена, ако тази промяна не е направена от
|
||||
вас, моля, незабавно нулирайте паролата си.
|
||||
ButtonText: Влизам
|
||||
InviteUser:
|
||||
Title: Покана за {{.ApplicationName}}
|
||||
PreHeader: Покана за {{.ApplicationName}}
|
||||
Subject: Покана за {{.ApplicationName}}
|
||||
Greeting: 'Здравейте {{.DisplayName}},'
|
||||
Text: Вашият потребител е бил поканен за {{.ApplicationName}}. Моля, кликнете върху бутона по-долу, за да завършите процеса на покана. Ако не сте поискали този имейл, моля, игнорирайте го.
|
||||
ButtonText: Приеми поканата
|
68
apps/api/internal/notification/static/i18n/cs.yaml
Normal file
68
apps/api/internal/notification/static/i18n/cs.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: Inicializace uživatele
|
||||
PreHeader: Inicializace uživatele
|
||||
Subject: Inicializace uživatele
|
||||
Greeting: Dobrý den, {{.DisplayName}},
|
||||
Text: Tento uživatel byl vytvořen. Pro přihlášení použijte uživatelské jméno {{.PreferredLoginName}}. Prosím klikněte na tlačítko níže k dokončení procesu inicializace. (Kód {{.Code}}) Pokud jste o tento email nepožádali, prosím ignorujte ho.
|
||||
ButtonText: Dokončit inicializaci
|
||||
PasswordReset:
|
||||
Title: Reset hesla
|
||||
PreHeader: Reset hesla
|
||||
Subject: Reset hesla
|
||||
Greeting: Dobrý den, {{.DisplayName}},
|
||||
Text: Přijali jsme žádost o reset hesla. Pro reset hesla prosím použijte tlačítko níže. (Kód {{.Code}}) Pokud jste o tento email nepožádali, prosím ignorujte ho.
|
||||
ButtonText: Reset hesla
|
||||
VerifyEmail:
|
||||
Title: Ověření emailu
|
||||
PreHeader: Ověření emailu
|
||||
Subject: Ověření emailu
|
||||
Greeting: Dobrý den, {{.DisplayName}},
|
||||
Text: Byl přidán nový email. Pro ověření vašeho emailu prosím použijte tlačítko níže. (Kód {{.Code}}) Pokud jste nepřidávali nový email, prosím ignorujte tento email.
|
||||
ButtonText: Ověřit email
|
||||
VerifyPhone:
|
||||
Title: Ověření telefonu
|
||||
PreHeader: Ověření telefonu
|
||||
Subject: Ověření telefonu
|
||||
Greeting: Dobrý den, {{.DisplayName}},
|
||||
Text: Bylo přidáno nové telefonní číslo. Prosím použijte následující kód k jeho ověření {{.Code}}
|
||||
ButtonText: Ověřit telefon
|
||||
VerifyEmailOTP:
|
||||
Title: Ověření jednorázového hesla
|
||||
PreHeader: Ověření jednorázového hesla
|
||||
Subject: Ověření jednorázového hesla
|
||||
Greeting: Dobrý den, {{.DisplayName}},
|
||||
Text: Prosím použijte jednorázové heslo {{.OTP}} k ověření během následujících pěti minut nebo klikněte na tlačítko "Ověřit".
|
||||
ButtonText: Ověřit
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} je vaše jednorázové heslo pro {{ .Domain }}. Použijte jej během následujících {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: Doména byla přiřazena
|
||||
PreHeader: Změna emailu / uživatelského jména
|
||||
Subject: Doména byla přiřazena
|
||||
Greeting: Dobrý den, {{.DisplayName}},
|
||||
Text: Doména {{.Domain}} byla přiřazena organizaci. Váš současný uživatel {{.Username}} není součástí této organizace. Proto budete muset změnit svůj email při přihlášení. Pro toto přihlášení jsme vytvořili dočasné uživatelské jméno ({{.TempUsername}}).
|
||||
ButtonText: Přihlásit se
|
||||
PasswordlessRegistration:
|
||||
Title: Přidat přihlášení bez hesla
|
||||
PreHeader: Přidat přihlášení bez hesla
|
||||
Subject: Přidat přihlášení bez hesla
|
||||
Greeting: Dobrý den, {{.DisplayName}},
|
||||
Text: Přijali jsme žádost o přidání tokenu pro přihlášení bez hesla. Pro přidání vašeho tokenu nebo zařízení pro přihlášení bez hesla prosím použijte tlačítko níže.
|
||||
ButtonText: Přidat přihlášení bez hesla
|
||||
PasswordChange:
|
||||
Title: Heslo uživatele bylo změněno
|
||||
PreHeader: Změna hesla
|
||||
Subject: Heslo uživatele bylo změněno
|
||||
Greeting: Dobrý den, {{.DisplayName}},
|
||||
Text: Heslo vašeho uživatele bylo změněno. Pokud tato změna nebyla provedena Vámi pak doporučujeme okamžitě resetovat/změnit vaše heslo.
|
||||
ButtonText: Přihlásit se
|
||||
InviteUser:
|
||||
Title: Pozvánka do {{.ApplicationName}}
|
||||
PreHeader: Pozvánka do {{.ApplicationName}}
|
||||
Subject: Pozvánka do {{.ApplicationName}}
|
||||
Greeting: Dobrý den, {{.DisplayName}},
|
||||
Text: Váš uživatel byl pozván do {{.ApplicationName}}. Klikněte prosím na tlačítko níže, abyste dokončili proces pozvání. Pokud jste o tento e-mail nepožádali, prosím, ignorujte ho.
|
||||
ButtonText: Přijmout pozvání
|
68
apps/api/internal/notification/static/i18n/de.yaml
Normal file
68
apps/api/internal/notification/static/i18n/de.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: ZITADEL - User initialisieren
|
||||
PreHeader: User initialisieren
|
||||
Subject: User initialisieren
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Dieser Benutzer wurde soeben erstellt. Mit dem Benutzernamen <br><strong>{{.PreferredLoginName}}</strong><br> kannst du dich anmelden. Nutze den unten stehenden Button, um die Initialisierung abzuschliessen <br>(Code <strong>{{.Code}}</strong>).<br> Falls du dieses E-Mail nicht angefordert hast, kannst du sie einfach ignorieren.
|
||||
ButtonText: Initialisierung abschliessen
|
||||
PasswordReset:
|
||||
Title: Passwort zurücksetzen
|
||||
PreHeader: Passwort zurücksetzen
|
||||
Subject: Passwort zurücksetzen
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Wir haben eine Anfrage zum Zurücksetzen deines Passworts bekommen. Du kannst den unten stehenden Button verwenden, um dein Passwort zurückzusetzen <br>(Code <strong>{{.Code}}</strong>).<br> Falls du das Zurücksetzen des Passworts nicht angefordert hast, kannst du diese E-Mail ignorieren.
|
||||
ButtonText: Passwort zurücksetzen
|
||||
VerifyEmail:
|
||||
Title: E-Mail-Adresse verifizieren
|
||||
PreHeader: E-Mail-Adresse verifizieren
|
||||
Subject: E-Mail-Adresse verifizieren
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Eine neue E-Mail-Adresse wurde hinzugefügt. Bitte verwende den unten stehenden Button, um diese zu verifizieren <br>(Code <strong>{{.Code}}</strong>).<br> Falls du die E-Mail-Adresse nicht selbst hinzugefügt hast, kannst du diese E-Mail ignorieren.
|
||||
ButtonText: E-Mail-Adresse verifizieren
|
||||
VerifyPhone:
|
||||
Title: Telefonnummer verifizieren
|
||||
PreHeader: Telefonnummer verifizieren
|
||||
Subject: Telefonnummer verifizieren
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Eine Telefonnummer wurde hinzugefügt. Bitte verifiziere diese, indem du folgenden Code eingibst<br>(Code <strong>{{.Code}}</strong>).<br>
|
||||
ButtonText: Telefonnummer verifizieren
|
||||
VerifyEmailOTP:
|
||||
Title: Einmalpasswort verifizieren
|
||||
PreHeader: Einmalpasswort verifizieren
|
||||
Subject: Einmalpasswort verifizieren
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Bitte nutze den 'Authentifizieren'-Button oder kopiere das Einmalpasswort {{.OTP}} und gib es zur Bestätigung ein, um dich innerhalb der nächsten fünf Minuten zu authentifizieren.
|
||||
ButtonText: Authentifizieren
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} ist dein Einmalpasswort für {{ .Domain }}. Verwende es innerhalb der nächsten {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: Domain wurde beansprucht
|
||||
PreHeader: E-Mail / Benutzername ändern
|
||||
Subject: Domain wurde beansprucht
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Die Domain {{.Domain}} wurde von einer Organisation beansprucht. Dein derzeitiger Benutzer {{.Username}} ist nicht Teil dieser Organisation. Daher musst du beim nächsten Login eine neue E-Mail-Adresse hinterlegen. Für diesen Login haben wir dir einen temporären Usernamen ({{.TempUsername}}) erstellt.
|
||||
ButtonText: Login
|
||||
PasswordlessRegistration:
|
||||
Title: Passwortlosen Login hinzufügen
|
||||
PreHeader: Passwortlosen Login hinzufügen
|
||||
Subject: Passwortlosen Login hinzufügen
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Wir haben eine Anfrage für das Hinzufügen eines Token für den passwortlosen Login erhalten. Du kannst den unten stehenden Button verwenden, um dein Token oder Gerät hinzuzufügen.
|
||||
ButtonText: Passwortlosen Login hinzufügen
|
||||
PasswordChange:
|
||||
Title: Passwort wurde geändert
|
||||
PreHeader: Passwortänderung
|
||||
Subject: Passwort wurde geändert
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Dein Passwort wurde geändert. Wenn diese Änderung nicht von dir gemacht wurde, empfehlen wir das sofortige Zurücksetzen deines Passworts.
|
||||
ButtonText: Login
|
||||
InviteUser:
|
||||
Title: Einladung zu {{.ApplicationName}}
|
||||
PreHeader: Einladung zu {{.ApplicationName}}
|
||||
Subject: Einladung zu {{.ApplicationName}}
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Ihr Benutzer wurde zu {{.ApplicationName}} eingeladen. Bitte klicken Sie auf die Schaltfläche unten, um den Einladungsprozess abzuschließen. Wenn Sie diese E-Mail nicht angefordert haben, ignorieren Sie sie bitte.
|
||||
ButtonText: Einladung annehmen
|
68
apps/api/internal/notification/static/i18n/en.yaml
Normal file
68
apps/api/internal/notification/static/i18n/en.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: Initialize User
|
||||
PreHeader: Initialize User
|
||||
Subject: Initialize User
|
||||
Greeting: Hello {{.DisplayName}},
|
||||
Text: This user was created. Use the username {{.PreferredLoginName}} to login. Please click the button below to finish the initialization process. (Code {{.Code}}) If you didn't ask for this mail, please ignore it.
|
||||
ButtonText: Finish initialization
|
||||
PasswordReset:
|
||||
Title: Reset password
|
||||
PreHeader: Reset password
|
||||
Subject: Reset password
|
||||
Greeting: Hello {{.DisplayName}},
|
||||
Text: We received a password reset request. Please use the button below to reset your password. (Code {{.Code}}) If you didn't ask for this mail, please ignore it.
|
||||
ButtonText: Reset password
|
||||
VerifyEmail:
|
||||
Title: Verify email
|
||||
PreHeader: Verify email
|
||||
Subject: Verify email
|
||||
Greeting: Hello {{.DisplayName}},
|
||||
Text: A new email has been added. Please use the button below to verify your email. (Code {{.Code}}) If you didn't add a new email, please ignore this email.
|
||||
ButtonText: Verify email
|
||||
VerifyPhone:
|
||||
Title: Verify phone
|
||||
PreHeader: Verify phone
|
||||
Subject: Verify phone
|
||||
Greeting: Hello {{.DisplayName}},
|
||||
Text: A new phone number has been added. Please use the following code to verify it {{.Code}}
|
||||
ButtonText: Verify phone
|
||||
VerifyEmailOTP:
|
||||
Title: Verify One-Time Password
|
||||
PreHeader: Verify One-Time Password
|
||||
Subject: Verify One-Time Password
|
||||
Greeting: Hello {{.DisplayName}},
|
||||
Text: Please use the one-time password {{.OTP}} to authenticate within the next five minutes or click the "Authenticate" button.
|
||||
ButtonText: Authenticate
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} is your one-time password for {{ .Domain }}. Use it within the next {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: Domain has been claimed
|
||||
PreHeader: Change email / username
|
||||
Subject: Domain has been claimed
|
||||
Greeting: Hello {{.DisplayName}},
|
||||
Text: The domain {{.Domain}} has been claimed by an organization. Your current user {{.Username}} is not part of this organization. Therefore you'll have to change your email when you login. We have created a temporary username ({{.TempUsername}}) for this login.
|
||||
ButtonText: Login
|
||||
PasswordlessRegistration:
|
||||
Title: Add Passwordless Login
|
||||
PreHeader: Add Passwordless Login
|
||||
Subject: Add Passwordless Login
|
||||
Greeting: Hello {{.DisplayName}},
|
||||
Text: We received a request to add a token for passwordless login. Please use the button below to add your token or device for passwordless login.
|
||||
ButtonText: Add Passwordless Login
|
||||
PasswordChange:
|
||||
Title: Password of user has changed
|
||||
PreHeader: Change password
|
||||
Subject: Password of user has changed
|
||||
Greeting: Hello {{.DisplayName}},
|
||||
Text: The password of your user has changed. If this change was not done by you, please be advised to immediately reset your password.
|
||||
ButtonText: Login
|
||||
InviteUser:
|
||||
Title: Invitation to {{.ApplicationName}}
|
||||
PreHeader: Invitation to {{.ApplicationName}}
|
||||
Subject: Invitation to {{.ApplicationName}}
|
||||
Greeting: Hello {{.DisplayName}},
|
||||
Text: Your user has been invited to {{.ApplicationName}}. Please click the button below to finish the invite process. If you didn't ask for this mail, please ignore it.
|
||||
ButtonText: Accept invite
|
68
apps/api/internal/notification/static/i18n/es.yaml
Normal file
68
apps/api/internal/notification/static/i18n/es.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: ZITADEL - Inicializar usuario
|
||||
PreHeader: Inicializar usuario
|
||||
Subject: Inicializar usuario
|
||||
Greeting: Hola {{.DisplayName}},
|
||||
Text: Este usuario fue creado en ZITADEL. Utiliza el nombre de usuario {{.PreferredLoginName}} para iniciar sesión. Por favor, haz clic en el botón más abajo para finalizar el proceso de inicialización. (Código {{.Code}}) Si no solicitaste este correo electrónico, por favor ignóralo.
|
||||
ButtonText: Finalizar inicialización
|
||||
PasswordReset:
|
||||
Title: ZITADEL - Restablecer contraseña
|
||||
PreHeader: Restablecer contraseña
|
||||
Subject: Restablecer contraseña
|
||||
Greeting: Hola {{.DisplayName}},
|
||||
Text: Hemos recibido una solicitud de restablecimiento de contraseña. Por favor, usa el botón más abajo para restablecer tu contraseña. (Código {{.Code}}) Si no solicitaste este correo electrónico, por favor ignóralo.
|
||||
ButtonText: Restablecer contraseña
|
||||
VerifyEmail:
|
||||
Title: ZITADEL - Verificar correo electrónico
|
||||
PreHeader: Verificar correo electrónico
|
||||
Subject: Verificar correo electrónico
|
||||
Greeting: Hola {{.DisplayName}},
|
||||
Text: Se ha añadido una nueva dirección de correo electrónico. Por favor, usa el botón más abajo para verificar tu correo electrónico. (Código {{.Code}}) Si no añadiste una nueva dirección de correo electrónico, por favor ignora este correo.
|
||||
ButtonText: Verificar correo electrónico
|
||||
VerifyPhone:
|
||||
Title: ZITADEL - Verificar teléfono
|
||||
PreHeader: Verificar teléfono
|
||||
Subject: Verificar teléfono
|
||||
Greeting: Hola {{.DisplayName}},
|
||||
Text: Se ha añadido un nuevo número de teléfono. Por favor, usa el siguiente código para verificarlo {{.Code}}
|
||||
ButtonText: Verificar teléfono
|
||||
VerifyEmailOTP:
|
||||
Title: ZITADEL - Verifica la contraseña de un solo uso
|
||||
PreHeader: Verifica la contraseña de un solo uso
|
||||
Subject: Verifica la contraseña de un solo uso
|
||||
Greeting: Hola {{.DisplayName}},
|
||||
Text: Por favor, utiliza el botón 'Autenticar' o copia la contraseña de un solo uso {{.OTP}} y pégala en la pantalla de autenticación para autenticarte en ZITADEL en los próximos cinco minutos.
|
||||
ButtonText: Autenticar
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} es su contraseña de un solo uso para {{ .Domain }}. Úselo dentro de los próximos {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: ZITADEL - Se ha reclamado un dominio
|
||||
PreHeader: Cambiar dirección de correo electrónico / nombre de usuario
|
||||
Subject: Se ha reclamado un dominio
|
||||
Greeting: Hola {{.DisplayName}},
|
||||
Text: El dominio {{.Domain}} ha sido reclamado por una organización. Tu usuario actual {{.Username}} no es parte de esta organización. Por tanto tendrás que cambiar tu dirección de correo electrónico cuando inicies sesión. Hemos creado un nombre de usuario temporal ({{.TempUsername}}) para este inicio de sesión.
|
||||
ButtonText: Iniciar sesión
|
||||
PasswordlessRegistration:
|
||||
Title: ZITADEL - Añadir inicio de sesión sin contraseña
|
||||
PreHeader: Añadir inicio de sesión sin contraseña
|
||||
Subject: Añadir inicio de sesión sin contraseña
|
||||
Greeting: Hola {{.DisplayName}},
|
||||
Text: Hemos recibido una solicitud para añadir un token para iniciar sesión sin contraseña. Por favor, usa el botón más abajo para añadir tu token o dispositivo para un inicio de sesión sin contraseña.
|
||||
ButtonText: Añadir inicio de sesión sin contraseña
|
||||
PasswordChange:
|
||||
Title: ZITADEL - La contraseña de usuario ha sido cambiada
|
||||
PreHeader: Cambiar contraseña
|
||||
Subject: La contraseña de usuario ha sido cambiada
|
||||
Greeting: Hola {{.DisplayName}},
|
||||
Text: La contraseña de tu usuario ha sido cambiada, si este cambio no fue hecho por ti, por favor proceder a restablecer inmediatamente tu contraseña.
|
||||
ButtonText: Iniciar sesión
|
||||
InviteUser:
|
||||
Title: Invitación a {{.ApplicationName}}
|
||||
PreHeader: Invitación a {{.ApplicationName}}
|
||||
Subject: Invitación a {{.ApplicationName}}
|
||||
Greeting: Hola {{.DisplayName}},
|
||||
Text: Tu usuario ha sido invitado a {{.ApplicationName}}. Haz clic en el botón de abajo para finalizar el proceso de invitación. Si no solicitaste este correo electrónico, por favor ignóralo.
|
||||
ButtonText: Aceptar invitación
|
68
apps/api/internal/notification/static/i18n/fr.yaml
Normal file
68
apps/api/internal/notification/static/i18n/fr.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: ZITADEL - Initialiser l'utilisateur
|
||||
PreHeader: Initialiser l'utilisateur
|
||||
Subject: Initialiser l'utilisateur
|
||||
Greeting: Bonjour {{.DisplayName}},
|
||||
Text: Cet utilisateur a été créé dans ZITADEL. Utilisez le nom d'utilisateur {{.PreferredLoginName}} pour vous connecter. Veuillez cliquer sur le bouton ci-dessous pour terminer le processus d'initialisation. (Code {{.Code}}) Si vous n'avez pas demandé ce courrier, veuillez l'ignorer.
|
||||
ButtonText: Terminer l'initialisation
|
||||
PasswordReset:
|
||||
Title: ZITADEL - Réinitialiser le mot de passe
|
||||
PreHeader: Réinitialiser le mot de passe
|
||||
Subject: Réinitialiser le mot de passe
|
||||
Greeting: Bonjour {{.DisplayName}},
|
||||
Text: Nous avons reçu une demande de réinitialisation du mot de passe. Veuillez utiliser le bouton ci-dessous pour réinitialiser votre mot de passe. (Code {{.Code}}) Si vous n'avez pas demandé cet e-mail, veuillez l'ignorer.
|
||||
ButtonText: Réinitialiser le mot de passe
|
||||
VerifyEmail:
|
||||
Title: ZITADEL - Vérifier l'email
|
||||
PreHeader: Vérifier l'email
|
||||
Subject: Vérifier l'email
|
||||
Greeting: Bonjour {{.DisplayName}},
|
||||
Text: Un nouveau courriel a été ajouté. Veuillez utiliser le bouton ci-dessous pour vérifier votre e-mail. (Code {{.Code}}) Si vous n'avez pas ajouté de nouvelle adresse e-mail, veuillez ignorer cet e-mail.
|
||||
ButtonText: Vérifier l'email
|
||||
VerifyPhone:
|
||||
Title: ZITADEL - Vérifier le téléphone
|
||||
PreHeader: Vérifier le téléphone
|
||||
Subject: Vérifier le téléphone
|
||||
Greeting: Bonjour {{.DisplayName}},
|
||||
Text: Un nouveau numéro de téléphone a été ajouté. Veuillez utiliser le code suivant pour le vérifier {{.Code}}
|
||||
ButtonText: Vérifier le téléphone
|
||||
VerifyEmailOTP:
|
||||
Title: ZITADEL - Vérifier le mot de passe à usage unique
|
||||
PreHeader: Vérifier le mot de passe à usage unique
|
||||
Subject: Vérifier le mot de passe à usage unique
|
||||
Greeting: Bonjour {{.DisplayName}},
|
||||
Text: Utilisez le bouton 'Authentifier' ou copiez le mot de passe à usage unique {{.OTP}} et collez-le à l'écran d'authentification pour vous authentifier sur ZITADEL dans les cinq prochaines minutes.
|
||||
ButtonText: Authentifier
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} est votre mot de passe à usage unique pour {{ .Domain }}. Utilisez-le dans les prochaines {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: ZITADEL - Le domaine a été réclamé
|
||||
PreHeader: Modifier l'email / le nom d'utilisateur
|
||||
Subject: Le domaine a été réclamé
|
||||
Greeting: Bonjour {{.DisplayName}},
|
||||
Text: Le domaine {{.Domain}} a été revendiqué par une organisation. Votre utilisateur actuel {{.Username}} ne fait pas partie de cette organisation. Par conséquent, vous devrez changer votre adresse électronique lors de votre connexion. Nous avons créé un nom d'utilisateur temporaire ({{.TempUsername}}) pour cette connexion.
|
||||
ButtonText: Connexion
|
||||
PasswordlessRegistration:
|
||||
Title: ZITADEL - Ajouter une connexion sans mot de passe
|
||||
PreHeader: Ajouter une connexion sans mot de passe
|
||||
Subject: Ajouter une connexion sans mot de passe
|
||||
Greeting: Bonjour {{.DisplayName}},
|
||||
Text: Nous avons reçu une demande d'ajout d'un jeton pour la connexion sans mot de passe. Veuillez utiliser le bouton ci-dessous pour ajouter votre jeton ou dispositif pour la connexion sans mot de passe.
|
||||
ButtonText: Ajouter une connexion sans mot de passe
|
||||
PasswordChange:
|
||||
Title: ZITADEL - Le mot de passe de l'utilisateur a changé
|
||||
PreHeader: Modifier le mot de passe
|
||||
Subject: Le mot de passe de l'utilisateur a changé
|
||||
Greeting: Bonjour {{.DisplayName}},
|
||||
Text: Le mot de passe de votre utilisateur a changé, si ce changement n'a pas été fait par vous, nous vous conseillons de réinitialiser immédiatement votre mot de passe.
|
||||
ButtonText: Login
|
||||
InviteUser:
|
||||
Title: Invitation à {{.ApplicationName}}
|
||||
PreHeader: Invitation à {{.ApplicationName}}
|
||||
Subject: Invitation à {{.ApplicationName}}
|
||||
Greeting: Bonjour {{.DisplayName}},
|
||||
Text: Votre utilisateur a été invité à {{.ApplicationName}}. Veuillez cliquer sur le bouton ci-dessous pour terminer le processus d'invitation. Si vous n'avez pas demandé cet e-mail, veuillez l'ignorer.
|
||||
ButtonText: Accepter l'invitation
|
78
apps/api/internal/notification/static/i18n/hu.yaml
Normal file
78
apps/api/internal/notification/static/i18n/hu.yaml
Normal file
@@ -0,0 +1,78 @@
|
||||
InitCode:
|
||||
Title: Felhasználó Inicializálása
|
||||
PreHeader: Felhasználó Inicializálása
|
||||
Subject: Felhasználó Inicializálása
|
||||
Greeting: "Kedves {{.DisplayName}},"
|
||||
Text: "Ez a felhasználó létre lett hozva. Használd a {{.PreferredLoginName}} felhasználónevet a bejelentkezéshez. Kérlek, kattints az alábbi gombra az inicializálási folyamat befejezéséhez. (Kód: {{.Code}}) Ha nem te kérted ezt az e-mailt, kérlek, hagyd figyelmen kívül."
|
||||
ButtonText: Inicializálás befejezése
|
||||
|
||||
PasswordReset:
|
||||
Title: Jelszó visszaállítása
|
||||
PreHeader: Jelszó visszaállítása
|
||||
Subject: Jelszó visszaállítása
|
||||
Greeting: "Kedves {{.DisplayName}},"
|
||||
Text: "Jelszó-visszaállítási kérelmet kaptunk. Kérjük, használd az alábbi gombot a jelszavad visszaállításához. (Kód: {{.Code}}) Ha nem kérted ezt az e-mailt, kérjük, hagyd figyelmen kívül."
|
||||
ButtonText: Jelszó visszaállítása
|
||||
|
||||
VerifyEmail:
|
||||
Title: E-mail megerősítése
|
||||
PreHeader: E-mail megerősítése
|
||||
Subject: E-mail megerősítése
|
||||
Greeting: "Kedves {{.DisplayName}},"
|
||||
Text: "Egy új e-mail hozzá lett adva. Kérjük, használd az alábbi gombot az e-mail címed megerősítéséhez. (Kód: {{.Code}}) Ha nem adtál hozzá új e-mail címet, kérjük, hagyd figyelmen kívül ezt az e-mailt."
|
||||
ButtonText: E-mail megerősítése
|
||||
|
||||
VerifyPhone:
|
||||
Title: Telefon megerősítése
|
||||
PreHeader: Telefon megerősítése
|
||||
Subject: Telefonszám ellenőrzése
|
||||
Greeting: "Kedves {{.DisplayName}},"
|
||||
Text: "Új telefonszám került hozzáadásra. Kérlek, használd a következő kódot az ellenőrzéshez: {{.Code}}"
|
||||
ButtonText: Telefonszám ellenőrzése
|
||||
|
||||
VerifyEmailOTP:
|
||||
Title: Egyszer használatos jelszó ellenőrzése
|
||||
PreHeader: Egyszer használatos jelszó ellenőrzése
|
||||
Subject: Egyszer használatos jelszó ellenőrzése
|
||||
Greeting: "Kedves {{.DisplayName}},"
|
||||
Text: "Kérlek, használd az egyszer használatos jelszót {{.OTP}} azonosításhoz az elkövetkező öt percben, vagy kattints az 'Azonosítás' gombra."
|
||||
ButtonText: Azonosítás
|
||||
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} Egyszerhasználatos jelszavad a(z) {{ .Domain }} oldalhoz. Eddig lesz érvényes: {{.Expiry}}.
|
||||
|
||||
{{.Domain}} #{{.OTP}}
|
||||
|
||||
DomainClaimed:
|
||||
Title: A domaint már igényelték
|
||||
PreHeader: E-mail / felhasználónév megváltoztatása
|
||||
Subject: A domaint már igényelték
|
||||
Greeting: "Kedves {{.DisplayName}},"
|
||||
Text: "A {{.Domain}} domaint egy szervezet igényelte. A jelenlegi felhasználód, {{.Username}}, nem része ennek a szervezetnek. Ezért meg kell változtatnod az e-mailed, amikor bejelentkezel. Létrehoztunk egy ideiglenes felhasználónevet ({{.TempUsername}}) ehhez a bejelentkezéshez."
|
||||
ButtonText: Bejelentkezés
|
||||
|
||||
PasswordlessRegistration:
|
||||
Title: Jelszó nélküli bejelentkezés hozzáadása
|
||||
PreHeader: Jelszó nélküli bejelentkezés hozzáadása
|
||||
Subject: Jelszó nélküli bejelentkezés hozzáadása
|
||||
Greeting: "Kedves {{.DisplayName}},"
|
||||
Text: "Kaptunk egy kérést a jelszó nélküli bejelentkezéshez szükséges token hozzáadására. Kérlek, használd az alábbi gombot a token vagy eszköz hozzáadásához a jelszó nélküli bejelentkezéshez."
|
||||
ButtonText: Jelszó Nélküli Bejelentkezés Hozzáadása
|
||||
|
||||
PasswordChange:
|
||||
Title: A felhasználó jelszava megváltozott
|
||||
PreHeader: Jelszó megváltoztatása
|
||||
Subject: A felhasználó jelszava megváltozott
|
||||
Greeting: "Kedves {{.DisplayName}},"
|
||||
Text: "A felhasználó jelszava megváltozott. Ha ezt a változtatást nem te végezted, javasoljuk, hogy azonnal állítsd vissza a jelszavad."
|
||||
ButtonText: Bejelentkezés
|
||||
|
||||
InviteUser:
|
||||
Title: "Meghívás a(z) {{.ApplicationName}}-ra"
|
||||
PreHeader: "Meghívás a(z) {{.ApplicationName}} szolgáltatásba"
|
||||
Subject: "Meghívás a(z) {{.ApplicationName}} szolgáltatásba"
|
||||
Greeting: "Kedves {{.DisplayName}},"
|
||||
Text: "Felhasználódat meghívták a(z) {{.ApplicationName}} szolgáltatásba. Kérlek, kattints az alábbi gombra a meghívás folyamatának befejezéséhez. Ha nem kérted ezt az e-mailt, kérlek hagyd figyelmen kívül."
|
||||
ButtonText: Meghívás elfogadása
|
||||
|
68
apps/api/internal/notification/static/i18n/id.yaml
Normal file
68
apps/api/internal/notification/static/i18n/id.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: Inisialisasi Pengguna
|
||||
PreHeader: Inisialisasi Pengguna
|
||||
Subject: Inisialisasi Pengguna
|
||||
Greeting: 'Halo {{.DisplayName}},'
|
||||
Text: Pengguna ini telah dibuat. {{.PreferredLoginName}} untuk masuk. {{.Code}}) Jika Anda tidak meminta email ini, abaikan saja.
|
||||
ButtonText: Selesaikan inisialisasi
|
||||
PasswordReset:
|
||||
Title: Setel ulang kata sandi
|
||||
PreHeader: Setel ulang kata sandi
|
||||
Subject: Setel ulang kata sandi
|
||||
Greeting: 'Halo {{.DisplayName}},'
|
||||
Text: Kami menerima permintaan pengaturan ulang kata sandi. {{.Code}}) Jika Anda tidak meminta email ini, abaikan saja.
|
||||
ButtonText: Setel ulang kata sandi
|
||||
VerifyEmail:
|
||||
Title: Verifikasi email
|
||||
PreHeader: Verifikasi email
|
||||
Subject: Verifikasi email
|
||||
Greeting: 'Halo {{.DisplayName}},'
|
||||
Text: Email baru telah ditambahkan. {{.Code}}) Jika Anda tidak menambahkan email baru, harap abaikan email ini.
|
||||
ButtonText: Verifikasi email
|
||||
VerifyPhone:
|
||||
Title: Verifikasi telepon
|
||||
PreHeader: Verifikasi telepon
|
||||
Subject: Verifikasi telepon
|
||||
Greeting: 'Halo {{.DisplayName}},'
|
||||
Text: 'Nomor telepon baru telah ditambahkan. {{.Code}}'
|
||||
ButtonText: Verifikasi telepon
|
||||
VerifyEmailOTP:
|
||||
Title: Verifikasi Kata Sandi Sekali Pakai
|
||||
PreHeader: Verifikasi Kata Sandi Sekali Pakai
|
||||
Subject: Verifikasi Kata Sandi Sekali Pakai
|
||||
Greeting: 'Halo {{.DisplayName}},'
|
||||
Text: Silakan gunakan kata sandi satu kali {{.OTP}} untuk mengautentikasi dalam lima menit berikutnya atau klik tombol "Otentikasi".
|
||||
ButtonText: Otentikasi
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} adalah kata sandi satu kali Anda untuk {{ .Domain }}. {{.Expiry}}.
|
||||
|
||||
{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: Domain telah diklaim
|
||||
PreHeader: Ganti email/nama pengguna
|
||||
Subject: Domain telah diklaim
|
||||
Greeting: 'Halo {{.DisplayName}},'
|
||||
Text: Domainnya {{.Domain}} telah diklaim oleh suatu organisasi. {{.Username}} bukan bagian dari organisasi ini. {{.TempUsername}}) untuk login ini.
|
||||
ButtonText: Login
|
||||
PasswordlessRegistration:
|
||||
Title: Tambahkan Login Tanpa Kata Sandi
|
||||
PreHeader: Tambahkan Login Tanpa Kata Sandi
|
||||
Subject: Tambahkan Login Tanpa Kata Sandi
|
||||
Greeting: 'Halo {{.DisplayName}},'
|
||||
Text: Kami menerima permintaan untuk menambahkan token untuk login tanpa kata sandi.
|
||||
ButtonText: Tambahkan Login Tanpa Kata Sandi
|
||||
PasswordChange:
|
||||
Title: Kata sandi pengguna telah berubah
|
||||
PreHeader: Ubah kata sandi
|
||||
Subject: Kata sandi pengguna telah berubah
|
||||
Greeting: 'Halo {{.DisplayName}},'
|
||||
Text: 'Kata sandi pengguna Anda telah berubah. '
|
||||
ButtonText: Login
|
||||
InviteUser:
|
||||
Title: Undangan ke {{.ApplicationName}}
|
||||
PreHeader: Undangan ke {{.ApplicationName}}
|
||||
Subject: Undangan ke {{.ApplicationName}}
|
||||
Greeting: 'Halo {{.DisplayName}},'
|
||||
Text: Pengguna Anda telah diundang ke {{.ApplicationName}}. Silakan klik tombol di bawah ini untuk menyelesaikan proses undangan. Jika Anda tidak meminta email ini, harap abaikan.
|
||||
ButtonText: Terima undangan
|
68
apps/api/internal/notification/static/i18n/it.yaml
Normal file
68
apps/api/internal/notification/static/i18n/it.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: ZITADEL - Inizializzare l'utente
|
||||
PreHeader: Inizializzare l'utente
|
||||
Subject: Inizializzare l'utente
|
||||
Greeting: 'Ciao {{.DisplayName}},'
|
||||
Text: Questo utente è stato creato in ZITADEL. Usa il nome utente {{.PreferredLoginName}} per accedere. Per favore, clicca il pulsante per finire il processo di inizializzazione. (Codice {{.Codice}}) Se non hai richiesto questa mail, per favore ignorala.
|
||||
ButtonText: Termina
|
||||
PasswordReset:
|
||||
Title: ZITADEL - Ripristina la password
|
||||
PreHeader: Ripristina la password
|
||||
Subject: Ripristina la password
|
||||
Greeting: 'Ciao {{.DisplayName}},'
|
||||
Text: Abbiamo ricevuto una richiesta di reimpostazione della password. Per favore clicca il pulsante per resettare la tua password. (Codice {{.Codice}}) Se non hai richiesto questa mail, ignorala.
|
||||
ButtonText: Ripristina
|
||||
VerifyEmail:
|
||||
Title: ZITADEL - Verifica l'e-mail
|
||||
PreHeader: Verifica l'e-mail
|
||||
Subject: Verifica l'e-mail
|
||||
Greeting: 'Ciao {{.DisplayName}},'
|
||||
Text: È stata aggiunta una nuova email. Per favore fai clic sul pulsante per verificare la tua mail. (Codice {{.Codice}}) Se non hai aggiunto una nuova email, ignora questa email.
|
||||
ButtonText: Verifica
|
||||
VerifyPhone:
|
||||
Title: ZITADEL - Verifica il telefono
|
||||
PreHeader: Verifica il telefono
|
||||
Subject: Verifica il telefono
|
||||
Greeting: 'Ciao {{.DisplayName}},'
|
||||
Text: È stato aggiunto un nuovo numero di telefono. Usa il seguente codice per verificarlo {{.Code}}
|
||||
ButtonText: Verifica
|
||||
VerifyEmailOTP:
|
||||
Title: ZITADEL - Verifica la password monouso
|
||||
PreHeader: Verifica la password monouso
|
||||
Subject: Verifica la password monouso
|
||||
Greeting: Ciao {{.DisplayName}},
|
||||
Text: Per favore, utilizza il pulsante 'Autentica' o copia la password monouso {{.OTP}} e incollala nella schermata di autenticazione per autenticarti a ZITADEL entro i prossimi cinque minuti.
|
||||
ButtonText: Autentica
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} è la tua password monouso per {{ .Domain }}. Usalo entro il prossimo {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: ZITADEL - Il dominio è stato rivendicato
|
||||
PreHeader: Cambiare email / nome utente
|
||||
Subject: Il dominio è stato rivendicato
|
||||
Greeting: 'Ciao {{.DisplayName}},'
|
||||
Text: Il dominio {{.Domain}} è stato rivendicato da un'organizzazione. Il tuo attuale utente {{.Username}} non fa parte di questa organizzazione. Perciò dovrai cambiare la tua email quando farai il login. Abbiamo creato un nome utente temporaneo ({{.TempUsername}}) per questo login.
|
||||
ButtonText: Accedi
|
||||
PasswordlessRegistration:
|
||||
Title: ZITADEL - Aggiungere autenticazione passwordless
|
||||
PreHeader: Aggiungi l'autenticazione passwordless
|
||||
Subject: Aggiungi l'autenticazione passwordless
|
||||
Greeting: 'Ciao {{.DisplayName}},'
|
||||
Text: Abbiamo ricevuto una richiesta per aggiungere l'autenticazione passwordless. Usa il pulsante qui sotto per aggiungere il tuo token o dispositivo per il login senza password.
|
||||
ButtonText: Attiva passwordless
|
||||
PasswordChange:
|
||||
Title: ZITADEL - La password dell'utente è stata modificata
|
||||
PreHeader: Modifica della password
|
||||
Subject: La password dell'utente è stata modificata
|
||||
Greeting: Ciao {{.DisplayName}},
|
||||
Text: La password del vostro utente è cambiata; se questa modifica non è stata fatta da voi, vi consigliamo di reimpostare immediatamente la vostra password.
|
||||
ButtonText: Login
|
||||
InviteUser:
|
||||
Title: Invito a {{.ApplicationName}}
|
||||
PreHeader: Invito a {{.ApplicationName}}
|
||||
Subject: Invito a {{.ApplicationName}}
|
||||
Greeting: 'Ciao {{.DisplayName}},'
|
||||
Text: Il tuo utente è stato invitato a {{.ApplicationName}}. Clicca sul pulsante qui sotto per completare il processo di invito. Se non hai richiesto questa email, ignorala.
|
||||
ButtonText: Accetta invito
|
68
apps/api/internal/notification/static/i18n/ja.yaml
Normal file
68
apps/api/internal/notification/static/i18n/ja.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: ZITADEL - ユーザーの初期セットアップ
|
||||
PreHeader: ユーザーの初期セットアップ
|
||||
Subject: ユーザーの初期セットアップ
|
||||
Greeting: こんにちは {{.DisplayName}} さん、
|
||||
Text: このユーザーはZITADELで作成されました。ユーザー名 {{.PreferredLoginName}} を使用してログインします。以下のボタンから、初期セットアップを完了してください。(コード {{.Code}})このメールの受信を希望していない場合は、無視してください。
|
||||
ButtonText: 初期セットアップを完了する
|
||||
PasswordReset:
|
||||
Title: ZITADEL - パスワードをリセットします
|
||||
PreHeader: パスワードの再設定
|
||||
Subject: パスワードの再設定
|
||||
Greeting: こんにちは {{.DisplayName}} さん、
|
||||
Text: パスワードリセットのリクエストを受け取りました。以下のボタンから、パスワードをリセットしてください。(コード {{.Code}})このメールの受信を希望していない場合は、無視してください。
|
||||
ButtonText: パスワードを再設定
|
||||
VerifyEmail:
|
||||
Title: ZITADEL - メールアドレスの認証
|
||||
PreHeader: メールアドレスの認証
|
||||
Subject: メールアドレスの認証
|
||||
Greeting: こんにちは {{.DisplayName}} さん、
|
||||
Text: 新しいメールアドレスが追加されました。以下のボタンから、メールアドレスを認証してください。(コード {{.Code}})新しいメールアドレスを追加していない場合は、このメールを無視してください。
|
||||
ButtonText: メールアドレスを認証
|
||||
VerifyPhone:
|
||||
Title: ZITADEL - 電話番号の認証
|
||||
PreHeader: 電話番号の認証
|
||||
Subject: 電話番号の認証
|
||||
Greeting: こんにちは {{.DisplayName}} さん、
|
||||
Text: 新しい電話番号が追加されました。次のコードを使用してを認証してください {{.Code}}
|
||||
ButtonText: 電話番号を認証
|
||||
VerifyEmailOTP:
|
||||
Title: ZITADEL - ワンタイムパスワードを確認する
|
||||
PreHeader: ワンタイムパスワードを確認する
|
||||
Subject: ワンタイムパスワードを確認する
|
||||
Greeting: こんにちは、{{.DisplayName}}さん
|
||||
Text: 認証ボタンを使用するか、ワンタイムパスワード {{.OTP}} をコピーして認証画面に貼り付け、次の5分以内にZITADELで認証してください。
|
||||
ButtonText: 認証
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} は、{{ .Domain }} のワンタイムパスワードです。次の {{.Expiry}} 以内に使用してください。
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: ZITADEL - ドメインの登録
|
||||
PreHeader: メールアドレス・ユーザー名の変更
|
||||
Subject: ドメインの登録
|
||||
Greeting: こんにちは {{.DisplayName}} さん、
|
||||
Text: ドメイン {{.Domain}} が組織によって登録されました。現在のユーザー {{.Username}} はこの組織のメンバーでないため、ログイン時にメールアドレスを変更する必要があります。一時的なユーザー名({{.TempUsername}})を使用してログインし、設定を進めてください。
|
||||
ButtonText: ログイン
|
||||
PasswordlessRegistration:
|
||||
Title: ZITADEL - パスワードレスログインの追加
|
||||
PreHeader: パスワードレスログインの追加
|
||||
Subject: パスワードレスログインの追加
|
||||
Greeting: こんにちは {{.DisplayName}} さん、
|
||||
Text: パスワードレスログイン用のトークンを追加するリクエストを受け取りました。以下のボタンから、パスワードレスログイン用のトークンやデバイスを追加してください。
|
||||
ButtonText: パスワードレスログインを追加
|
||||
PasswordChange:
|
||||
Title: ZITADEL - ユーザーのパスワードが変更されました
|
||||
PreHeader: パスワードの変更
|
||||
Subject: ユーザーのパスワードが変更されました
|
||||
Greeting: こんにちは {{.DisplayName}} さん、
|
||||
Text: ユーザーのパスワードが変更されました。この変更があなたによって行われなかった場合は、すぐにパスワードをリセットすることをお勧めします。
|
||||
ButtonText: ログイン
|
||||
InviteUser:
|
||||
Title: '{{.ApplicationName}}への招待'
|
||||
PreHeader: '{{.ApplicationName}}への招待'
|
||||
Subject: '{{.ApplicationName}}への招待'
|
||||
Greeting: こんにちは {{.DisplayName}} さん、
|
||||
Text: あなたのユーザーは{{.ApplicationName}}に招待されました。下のボタンをクリックして、招待プロセスを完了してください。このメールをリクエストしていない場合は、無視してください。
|
||||
ButtonText: 招待を受け入れる
|
68
apps/api/internal/notification/static/i18n/ko.yaml
Normal file
68
apps/api/internal/notification/static/i18n/ko.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: 사용자 초기화
|
||||
PreHeader: 사용자 초기화
|
||||
Subject: 사용자 초기화
|
||||
Greeting: 안녕하세요, {{.DisplayName}}님,
|
||||
Text: 사용자가 생성되었습니다. 로그인하려면 사용자 이름 {{.PreferredLoginName}}을 사용하세요. 초기화 프로세스를 완료하려면 아래 버튼을 클릭하세요. (코드 {{.Code}}) 이 메일을 요청하지 않으셨다면 무시하셔도 됩니다.
|
||||
ButtonText: 초기화 완료
|
||||
PasswordReset:
|
||||
Title: 비밀번호 재설정
|
||||
PreHeader: 비밀번호 재설정
|
||||
Subject: 비밀번호 재설정
|
||||
Greeting: 안녕하세요, {{.DisplayName}}님,
|
||||
Text: 비밀번호 재설정 요청을 받았습니다. 비밀번호를 재설정하려면 아래 버튼을 사용하세요. (코드 {{.Code}}) 이 메일을 요청하지 않으셨다면 무시하셔도 됩니다.
|
||||
ButtonText: 비밀번호 재설정
|
||||
VerifyEmail:
|
||||
Title: 이메일 인증
|
||||
PreHeader: 이메일 인증
|
||||
Subject: 이메일 인증
|
||||
Greeting: 안녕하세요, {{.DisplayName}}님,
|
||||
Text: 새 이메일이 추가되었습니다. 이메일을 인증하려면 아래 버튼을 사용하세요. (코드 {{.Code}}) 새로운 이메일을 추가하지 않으셨다면 이 메일을 무시하세요.
|
||||
ButtonText: 이메일 인증
|
||||
VerifyPhone:
|
||||
Title: 전화번호 인증
|
||||
PreHeader: 전화번호 인증
|
||||
Subject: 전화번호 인증
|
||||
Greeting: 안녕하세요, {{.DisplayName}}님,
|
||||
Text: 새 전화번호가 추가되었습니다. 다음 코드를 사용하여 인증하세요 {{.Code}}
|
||||
ButtonText: 전화번호 인증
|
||||
VerifyEmailOTP:
|
||||
Title: 일회용 비밀번호 인증
|
||||
PreHeader: 일회용 비밀번호 인증
|
||||
Subject: 일회용 비밀번호 인증
|
||||
Greeting: 안녕하세요, {{.DisplayName}}님,
|
||||
Text: 인증하려면 다음 일회용 비밀번호 {{.OTP}}를 5분 이내에 사용하거나 "인증" 버튼을 클릭하세요.
|
||||
ButtonText: 인증
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}}는 {{ .Domain }}의 일회용 비밀번호입니다. 다음 {{.Expiry}} 이내에 사용하세요.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: 조직에서 도메인을 소유하게 되었습니다
|
||||
PreHeader: 이메일 / 사용자 이름 변경
|
||||
Subject: 조직에서 도메인을 소유하게 되었습니다
|
||||
Greeting: 안녕하세요, {{.DisplayName}}님,
|
||||
Text: 도메인 {{.Domain}} 은(는) 조직에서 소유하게 되었습니다. 현재 사용자인 {{.Username}}은 이 조직의 구성원이 아닙니다. 따라서 로그인할 때 이메일을 변경해야 합니다. 이 로그인용으로 임시 사용자 이름 ({{.TempUsername}})을 생성했습니다.
|
||||
ButtonText: 로그인
|
||||
PasswordlessRegistration:
|
||||
Title: 비밀번호 없는 로그인 추가
|
||||
PreHeader: 비밀번호 없는 로그인 추가
|
||||
Subject: 비밀번호 없는 로그인 추가
|
||||
Greeting: 안녕하세요, {{.DisplayName}}님,
|
||||
Text: 비밀번호 없는 로그인을 위한 토큰 추가 요청을 받았습니다. 비밀번호 없는 로그인을 위해 토큰 또는 장치를 추가하려면 아래 버튼을 클릭하세요.
|
||||
ButtonText: 비밀번호 없는 로그인 추가
|
||||
PasswordChange:
|
||||
Title: 사용자 비밀번호 변경됨
|
||||
PreHeader: 비밀번호 변경
|
||||
Subject: 사용자 비밀번호 변경됨
|
||||
Greeting: 안녕하세요, {{.DisplayName}}님,
|
||||
Text: 사용자의 비밀번호가 변경되었습니다. 본인이 직접 변경하지 않으셨다면 즉시 비밀번호를 재설정하시기 바랍니다.
|
||||
ButtonText: 로그인
|
||||
InviteUser:
|
||||
Title: "{{.ApplicationName}} 초대"
|
||||
PreHeader: "{{.ApplicationName}} 초대"
|
||||
Subject: "{{.ApplicationName}} 초대"
|
||||
Greeting: 안녕하세요, {{.DisplayName}}님,
|
||||
Text: "{{.ApplicationName}}에 초대되었습니다. 초대 프로세스를 완료하려면 아래 버튼을 클릭하세요. 이 메일을 요청하지 않으셨다면 무시하셔도 됩니다."
|
||||
ButtonText: 초대 수락
|
68
apps/api/internal/notification/static/i18n/mk.yaml
Normal file
68
apps/api/internal/notification/static/i18n/mk.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: ZITADEL - Иницијализација на Корисник
|
||||
PreHeader: Иницијализација на Корисник
|
||||
Subject: Иницијализација на Корисник
|
||||
Greeting: Здраво {{.DisplayName}},
|
||||
Text: Овој корисник е креиран во ZITADEL. Користете го корисничкото име {{.PreferredLoginName}} за најава. Ве молиме кликнете на копчето подолу за да го завршите процесот на иницијализација. (Код {{.Code}}) Ако не сте барале/и оваа е-пошта, ве молиме игнорирајте ја.
|
||||
ButtonText: Заврши иницијализација
|
||||
PasswordReset:
|
||||
Title: ZITADEL - Ресетирање на лозинка
|
||||
PreHeader: Ресетирање на лозинка
|
||||
Subject: Ресетирање на лозинка
|
||||
Greeting: Здраво {{.DisplayName}},
|
||||
Text: Добивме барање за ресетирање на лозинката. Ве молиме користете го копчето подолу за да ја ресетирате вашата лозинка. (Код {{.Code}}) Ако не сте ја побарале ресетирање на лозинка, ве молиме игнорирајте ја оваа е-пошта.
|
||||
ButtonText: Ресетирај лозинка
|
||||
VerifyEmail:
|
||||
Title: ZITADEL - Верификација на е-пошта
|
||||
PreHeader: Верификација на е-пошта
|
||||
Subject: Верификација на е-пошта
|
||||
Greeting: Здраво {{.DisplayName}},
|
||||
Text: Додадена е нова е-пошта. Ве молиме користете го копчето подолу за да ја верифицирате вашата е-пошта. (Код {{.Code}}) Ако не сте додале нова е-пошта, ве молиме игнорирајте ја оваа е-пошта.
|
||||
ButtonText: Верифицирај е-пошта
|
||||
VerifyPhone:
|
||||
Title: ZITADEL - Верификација на телефонски број
|
||||
PreHeader: Верификација на телефонски број
|
||||
Subject: Верификација на телефонски број
|
||||
Greeting: Здраво {{.DisplayName}},
|
||||
Text: Додаден е нов телефонски број. Ве молиме користете го следниот код за да го верифицирате. {{.Code}}
|
||||
ButtonText: Верифицирај телефонски број
|
||||
VerifyEmailOTP:
|
||||
Title: ZITADEL - Потврди еднократна лозинка
|
||||
PreHeader: Потврди еднократна лозинка
|
||||
Subject: Потврди еднократна лозинка
|
||||
Greeting: Здраво {{.DisplayName}},
|
||||
Text: Ве молам, користи го копчето 'Автентицирај' или копирај ја еднократната лозинка {{.OTP}} и стави ја на екранот за автентикација за да се автентицираш на ZITADEL во следните пет минути.
|
||||
ButtonText: Автентицирај
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} е вашата еднократна лозинка за {{ .Domain }}. Користете го во следниот {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: ZITADEL - Доменот е преземен
|
||||
PreHeader: Промена на е-пошта / корисничко име
|
||||
Subject: Доменот е преземен
|
||||
Greeting: Здраво {{.DisplayName}},
|
||||
Text: Доменот {{.Domain}} е преземен од страна на организација. Вашиот моментален корисник {{.Username}} не е дел од оваа организација. Затоа, при најава, треба да го промените вашиот е-поштенска адреса. Направивме привремено корисничко име ({{.TempUsername}}) за оваа најава.
|
||||
ButtonText: Најави се
|
||||
PasswordlessRegistration:
|
||||
Title: ZITADEL - Додади Најава без Лозинка
|
||||
PreHeader: Додади Најава без Лозинка
|
||||
Subject: Додади Најава без Лозинка
|
||||
Greeting: Здраво {{.DisplayName}},
|
||||
Text: Добивме барање да додадеме токен за најава без лозинка. Ве молиме користете го копчето подолу за да додадете вашиот токен или уред за најава без лозинка.
|
||||
ButtonText: Додади Најава без Лозинка
|
||||
PasswordChange:
|
||||
Title: ZITADEL - Лозинката на корисникот е променета
|
||||
PreHeader: Промена на лозинка
|
||||
Subject: Лозинката на корисникот е променета
|
||||
Greeting: Здраво {{.DisplayName}},
|
||||
Text: Лозинката на вашиот корисник е променета. Ако оваа промена не е извршена од вас, ве молиме веднаш ресетирајте ја вашата лозинка.
|
||||
ButtonText: Најава
|
||||
InviteUser:
|
||||
Title: Покана за {{.ApplicationName}}
|
||||
PreHeader: Покана за {{.ApplicationName}}
|
||||
Subject: Покана за {{.ApplicationName}}
|
||||
Greeting: Здраво {{.DisplayName}},
|
||||
Text: Вашиот корисник е бил поканет за {{.ApplicationName}}. Ве молиме кликнете на копчето подолу за да го завршите процесот на покана. Ако не сте побарале овој мејл, ве молиме игнорирајте го.
|
||||
ButtonText: Прифати покана
|
68
apps/api/internal/notification/static/i18n/nl.yaml
Normal file
68
apps/api/internal/notification/static/i18n/nl.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: Gebruiker Initialiseren
|
||||
PreHeader: Gebruiker Initialiseren
|
||||
Subject: Gebruiker Initialiseren
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Deze gebruiker is aangemaakt. Gebruik de gebruikersnaam {{.PreferredLoginName}} om in te loggen. Klik op de knop hieronder om het initialisatieproces te voltooien. (Code {{.Code}}) Als u niet om deze mail heeft gevraagd, negeer deze dan.
|
||||
ButtonText: Voltooi initialisatie
|
||||
PasswordReset:
|
||||
Title: Wachtwoord resetten
|
||||
PreHeader: Wachtwoord resetten
|
||||
Subject: Wachtwoord resetten
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: We hebben een verzoek ontvangen om uw wachtwoord te resetten. Gebruik de knop hieronder om uw wachtwoord te resetten. (Code {{.Code}}) Als u niet om deze mail heeft gevraagd, negeer deze dan.
|
||||
ButtonText: Reset wachtwoord
|
||||
VerifyEmail:
|
||||
Title: Verifieer email
|
||||
PreHeader: Verifieer email
|
||||
Subject: Verifieer email
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Er is een nieuwe email toegevoegd. Gebruik de knop hieronder om uw email te verifiëren. (Code {{.Code}}) Als u geen nieuwe email heeft toegevoegd, negeer deze email dan.
|
||||
ButtonText: Verifieer email
|
||||
VerifyPhone:
|
||||
Title: Verifieer telefoon
|
||||
PreHeader: Verifieer telefoon
|
||||
Subject: Verifieer telefoon
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Er is een nieuw telefoonnummer toegevoegd. Gebruik de volgende code om het te verifiëren {{.Code}}
|
||||
ButtonText: Verifieer telefoon
|
||||
VerifyEmailOTP:
|
||||
Title: Verifieer One-Time Wachtwoord
|
||||
PreHeader: Verifieer One-Time Wachtwoord
|
||||
Subject: Verifieer One-Time Wachtwoord
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Gebruik het eenmalige wachtwoord {{.OTP}} om binnen de volgende vijf minuten te authenticeren of klik op de "Authenticeer" knop.
|
||||
ButtonText: Authenticeer
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} is uw eenmalige wachtwoord voor {{ .Domain }}. Gebruik het binnen de volgende {{.Expiry}} minuten.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: Domein is geclaimd
|
||||
PreHeader: Verander email / gebruikersnaam
|
||||
Subject: Domein is geclaimd
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Het domein {{.Domain}} is geclaimd door een organisatie. Uw huidige gebruiker {{.Username}} is geen onderdeel van deze organisatie. Daarom moet u uw email veranderen wanneer u inlogt. We hebben een tijdelijke gebruikersnaam ({{.TempUsername}}) voor deze login gecreëerd.
|
||||
ButtonText: Inloggen
|
||||
PasswordlessRegistration:
|
||||
Title: Voeg Wachtwoordloze Login Toe
|
||||
PreHeader: Voeg Wachtwoordloze Login Toe
|
||||
Subject: Voeg Wachtwoordloze Login Toe
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: We hebben een verzoek ontvangen om een token toe te voegen voor wachtwoordloze login. Gebruik de knop hieronder om uw token of apparaat toe te voegen voor wachtwoordloze login.
|
||||
ButtonText: Voeg Wachtwoordloze Login Toe
|
||||
PasswordChange:
|
||||
Title: Wachtwoord van gebruiker is veranderd
|
||||
PreHeader: Verander wachtwoord
|
||||
Subject: Wachtwoord van gebruiker is veranderd
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Het wachtwoord van uw gebruiker is veranderd. Als deze wijziging niet door u is gedaan, wordt u geadviseerd om direct uw wachtwoord te resetten.
|
||||
ButtonText: Inloggen
|
||||
InviteUser:
|
||||
Title: Uitnodiging voor {{.ApplicationName}}
|
||||
PreHeader: Uitnodiging voor {{.ApplicationName}}
|
||||
Subject: Uitnodiging voor {{.ApplicationName}}
|
||||
Greeting: Hallo {{.DisplayName}},
|
||||
Text: Uw gebruiker is uitgenodigd voor {{.ApplicationName}}. Klik op de onderstaande knop om het uitnodigingsproces te voltooien. Als u deze e-mail niet hebt aangevraagd, negeer deze dan.
|
||||
ButtonText: Uitnodiging accepteren
|
68
apps/api/internal/notification/static/i18n/pl.yaml
Normal file
68
apps/api/internal/notification/static/i18n/pl.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: ZITADEL - Inicjalizacja użytkownika
|
||||
PreHeader: Inicjalizacja użytkownika
|
||||
Subject: Inicjalizacja użytkownika
|
||||
Greeting: Witaj {{.DisplayName}},
|
||||
Text: Ten użytkownik został utworzony w ZITADEL. Aby się zalogować, użyj nazwy użytkownika {{.PreferredLoginName}}. Kliknij poniższy przycisk, aby zakończyć proces inicjalizacji. (Kod {{.Code}}) Jeśli nie wystąpiła prośba o ten e-mail, proszę go zignorować.
|
||||
ButtonText: Zakończ inicjalizację
|
||||
PasswordReset:
|
||||
Title: ZITADEL - Resetowanie hasła
|
||||
PreHeader: Resetowanie hasła
|
||||
Subject: Resetowanie hasła
|
||||
Greeting: Witaj {{.DisplayName}},
|
||||
Text: Otrzymaliśmy prośbę o resetowanie hasła. Kliknij poniższy przycisk, aby zresetować swoje hasło. (Kod {{.Code}}) Jeśli nie wystąpiła prośba o ten e-mail, proszę go zignorować.
|
||||
ButtonText: Zresetuj hasło
|
||||
VerifyEmail:
|
||||
Title: ZITADEL - Weryfikacja adresu e-mail
|
||||
PreHeader: Weryfikacja adresu e-mail
|
||||
Subject: Weryfikacja adresu e-mail
|
||||
Greeting: Witaj {{.DisplayName}},
|
||||
Text: Został dodany nowy adres e-mail. Użyj poniższego przycisku, aby zweryfikować swój adres e-mail. (Kod {{.Code}}) Jeśli nie dodałeś nowego adresu e-mail, proszę zignoruj ten e-mail.
|
||||
ButtonText: Zweryfikuj adres e-mail
|
||||
VerifyPhone:
|
||||
Title: ZITADEL - Weryfikacja numeru telefonu
|
||||
PreHeader: Weryfikacja numeru telefonu
|
||||
Subject: Weryfikacja numeru telefonu
|
||||
Greeting: Witaj {{.DisplayName}},
|
||||
Text: Został dodany nowy numer telefonu. Użyj następującego kodu, aby go zweryfikować {{.Code}}
|
||||
ButtonText: Zweryfikuj numer telefonu
|
||||
VerifyEmailOTP:
|
||||
Title: ZITADEL - Zatwierdź hasło jednorazowe
|
||||
PreHeader: Zatwierdź hasło jednorazowe
|
||||
Subject: Zatwierdź hasło jednorazowe
|
||||
Greeting: Witaj {{.DisplayName}},
|
||||
Text: Proszę, użyj przycisku 'Uwierzytelnij' lub skopiuj hasło jednorazowe {{.OTP}} i wklej go na ekran uwierzytelniania, aby uwierzytelnić się w ZITADEL w ciągu najbliższych pięciu minut.
|
||||
ButtonText: Uwierzytelnij
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} to Twoje jednorazowe hasło do domeny {{ .Domain }}. Użyj go w ciągu najbliższych {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: ZITADEL - Domena została zarejestrowana
|
||||
PreHeader: Zmiana adresu e-mail / nazwy użytkownika
|
||||
Subject: Domena została zarejestrowana
|
||||
Greeting: Witaj {{.DisplayName}},
|
||||
Text: Domena {{.Domain}} została zarejestrowana przez organizację. Twój obecny użytkownik {{.Username}} nie jest członkiem tej organizacji. Dlatego będziesz musiał zmienić swój adres e-mail podczas logowania. Stworzyliśmy tymczasową nazwę użytkownika ({{.TempUsername}}) dla tego logowania.
|
||||
ButtonText: Zaloguj się
|
||||
PasswordlessRegistration:
|
||||
Title: ZITADEL - Dodaj logowanie bez hasła
|
||||
PreHeader: Dodaj logowanie bez hasła
|
||||
Subject: Dodaj logowanie bez hasła
|
||||
Greeting: Witaj {{.DisplayName}},
|
||||
Text: Otrzymaliśmy prośbę o dodanie tokenu do logowania bez hasła. Użyj poniższego przycisku, aby dodać swój token lub urządzenie do logowania bez hasła.
|
||||
ButtonText: Dodaj logowanie bez hasła
|
||||
PasswordChange:
|
||||
Title: ZITADEL - Hasło użytkownika zostało zmienione
|
||||
PreHeader: Zmiana hasła
|
||||
Subject: Hasło użytkownika zostało zmienione
|
||||
Greeting: Witaj {{.DisplayName}},
|
||||
Text: Hasło Twojego użytkownika zostało zmienione, jeśli ta zmiana nie została dokonana przez Ciebie, zalecamy natychmiastowe zresetowanie hasła.
|
||||
ButtonText: Zaloguj się
|
||||
InviteUser:
|
||||
Title: Zaproszenie do {{.ApplicationName}}
|
||||
PreHeader: Zaproszenie do {{.ApplicationName}}
|
||||
Subject: Zaproszenie do {{.ApplicationName}}
|
||||
Greeting: Witaj {{.DisplayName}},
|
||||
Text: Twój użytkownik został zaproszony do {{.ApplicationName}}. Kliknij poniższy przycisk, aby zakończyć proces zaproszenia. Jeśli nie zażądałeś tego e-maila, zignoruj go.
|
||||
ButtonText: Akceptuj zaproszenie
|
68
apps/api/internal/notification/static/i18n/pt.yaml
Normal file
68
apps/api/internal/notification/static/i18n/pt.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: ZITADEL - Inicializar Usuário
|
||||
PreHeader: Inicializar Usuário
|
||||
Subject: Inicializar Usuário
|
||||
Greeting: Olá {{.DisplayName}},
|
||||
Text: Este usuário foi criado no ZITADEL. Use o nome de usuário {{.PreferredLoginName}} para fazer login. Por favor, clique no botão abaixo para finalizar o processo de inicialização. (Código {{.Code}}) Se você não solicitou este e-mail, por favor, ignore-o.
|
||||
ButtonText: Finalizar inicialização
|
||||
PasswordReset:
|
||||
Title: ZITADEL - Redefinir senha
|
||||
PreHeader: Redefinir senha
|
||||
Subject: Redefinir senha
|
||||
Greeting: Olá {{.DisplayName}},
|
||||
Text: Recebemos uma solicitação de redefinição de senha. Por favor, use o botão abaixo para redefinir sua senha. (Código {{.Code}}) Se você não solicitou este e-mail, por favor, ignore-o.
|
||||
ButtonText: Redefinir senha
|
||||
VerifyEmail:
|
||||
Title: ZITADEL - Verificar e-mail
|
||||
PreHeader: Verificar e-mail
|
||||
Subject: Verificar e-mail
|
||||
Greeting: Olá {{.DisplayName}},
|
||||
Text: Um novo e-mail foi adicionado. Por favor, use o botão abaixo para verificar seu e-mail. (Código {{.Code}}) Se você não adicionou um novo e-mail, por favor, ignore este e-mail.
|
||||
ButtonText: Verificar e-mail
|
||||
VerifyPhone:
|
||||
Title: ZITADEL - Verificar telefone
|
||||
PreHeader: Verificar telefone
|
||||
Subject: Verificar telefone
|
||||
Greeting: Olá {{.DisplayName}},
|
||||
Text: Um novo número de telefone foi adicionado. Por favor, use o código a seguir para verificá-lo {{.Code}}
|
||||
ButtonText: Verificar telefone
|
||||
VerifyEmailOTP:
|
||||
Title: ZITADEL - Verifica a senha de uso único
|
||||
PreHeader: Verifica a senha de uso único
|
||||
Subject: Verifica a senha de uso único
|
||||
Greeting: Olá {{.DisplayName}},
|
||||
Text: Por favor, usa o botão 'Autenticar' ou copia a senha de uso único {{.OTP}} e cola-a na tela de autenticação para te autenticares no ZITADEL nos próximos cinco minutos.
|
||||
ButtonText: Autenticar
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} é sua senha única para {{ .Domain }}. Use-o nos próximos {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: ZITADEL - Domínio foi reivindicado
|
||||
PreHeader: Alterar e-mail / nome de usuário
|
||||
Subject: Domínio foi reivindicado
|
||||
Greeting: Olá {{.DisplayName}},
|
||||
Text: O domínio {{.Domain}} foi reivindicado por uma organização. Seu usuário atual, {{.Username}}, não faz parte dessa organização. Portanto, você terá que alterar seu e-mail quando fizer o login. Criamos um nome de usuário temporário ({{.TempUsername}}) para este login.
|
||||
ButtonText: Fazer login
|
||||
PasswordlessRegistration:
|
||||
Title: ZITADEL - Adicionar Login sem Senha
|
||||
PreHeader: Adicionar Login sem Senha
|
||||
Subject: Adicionar Login sem Senha
|
||||
Greeting: Olá {{.DisplayName}},
|
||||
Text: Recebemos uma solicitação para adicionar um token ou dispositivo para login sem senha. Por favor, use o botão abaixo para adicionar seu token ou dispositivo para login sem senha.
|
||||
ButtonText: Adicionar Login sem Senha
|
||||
PasswordChange:
|
||||
Title: ZITADEL - Senha do usuário foi alterada
|
||||
PreHeader: Alterar senha
|
||||
Subject: Senha do usuário foi alterada
|
||||
Greeting: Olá {{.DisplayName}},
|
||||
Text: A senha do seu usuário foi alterada. Se esta alteração não foi feita por você, recomendamos que você redefina sua senha imediatamente.
|
||||
ButtonText: Fazer login
|
||||
InviteUser:
|
||||
Title: Convite para {{.ApplicationName}}
|
||||
PreHeader: Convite para {{.ApplicationName}}
|
||||
Subject: Convite para {{.ApplicationName}}
|
||||
Greeting: Olá {{.DisplayName}},
|
||||
Text: Seu usuário foi convidado para {{.ApplicationName}}. Clique no botão abaixo para concluir o processo de convite. Se você não solicitou este e-mail, por favor, ignore-o.
|
||||
ButtonText: Aceitar convite
|
68
apps/api/internal/notification/static/i18n/ro.yaml
Normal file
68
apps/api/internal/notification/static/i18n/ro.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: Inițializare Utilizator
|
||||
PreHeader: Inițializare Utilizator
|
||||
Subject: Inițializare Utilizator
|
||||
Greeting: Bună ziua, {{.DisplayName}},
|
||||
Text: Acest utilizator a fost creat. Folosiți numele de utilizator {{.PreferredLoginName}} pentru a vă autentifica. Vă rugăm să dați clic pe butonul de mai jos pentru a finaliza procesul de inițializare. (Cod {{.Code}}) Dacă nu ați solicitat acest e-mail, vă rugăm să îl ignorați.
|
||||
ButtonText: Finalizare inițializare
|
||||
PasswordReset:
|
||||
Title: Resetare parolă
|
||||
PreHeader: Resetare parolă
|
||||
Subject: Resetare parolă
|
||||
Greeting: Bună ziua, {{.DisplayName}},
|
||||
Text: Am primit o cerere de resetare a parolei. Vă rugăm să folosiți butonul de mai jos pentru a vă reseta parola. (Cod {{.Code}}) Dacă nu ați solicitat acest e-mail, vă rugăm să îl ignorați.
|
||||
ButtonText: Resetare parolă
|
||||
VerifyEmail:
|
||||
Title: Verificare e-mail
|
||||
PreHeader: Verificare e-mail
|
||||
Subject: Verificare e-mail
|
||||
Greeting: Bună ziua, {{.DisplayName}},
|
||||
Text: A fost adăugat un e-mail nou. Vă rugăm să folosiți butonul de mai jos pentru a vă verifica e-mailul. (Cod {{.Code}}) Dacă nu ați adăugat un e-mail nou, vă rugăm să ignorați acest e-mail.
|
||||
ButtonText: Verificare e-mail
|
||||
VerifyPhone:
|
||||
Title: Verificare telefon
|
||||
PreHeader: Verificare telefon
|
||||
Subject: Verificare telefon
|
||||
Greeting: Bună ziua, {{.DisplayName}},
|
||||
Text: "A fost adăugat un număr de telefon nou. Vă rugăm să folosiți următorul cod pentru a-l verifica: {{.Code}}"
|
||||
ButtonText: Verificare telefon
|
||||
VerifyEmailOTP:
|
||||
Title: Verificare parolă unică
|
||||
PreHeader: Verificare parolă unică
|
||||
Subject: Verificare parolă unică
|
||||
Greeting: Bună ziua, {{.DisplayName}},
|
||||
Text: Vă rugăm să folosiți parola unică {{.OTP}} pentru a vă autentifica în următoarele cinci minute sau dați clic pe butonul "Autentificare".
|
||||
ButtonText: Autentificare
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} este parola dvs. unică pentru {{ .Domain }}. Folosiți-o în următoarele {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: Domeniul a fost revendicat
|
||||
PreHeader: Schimbare e-mail / nume de utilizator
|
||||
Subject: Domeniul a fost revendicat
|
||||
Greeting: Bună ziua, {{.DisplayName}},
|
||||
Text: Domeniul {{.Domain}} a fost revendicat de o organizație. Utilizatorul dvs. actual {{.Username}} nu face parte din această organizație. Prin urmare, va trebui să vă schimbați e-mailul când vă autentificați. Am creat un nume de utilizator temporar ({{.TempUsername}}) pentru această autentificare.
|
||||
ButtonText: Autentificare
|
||||
PasswordlessRegistration:
|
||||
Title: Adăugare autentificare fără parolă
|
||||
PreHeader: Adăugare autentificare fără parolă
|
||||
Subject: Adăugare autentificare fără parolă
|
||||
Greeting: Bună ziua, {{.DisplayName}},
|
||||
Text: Am primit o cerere de adăugare a unui token pentru autentificare fără parolă. Vă rugăm să folosiți butonul de mai jos pentru a adăuga token-ul sau dispozitivul pentru autentificare fără parolă.
|
||||
ButtonText: Adăugare autentificare fără parolă
|
||||
PasswordChange:
|
||||
Title: Parola utilizatorului a fost schimbată
|
||||
PreHeader: Schimbare parolă
|
||||
Subject: Parola utilizatorului a fost schimbată
|
||||
Greeting: Bună ziua, {{.DisplayName}},
|
||||
Text: Parola utilizatorului dvs. a fost schimbată. Dacă această modificare nu a fost făcută de dvs., vă recomandăm să vă resetați imediat parola.
|
||||
ButtonText: Autentificare
|
||||
InviteUser:
|
||||
Title: Invitație la {{.ApplicationName}}
|
||||
PreHeader: Invitație la {{.ApplicationName}}
|
||||
Subject: Invitație la {{.ApplicationName}}
|
||||
Greeting: Bună ziua, {{.DisplayName}},
|
||||
Text: Utilizatorul dvs. a fost invitat la {{.ApplicationName}}. Vă rugăm să dați clic pe butonul de mai jos pentru a finaliza procesul de invitație. Dacă nu ați solicitat acest e-mail, vă rugăm să îl ignorați.
|
||||
ButtonText: Acceptare invitație
|
68
apps/api/internal/notification/static/i18n/ru.yaml
Normal file
68
apps/api/internal/notification/static/i18n/ru.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: Регистрация пользователя
|
||||
PreHeader: Регистрация пользователя
|
||||
Subject: Регистрация пользователя
|
||||
Greeting: Здравствуйте {{.DisplayName}},
|
||||
Text: Используйте логин {{.PreferredLoginName}} для входа. Пожалуйста, нажмите кнопку ниже для завершения процесса регистрации. (Код {{.Code}}) Если вы не запрашивали это письмо, пожалуйста, проигнорируйте его.
|
||||
ButtonText: Завершить регистрацию
|
||||
PasswordReset:
|
||||
Title: Сброс пароля
|
||||
PreHeader: Сброс пароля
|
||||
Subject: Сброс пароля
|
||||
Greeting: Здравствуйте {{.DisplayName}},
|
||||
Text: Мы получили запрос на сброс пароля. Пожалуйста, нажмите кнопку ниже для сброса вашего пароля. (Код {{.Code}}) Если вы не запрашивали это письмо, пожалуйста, проигнорируйте его.
|
||||
ButtonText: Сбросить пароль
|
||||
VerifyEmail:
|
||||
Title: Подтверждение email
|
||||
PreHeader: Подтверждение email
|
||||
Subject: Подтверждение email
|
||||
Greeting: Здравствуйте {{.DisplayName}},
|
||||
Text: Добавлен новый email. Пожалуйста, нажмите кнопку ниже для подтверждения вашего email. (Код {{.Code}}) Если вы не запрашивали это письмо, пожалуйста, проигнорируйте его.
|
||||
ButtonText: Подтвердить email
|
||||
VerifyPhone:
|
||||
Title: Подтверждение телефона
|
||||
PreHeader: Подтверждение телефона
|
||||
Subject: Подтверждение телефона
|
||||
Greeting: Здравствуйте {{.DisplayName}},
|
||||
Text: Добавлен новый номер телефона. Пожалуйста, используйте следующий код, чтобы подтвердить его. Код {{.Code}}
|
||||
ButtonText: Подтвердить телефон
|
||||
VerifyEmailOTP:
|
||||
Title: Проверка одноразового пароля
|
||||
PreHeader: Проверка одноразового пароля
|
||||
Subject: Проверка одноразового пароля
|
||||
Greeting: Привет, {{.DisplayName}}!
|
||||
Text: Пожалуйста, используйте одноразовый пароль {{.OTP}} для аутентификации в течение следующих пяти минут или нажмите кнопку «Войти».
|
||||
ButtonText: Войти
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} — это ваш одноразовый пароль для {{ .Domain }}. Используйте его в течение следующих {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: Утверждение домена
|
||||
PreHeader: Изменение email / логина
|
||||
Subject: Домен был утвержден
|
||||
Greeting: Здравствуйте {{.DisplayName}},
|
||||
Text: Домен {{.Domain}} был утвержден организацией. Ваш текущий пользователь {{.Username}} не является частью этой организации. Вам необходимо изменить свой email при входе в систему. Мы создали временный логин ({{.TempUsername}}) для входа.
|
||||
ButtonText: Вход
|
||||
PasswordlessRegistration:
|
||||
Title: Добавление входа без пароля
|
||||
PreHeader: Добавление входа без пароля
|
||||
Subject: Добавление входа без пароля
|
||||
Greeting: Здравствуйте {{.DisplayName}},
|
||||
Text: Мы получили запрос на добавление токена для входа без пароля. Пожалуйста, используйте кнопку ниже, чтобы добавить свой токен или устройство для входа без пароля.
|
||||
ButtonText: Добавить вход без пароля
|
||||
PasswordChange:
|
||||
Title: Смена пароля пользователя
|
||||
PreHeader: Смена пароля
|
||||
Subject: Пароль пользователя изменен
|
||||
Greeting: Здравствуйте {{.DisplayName}},
|
||||
Text: Пароль пользователя был изменен. Если это изменение сделано не вами, советуем немедленно сбросить пароль.
|
||||
ButtonText: Вход
|
||||
InviteUser:
|
||||
Title: Приглашение в {{.ApplicationName}}
|
||||
PreHeader: Приглашение в {{.ApplicationName}}
|
||||
Subject: Приглашение в {{.ApplicationName}}
|
||||
Greeting: Здравствуйте, {{.DisplayName}},
|
||||
Text: Ваш пользователь был приглашен в {{.ApplicationName}}. Пожалуйста, нажмите кнопку ниже, чтобы завершить процесс приглашения. Если вы не запрашивали это письмо, пожалуйста, игнорируйте его.
|
||||
ButtonText: Принять приглашение
|
68
apps/api/internal/notification/static/i18n/sv.yaml
Normal file
68
apps/api/internal/notification/static/i18n/sv.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: Verifiera användare
|
||||
PreHeader: Verifiera användare
|
||||
Subject: Verifiera användare
|
||||
Greeting: Hej {{.DisplayName}},
|
||||
Text: Din användare har skapats. Använd användarnamnet {{.PreferredLoginName}} för att logga in. Klicka på knappen nedan för att genomföra verifieringsprocessen. (Kod {{.Code}}) Om du inte begärt det här meddelande, vänligen ignorera det.
|
||||
ButtonText: Slutför verifiering
|
||||
PasswordReset:
|
||||
Title: Återställ lösenord
|
||||
PreHeader: Återställ lösenord
|
||||
Subject: Återställ lösenord
|
||||
Greeting: Hej {{.DisplayName}},
|
||||
Text: Vi har mottagit en begäran om att återställa lösenordet. Använd knappen nedan för att återställa ditt lösenord. (Kod {{.Code}}) Om du inte begärt det här meddelande, vänligen ignorera det.
|
||||
ButtonText: Återställ lösenord
|
||||
VerifyEmail:
|
||||
Title: Verifiera e-post
|
||||
PreHeader: Verifiera e-post
|
||||
Subject: Verifiera e-post
|
||||
Greeting: Hej {{.DisplayName}},
|
||||
Text: En ny e-postadress har lagts till. Använd knappen nedan för att verifiera din e-post. (Kod {{.Code}}) Om du inte lagt till en ny e-postadress, vänligen ignorera detta mail.
|
||||
ButtonText: Verifiera e-post
|
||||
VerifyPhone:
|
||||
Title: Verifiera telefon
|
||||
PreHeader: Verifiera telefon
|
||||
Subject: Verifiera telefon
|
||||
Greeting: Hej {{.DisplayName}},
|
||||
Text: Ett nytt mobilnummer har lagts till. Använd följande kod för att verifiera det {{.Code}}
|
||||
ButtonText: Verifiera telefon
|
||||
VerifyEmailOTP:
|
||||
Title: Verifiera engångslösenord
|
||||
PreHeader: Verifiera engångslösenord
|
||||
Subject: Verifiera engångslösenord
|
||||
Greeting: Hej {{.DisplayName}},
|
||||
Text: Använd engångslösenordet {{.OTP}} för att autentisera inom de närmaste fem minuterna eller klicka på "Autentisera"-knappen.
|
||||
ButtonText: Autentisera
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} är ditt engångslösenord för {{ .Domain }}. Använd det inom {{.Expiry}}.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: Domän har blivit tagen i anspråk
|
||||
PreHeader: Ändra e-post / användarnamn
|
||||
Subject: Domän har blivit tagen i anspråk
|
||||
Greeting: Hej {{.DisplayName}},
|
||||
Text: Domänen {{.Domain}} har blivit tagen i anspråk av en organisation. Din nuvarande användare {{.Username}} är inte en del av denna organisation. Därför måste du ändra din e-post när du loggar in. Vi har skapat ett tillfälligt användarnamn ({{.TempUsername}}) för denna inloggning.
|
||||
ButtonText: Logga in
|
||||
PasswordlessRegistration:
|
||||
Title: Lägg till lösenordsfri inloggning
|
||||
PreHeader: Lägg till lösenordsfri inloggning
|
||||
Subject: Lägg till lösenordsfri inloggning
|
||||
Greeting: Hej {{.DisplayName}},
|
||||
Text: Vi har mottagit en begäran om att lägga till en token för lösenordsfri inloggning. Använd knappen nedan för att lägga till din token eller enhet för lösenordsfri inloggning.
|
||||
ButtonText: Lägg till lösenordsfri inloggning
|
||||
PasswordChange:
|
||||
Title: Användarens lösenord har ändrats
|
||||
PreHeader: Ändra lösenord
|
||||
Subject: Användarens lösenord har ändrats
|
||||
Greeting: Hej {{.DisplayName}},
|
||||
Text: Lösenordet för din användare har ändrats. Om denna ändring inte gjordes av dig, vänligen återställ ditt lösenord omedelbart.
|
||||
ButtonText: Logga in
|
||||
InviteUser:
|
||||
Title: Inbjudan till {{.ApplicationName}}
|
||||
PreHeader: Inbjudan till {{.ApplicationName}}
|
||||
Subject: Inbjudan till {{.ApplicationName}}
|
||||
Greeting: Hej {{.DisplayName}},
|
||||
Text: Din användare har blivit inbjuden till {{.ApplicationName}}. Klicka på knappen nedan för att slutföra inbjudansprocessen. Om du inte har begärt detta e-postmeddelande, ignorera det.
|
||||
ButtonText: Acceptera inbjudan
|
68
apps/api/internal/notification/static/i18n/tr.yaml
Normal file
68
apps/api/internal/notification/static/i18n/tr.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: Kullanıcıyı Başlat
|
||||
PreHeader: Kullanıcıyı Başlat
|
||||
Subject: Kullanıcıyı Başlat
|
||||
Greeting: Merhaba {{.DisplayName}},
|
||||
Text: Bu kullanıcı oluşturuldu. Giriş yapmak için {{.PreferredLoginName}} kullanıcı adını kullanın. Başlatma işlemini tamamlamak için lütfen aşağıdaki düğmeye tıklayın. (Kod {{.Code}}) Bu e-postayı siz istemediyseniz, lütfen görmezden gelin.
|
||||
ButtonText: Başlatmayı tamamla
|
||||
PasswordReset:
|
||||
Title: Şifre sıfırla
|
||||
PreHeader: Şifre sıfırla
|
||||
Subject: Şifre sıfırla
|
||||
Greeting: Merhaba {{.DisplayName}},
|
||||
Text: Şifre sıfırlama isteği aldık. Şifrenizi sıfırlamak için lütfen aşağıdaki düğmeyi kullanın. (Kod {{.Code}}) Bu e-postayı siz istemediyseniz, lütfen görmezden gelin.
|
||||
ButtonText: Şifreyi sıfırla
|
||||
VerifyEmail:
|
||||
Title: E-postayı doğrula
|
||||
PreHeader: E-postayı doğrula
|
||||
Subject: E-postayı doğrula
|
||||
Greeting: Merhaba {{.DisplayName}},
|
||||
Text: Yeni bir e-posta adresi eklendi. E-posta adresinizi doğrulamak için lütfen aşağıdaki düğmeyi kullanın. (Kod {{.Code}}) Yeni bir e-posta eklemediyseniz, lütfen bu e-postayı görmezden gelin.
|
||||
ButtonText: E-postayı doğrula
|
||||
VerifyPhone:
|
||||
Title: Telefonu doğrula
|
||||
PreHeader: Telefonu doğrula
|
||||
Subject: Telefonu doğrula
|
||||
Greeting: Merhaba {{.DisplayName}},
|
||||
Text: Yeni bir telefon numarası eklendi. Doğrulamak için lütfen şu kodu kullanın {{.Code}}
|
||||
ButtonText: Telefonu doğrula
|
||||
VerifyEmailOTP:
|
||||
Title: Tek Kullanımlık Şifreyi Doğrula
|
||||
PreHeader: Tek Kullanımlık Şifreyi Doğrula
|
||||
Subject: Tek Kullanımlık Şifreyi Doğrula
|
||||
Greeting: Merhaba {{.DisplayName}},
|
||||
Text: Önümüzdeki beş dakika içinde kimlik doğrulaması yapmak için {{.OTP}} tek kullanımlık şifreyi kullanın veya "Kimlik Doğrula" düğmesine tıklayın.
|
||||
ButtonText: Kimlik Doğrula
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}}, {{ .Domain }} için tek kullanımlık şifrenizdir. Önümüzdeki {{.Expiry}} içinde kullanın.
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: Domain talep edildi
|
||||
PreHeader: E-posta / kullanıcı adını değiştir
|
||||
Subject: Domain talep edildi
|
||||
Greeting: Merhaba {{.DisplayName}},
|
||||
Text: "{{.Domain}} domaini bir organizasyon tarafından talep edildi. Mevcut kullanıcınız {{.Username}} bu organizasyonun parçası değil. Bu nedenle giriş yaparken e-postanızı değiştirmeniz gerekecek. Bu giriş için geçici bir kullanıcı adı ({{.TempUsername}}) oluşturduk."
|
||||
ButtonText: Giriş Yap
|
||||
PasswordlessRegistration:
|
||||
Title: Şifresiz Giriş Ekle
|
||||
PreHeader: Şifresiz Giriş Ekle
|
||||
Subject: Şifresiz Giriş Ekle
|
||||
Greeting: Merhaba {{.DisplayName}},
|
||||
Text: Şifresiz giriş için token ekleme isteği aldık. Şifresiz giriş için token'ınızı veya cihazınızı eklemek için lütfen aşağıdaki düğmeyi kullanın.
|
||||
ButtonText: Şifresiz Giriş Ekle
|
||||
PasswordChange:
|
||||
Title: Kullanıcının şifresi değişti
|
||||
PreHeader: Şifre değiştir
|
||||
Subject: Kullanıcının şifresi değişti
|
||||
Greeting: Merhaba {{.DisplayName}},
|
||||
Text: Kullanıcınızın şifresi değişti. Bu değişikliği siz yapmadıysanız, lütfen derhal şifrenizi sıfırlamanız önerilir.
|
||||
ButtonText: Giriş Yap
|
||||
InviteUser:
|
||||
Title: "{{.ApplicationName}} için davet"
|
||||
PreHeader: "{{.ApplicationName}} için davet"
|
||||
Subject: "{{.ApplicationName}} için davet"
|
||||
Greeting: Merhaba {{.DisplayName}},
|
||||
Text: "Kullanıcınız {{.ApplicationName}} uygulamasına davet edildi. Davet işlemini tamamlamak için lütfen aşağıdaki düğmeye tıklayın. Bu e-postayı siz istemediyseniz, lütfen görmezden gelin."
|
||||
ButtonText: Daveti kabul et
|
68
apps/api/internal/notification/static/i18n/zh.yaml
Normal file
68
apps/api/internal/notification/static/i18n/zh.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
InitCode:
|
||||
Title: ZITADEL - 初始化用户
|
||||
PreHeader: 初始化用户
|
||||
Subject: 初始化用户
|
||||
Greeting: 你好 {{.DisplayName}},
|
||||
Text: 此用户是在 ZITADEL 中创建的。使用用户名 {{.PreferredLoginName}} 登录。请单击下面的按钮完成初始化过程。(代码 {{.Code}})如果不是您本人操作,请忽略它。
|
||||
ButtonText: 完成初始化
|
||||
PasswordReset:
|
||||
Title: ZITADEL - 重置密码
|
||||
PreHeader: 重置密码
|
||||
Subject: 重置密码
|
||||
Greeting: 你好 {{.DisplayName}},
|
||||
Text: 我们收到了密码重置请求。请使用下面的按钮重置您的密码。(验证码 {{.Code}})如果不是您本人操作,请忽略它。
|
||||
ButtonText: 重置密码
|
||||
VerifyEmail:
|
||||
Title: ZITADEL - 验证电子邮箱
|
||||
PreHeader: 验证电子邮箱
|
||||
Subject: 验证电子邮箱
|
||||
Greeting: 你好 {{.DisplayName}},
|
||||
Text: 已添加新电子邮件。请使用下面的按钮来验证您的邮件。(验证码 {{.Code}})如果不是您本人操作,请忽略此电子邮件。
|
||||
ButtonText: 验证电子邮箱
|
||||
VerifyPhone:
|
||||
Title: ZITADEL - 验证手机号码
|
||||
PreHeader: 验证手机号码
|
||||
Subject: 验证手机号码
|
||||
Greeting: 你好 {{.DisplayName}},
|
||||
Text: 您的用户中添加了一个新的手机号码,请使用以下验证码进行验证 {{.Code}}
|
||||
ButtonText: 验证手机号码
|
||||
VerifyEmailOTP:
|
||||
Title: ZITADEL - 验证一次性密码
|
||||
PreHeader: 验证一次性密码
|
||||
Subject: 验证一次性密码
|
||||
Greeting: 你好,{{.DisplayName}},
|
||||
Text: 请使用 '验证' 按钮,或复制一次性密码 {{.OTP}} 并将其粘贴到验证屏幕中,以在接下来的五分钟内在 ZITADEL 中进行验证。
|
||||
ButtonText: 验证
|
||||
VerifySMSOTP:
|
||||
Text: >-
|
||||
{{.OTP}} 是您的 {{ .Domain }} 的一次性密码。在下一个 {{.Expiry}} 内使用它。
|
||||
|
||||
@{{.Domain}} #{{.OTP}}
|
||||
DomainClaimed:
|
||||
Title: ZITADEL - 域名所有权验证
|
||||
PreHeader: 更改电子邮件/用户名
|
||||
Subject: 域名所有权验证
|
||||
Greeting: 你好 {{.DisplayName}},
|
||||
Text: 域 {{.Domain}} 已被组织使用。您当前的用户 {{.Username}} 不属于此组织。因此,您必须在登录时更改您的电子邮件。我们为此登录创建了一个临时用户名 ({{.TempUsername}})。
|
||||
ButtonText: 登录
|
||||
PasswordlessRegistration:
|
||||
Title: ZITADEL - 添加无密码登录
|
||||
PreHeader: 添加无密码登录
|
||||
Subject: 添加无密码登录
|
||||
Greeting: 你好 {{.DisplayName}},
|
||||
Text: 我们收到了为无密码登录添加令牌的请求。请使用下面的按钮添加您的令牌或设备以进行无密码登录。
|
||||
ButtonText: 添加无密码登录
|
||||
PasswordChange:
|
||||
Title: ZITADEL - 用户的密码已经改变
|
||||
PreHeader: 更改密码
|
||||
Subject: 用户的密码已经改变
|
||||
Greeting: 你好 {{.DisplayName}},
|
||||
Text: 您的用户的密码已经改变,如果这个改变不是由您做的,请注意立即重新设置您的密码。
|
||||
ButtonText: 登录
|
||||
InviteUser:
|
||||
Title: '{{.ApplicationName}}邀请'
|
||||
PreHeader: '{{.ApplicationName}}邀请'
|
||||
Subject: '{{.ApplicationName}}邀请'
|
||||
Greeting: 您好,{{.DisplayName}},
|
||||
Text: 您的用户已被邀请加入{{.ApplicationName}}。请点击下面的按钮完成邀请过程。如果您没有请求此邮件,请忽略它。
|
||||
ButtonText: 接受邀请
|
363
apps/api/internal/notification/static/templates/template.html
Normal file
363
apps/api/internal/notification/static/templates/template.html
Normal file
@@ -0,0 +1,363 @@
|
||||
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<title>
|
||||
|
||||
</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a { padding:0; }
|
||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
||||
p { display:block;margin:13px 0; }
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
.mj-column-per-60 { width:60% !important; max-width: 60%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
|
||||
|
||||
@media only screen and (max-width:480px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
</style>
|
||||
<style type="text/css">.shadow a {
|
||||
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12);
|
||||
}</style>
|
||||
|
||||
{{if .FontURL}}
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: '{{.FontFaceFamily}}';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url({{.FontURL}});
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
</head>
|
||||
<body style="word-spacing:normal;">
|
||||
|
||||
|
||||
<div
|
||||
style=""
|
||||
>
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:{{.BackgroundColor}};background-color:{{.BackgroundColor}};width:100%;border-radius:16px;"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:800px;" width="800" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;border-radius:16px;max-width:800px;">
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:16px;"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:20px 0;padding-left:0;text-align:center;"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="800px" ><![endif]-->
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:800px;" width="800" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:800px;">
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:0;text-align:center;"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="width:800px;" ><![endif]-->
|
||||
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;"
|
||||
>
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:800px;" ><![endif]-->
|
||||
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
|
||||
>
|
||||
|
||||
<table
|
||||
border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding:0;">
|
||||
{{if .LogoURL}}
|
||||
<table
|
||||
border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%"
|
||||
>
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
align="center" style="font-size:0px;padding:50px 0 30px 0;word-break:break-word;"
|
||||
>
|
||||
|
||||
<table
|
||||
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:180px;">
|
||||
|
||||
<img
|
||||
height="auto" src="{{.LogoURL}}" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180"
|
||||
/>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!--[if mso | IE]></td></tr><tr><td class="" width="800px" ><![endif]-->
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:800px;" width="800" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:800px;">
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:0;text-align:center;"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:480px;" ><![endif]-->
|
||||
|
||||
<div
|
||||
class="mj-column-per-60 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
|
||||
>
|
||||
|
||||
<table
|
||||
border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding:0;">
|
||||
|
||||
<table
|
||||
border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%"
|
||||
>
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"
|
||||
>
|
||||
|
||||
<div
|
||||
style="font-family:{{.FontFamily}};font-size:24px;font-weight:500;line-height:1;text-align:center;color:{{.FontColor}};"
|
||||
>{{.Greeting}}</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"
|
||||
>
|
||||
|
||||
<div
|
||||
style="font-family:{{.FontFamily}};font-size:16px;font-weight:light;line-height:1.5;text-align:center;color:{{.FontColor}};"
|
||||
>{{.Text}}</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td
|
||||
align="center" vertical-align="middle" class="shadow" style="font-size:0px;padding:10px 25px;word-break:break-word;"
|
||||
>
|
||||
|
||||
<table
|
||||
border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"
|
||||
>
|
||||
<tr>
|
||||
<td
|
||||
align="center" bgcolor="{{.PrimaryColor}}" role="presentation" style="border:none;border-radius:6px;cursor:auto;mso-padding-alt:10px 25px;background:{{.PrimaryColor}};" valign="middle"
|
||||
>
|
||||
<a
|
||||
href="{{.URL}}" rel="noopener noreferrer notrack" style="display:inline-block;background:{{.PrimaryColor}};color:#ffffff;font-family:{{.FontFamily}};font-size:14px;font-weight:500;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:6px;" target="_blank"
|
||||
>
|
||||
{{.ButtonText}}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
{{if .IncludeFooter}}
|
||||
<tr>
|
||||
<td
|
||||
align="center" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-right:20px;padding-bottom:20px;padding-left:20px;word-break:break-word;"
|
||||
>
|
||||
|
||||
<p
|
||||
style="border-top:solid 2px #dbdbdb;font-size:1px;margin:0px auto;width:100%;"
|
||||
>
|
||||
</p>
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #dbdbdb;font-size:1px;margin:0px auto;width:440px;" role="presentation" width="440px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
align="center" style="font-size:0px;padding:16px;word-break:break-word;"
|
||||
>
|
||||
|
||||
<div
|
||||
style="font-family:{{.FontFamily}};font-size:13px;line-height:1;text-align:center;color:{{.FontColor}};"
|
||||
>{{.FooterText}}</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
@@ -0,0 +1,53 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-attributes>
|
||||
<mj-font name="Lato" href="{{.FontURL}}" />
|
||||
<mj-text align="center" color="{{.FontColor}}" font-family="{{.FontFamily}}" />
|
||||
<mj-section padding="0" full-width="full-width" />
|
||||
<mj-body width="800px" border-radius="16px"/>
|
||||
<mj-image padding="0" />
|
||||
<mj-column padding="0" />
|
||||
<mj-wrapper padding-left="0" full-width="full-width" />
|
||||
</mj-attributes>
|
||||
<mj-style>
|
||||
.shadow a {
|
||||
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-wrapper background-color="{{.BackgroundColor}}" border-radius="16px">
|
||||
{{if .IncludeLogo}}
|
||||
<mj-section>
|
||||
<mj-group>
|
||||
<mj-column>
|
||||
<mj-image src="{{.LogoURL}}" align="center" width="180px" border-radius="8px" padding="50px 0 30px 0" />
|
||||
</mj-column>
|
||||
</mj-group>
|
||||
</mj-section>
|
||||
{{end}}
|
||||
<mj-section>
|
||||
<mj-column width="60%">
|
||||
<mj-text font-size="24px" font-weight="500">{{.Greeting}}</mj-text>
|
||||
<mj-text font-size="16px" line-height="1.5" font-weight="light">{{.Text}}</mj-text>
|
||||
<mj-button css-class="shadow" border-radius="6px" href="{{.URL}}" rel="noopener noreferrer" background-color="{{.PrimaryColor}}" font-size="14px" font-weight="500" >{{.ButtonText}}</mj-button>
|
||||
|
||||
{{if .IncludeFooter}}
|
||||
<mj-divider
|
||||
border-color="#dbdbdb"
|
||||
border-width="2px"
|
||||
border-style="solid"
|
||||
padding-left="20px"
|
||||
padding-right="20px"
|
||||
padding-bottom="20px"
|
||||
padding-top="20px"
|
||||
></mj-divider>
|
||||
<mj-text padding="16px">
|
||||
{{.FooterText}} </mj-text>
|
||||
|
||||
{{end}}
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
</mj-body>
|
||||
</mjml>
|
3
apps/api/internal/notification/statik/generate.go
Normal file
3
apps/api/internal/notification/statik/generate.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package statik
|
||||
|
||||
//go:generate go run github.com/rakyll/statik -f -src=../static -dest=.. -ns=notification
|
80
apps/api/internal/notification/templates/template.go
Normal file
80
apps/api/internal/notification/templates/template.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
templatesPath = "/templates"
|
||||
templateFileName = "template.html"
|
||||
)
|
||||
|
||||
func GetParsedTemplate(mailhtml string, contentData interface{}) (string, error) {
|
||||
template, err := ParseTemplateFile(mailhtml, contentData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ParseTemplateText(template, contentData)
|
||||
}
|
||||
|
||||
func ParseTemplateFile(mailhtml string, data interface{}) (string, error) {
|
||||
tmpl, err := template.New("tmpl").Parse(mailhtml)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return parseTemplate(tmpl, data)
|
||||
}
|
||||
|
||||
func ParseTemplateText(text string, data interface{}) (string, error) {
|
||||
template, err := template.New("template").Parse(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseTemplate(template, data)
|
||||
}
|
||||
|
||||
func parseTemplate(template *template.Template, data interface{}) (string, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
if err := template.Execute(buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func readFile(dir http.FileSystem, fileName string) (*template.Template, error) {
|
||||
f, err := dir.Open(templatesPath + "/" + fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
content, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl, err := template.New(fileName).Parse(string(content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
func readFileFromDatabase(dir http.FileSystem, fileName string) (*template.Template, error) {
|
||||
f, err := dir.Open(templatesPath + "/" + fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
content, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl, err := template.New(fileName).Parse(string(content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
51
apps/api/internal/notification/templates/templateData.go
Normal file
51
apps/api/internal/notification/templates/templateData.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/i18n"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultFontFamily = "-apple-system, BlinkMacSystemFont, Segoe UI, Lato, Arial, Helvetica, sans-serif"
|
||||
DefaultFontColor = "#22292f"
|
||||
DefaultBackgroundColor = "#fafafa"
|
||||
DefaultPrimaryColor = "#5282C1"
|
||||
)
|
||||
|
||||
type TemplateData struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
PreHeader string `json:"preHeader,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Greeting string `json:"greeting,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ButtonText string `json:"buttonText,omitempty"`
|
||||
PrimaryColor string `json:"primaryColor,omitempty"`
|
||||
BackgroundColor string `json:"backgroundColor,omitempty"`
|
||||
FontColor string `json:"fontColor,omitempty"`
|
||||
LogoURL string `json:"logoUrl,omitempty"`
|
||||
FontURL string `json:"fontUrl,omitempty"`
|
||||
FontFaceFamily string `json:"fontFaceFamily,omitempty"`
|
||||
FontFamily string `json:"fontFamily,omitempty"`
|
||||
|
||||
IncludeFooter bool `json:"includeFooter,omitempty"`
|
||||
FooterText string `json:"footerText,omitempty"`
|
||||
}
|
||||
|
||||
func (data *TemplateData) Translate(translator *i18n.Translator, msgType string, args map[string]interface{}, langs ...string) {
|
||||
data.Title = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageTitle), args, langs...)
|
||||
data.PreHeader = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessagePreHeader), args, langs...)
|
||||
data.Subject = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageSubject), args, langs...)
|
||||
data.Greeting = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageGreeting), args, langs...)
|
||||
data.Text = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageText), args, langs...)
|
||||
data.ButtonText = translator.Localize(fmt.Sprintf("%s.%s", msgType, domain.MessageButtonText), args, langs...)
|
||||
// Footer text is neither included in i18n files nor defaults.yaml
|
||||
footerText := fmt.Sprintf("%s.%s", msgType, domain.MessageFooterText)
|
||||
data.FooterText = translator.Localize(footerText, args, langs...)
|
||||
// translator returns the id of the string to be translated if no translation is found for that id
|
||||
// we'll include the footer if we have a custom non-empty string and if the string doesn't include the
|
||||
// id of the string that could not be translated example InitCode.Footer
|
||||
data.IncludeFooter = len(data.FooterText) > 0 && data.FooterText != footerText
|
||||
}
|
20
apps/api/internal/notification/types/domain_claimed.go
Normal file
20
apps/api/internal/notification/types/domain_claimed.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
func (notify Notify) SendDomainClaimed(ctx context.Context, user *query.NotifyUser, username string) error {
|
||||
url := login.LoginLink(http_utils.DomainContext(ctx).Origin(), user.ResourceOwner)
|
||||
index := strings.LastIndex(user.LastEmail, "@")
|
||||
args := make(map[string]interface{})
|
||||
args["TempUsername"] = username
|
||||
args["Domain"] = user.LastEmail[index+1:]
|
||||
return notify(url, args, domain.DomainClaimedMessageType, true)
|
||||
}
|
@@ -0,0 +1,28 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
func (notify Notify) SendEmailVerificationCode(ctx context.Context, user *query.NotifyUser, code string, urlTmpl, authRequestID string) error {
|
||||
var url string
|
||||
if urlTmpl == "" {
|
||||
url = login.MailVerificationLink(http_utils.DomainContext(ctx).Origin(), user.ID, code, user.ResourceOwner, authRequestID)
|
||||
} else {
|
||||
var buf strings.Builder
|
||||
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
|
||||
return err
|
||||
}
|
||||
url = buf.String()
|
||||
}
|
||||
|
||||
args := make(map[string]interface{})
|
||||
args["Code"] = code
|
||||
return notify(url, args, domain.VerifyEmailMessageType, true)
|
||||
}
|
@@ -0,0 +1,92 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
func TestNotify_SendEmailVerificationCode(t *testing.T) {
|
||||
type args struct {
|
||||
user *query.NotifyUser
|
||||
origin *http_utils.DomainCtx
|
||||
code string
|
||||
urlTmpl string
|
||||
authRequestID string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *notifyResult
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "default URL",
|
||||
args: args{
|
||||
user: &query.NotifyUser{
|
||||
ID: "user1",
|
||||
ResourceOwner: "org1",
|
||||
},
|
||||
origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"},
|
||||
code: "123",
|
||||
urlTmpl: "",
|
||||
authRequestID: "authRequestID",
|
||||
},
|
||||
want: ¬ifyResult{
|
||||
url: "https://example.com/ui/login/mail/verification?authRequestID=authRequestID&code=123&orgID=org1&userID=user1",
|
||||
args: map[string]interface{}{"Code": "123"},
|
||||
messageType: domain.VerifyEmailMessageType,
|
||||
allowUnverifiedNotificationChannel: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template error",
|
||||
args: args{
|
||||
user: &query.NotifyUser{
|
||||
ID: "user1",
|
||||
ResourceOwner: "org1",
|
||||
},
|
||||
origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"},
|
||||
code: "123",
|
||||
urlTmpl: "{{",
|
||||
authRequestID: "authRequestID",
|
||||
},
|
||||
want: ¬ifyResult{},
|
||||
wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
|
||||
},
|
||||
{
|
||||
name: "template success",
|
||||
args: args{
|
||||
user: &query.NotifyUser{
|
||||
ID: "user1",
|
||||
ResourceOwner: "org1",
|
||||
},
|
||||
origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"},
|
||||
code: "123",
|
||||
urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
|
||||
authRequestID: "authRequestID",
|
||||
},
|
||||
want: ¬ifyResult{
|
||||
url: "https://example.com/email/verify?userID=user1&code=123&orgID=org1",
|
||||
args: map[string]interface{}{"Code": "123"},
|
||||
messageType: domain.VerifyEmailMessageType,
|
||||
allowUnverifiedNotificationChannel: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, notify := mockNotify()
|
||||
err := notify.SendEmailVerificationCode(http_utils.WithDomainContext(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.urlTmpl, tt.args.authRequestID)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
17
apps/api/internal/notification/types/init_code.go
Normal file
17
apps/api/internal/notification/types/init_code.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
func (notify Notify) SendUserInitCode(ctx context.Context, user *query.NotifyUser, code, authRequestID string) error {
|
||||
url := login.InitUserLink(http_utils.DomainContext(ctx).Origin(), user.ID, user.PreferredLoginName, code, user.ResourceOwner, user.PasswordSet, authRequestID)
|
||||
args := make(map[string]interface{})
|
||||
args["Code"] = code
|
||||
return notify(url, args, domain.InitCodeMessageType, true)
|
||||
}
|
31
apps/api/internal/notification/types/invite_code.go
Normal file
31
apps/api/internal/notification/types/invite_code.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
func (notify Notify) SendInviteCode(ctx context.Context, user *query.NotifyUser, code, applicationName, urlTmpl, authRequestID string) error {
|
||||
var url string
|
||||
if applicationName == "" {
|
||||
applicationName = "ZITADEL"
|
||||
}
|
||||
if urlTmpl == "" {
|
||||
url = login.InviteUserLink(http_utils.DomainContext(ctx).Origin(), user.ID, user.PreferredLoginName, code, user.ResourceOwner, authRequestID)
|
||||
} else {
|
||||
var buf strings.Builder
|
||||
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
|
||||
return err
|
||||
}
|
||||
url = buf.String()
|
||||
}
|
||||
args := make(map[string]interface{})
|
||||
args["Code"] = code
|
||||
args["ApplicationName"] = applicationName
|
||||
return notify(url, args, domain.InviteUserMessageType, true)
|
||||
}
|
171
apps/api/internal/notification/types/notification.go
Normal file
171
apps/api/internal/notification/types/notification.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html"
|
||||
"strings"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/i18n"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/email"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/set"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/sms"
|
||||
"github.com/zitadel/zitadel/internal/notification/channels/webhook"
|
||||
"github.com/zitadel/zitadel/internal/notification/senders"
|
||||
"github.com/zitadel/zitadel/internal/notification/templates"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
type Notify func(
|
||||
url string,
|
||||
args map[string]interface{},
|
||||
messageType string,
|
||||
allowUnverifiedNotificationChannel bool,
|
||||
) error
|
||||
|
||||
type ChannelChains interface {
|
||||
Email(context.Context) (*senders.Chain, *email.Config, error)
|
||||
SMS(context.Context) (*senders.Chain, *sms.Config, error)
|
||||
Webhook(context.Context, webhook.Config) (*senders.Chain, error)
|
||||
SecurityTokenEvent(context.Context, set.Config) (*senders.Chain, error)
|
||||
}
|
||||
|
||||
func SendEmail(
|
||||
ctx context.Context,
|
||||
channels ChannelChains,
|
||||
mailhtml string,
|
||||
translator *i18n.Translator,
|
||||
user *query.NotifyUser,
|
||||
colors *query.LabelPolicy,
|
||||
triggeringEventType eventstore.EventType,
|
||||
) Notify {
|
||||
return func(
|
||||
urlTmpl string,
|
||||
args map[string]interface{},
|
||||
messageType string,
|
||||
allowUnverifiedNotificationChannel bool,
|
||||
) error {
|
||||
args = mapNotifyUserToArgs(user, args)
|
||||
sanitizeArgsForHTML(args)
|
||||
url, err := urlFromTemplate(urlTmpl, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors)
|
||||
template, err := templates.GetParsedTemplate(mailhtml, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return generateEmail(
|
||||
ctx,
|
||||
channels,
|
||||
user,
|
||||
template,
|
||||
data,
|
||||
args,
|
||||
allowUnverifiedNotificationChannel,
|
||||
triggeringEventType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeArgsForHTML(args map[string]any) {
|
||||
for key, arg := range args {
|
||||
switch a := arg.(type) {
|
||||
case string:
|
||||
args[key] = html.EscapeString(a)
|
||||
case []string:
|
||||
for i, s := range a {
|
||||
a[i] = html.EscapeString(s)
|
||||
}
|
||||
case database.TextArray[string]:
|
||||
for i, s := range a {
|
||||
a[i] = html.EscapeString(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func urlFromTemplate(urlTmpl string, args map[string]interface{}) (string, error) {
|
||||
var buf strings.Builder
|
||||
if err := domain.RenderURLTemplate(&buf, urlTmpl, args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func SendSMS(
|
||||
ctx context.Context,
|
||||
channels ChannelChains,
|
||||
translator *i18n.Translator,
|
||||
user *query.NotifyUser,
|
||||
colors *query.LabelPolicy,
|
||||
triggeringEventType eventstore.EventType,
|
||||
instanceID string,
|
||||
jobID string,
|
||||
generatorInfo *senders.CodeGeneratorInfo,
|
||||
) Notify {
|
||||
return func(
|
||||
urlTmpl string,
|
||||
args map[string]interface{},
|
||||
messageType string,
|
||||
allowUnverifiedNotificationChannel bool,
|
||||
) error {
|
||||
args = mapNotifyUserToArgs(user, args)
|
||||
url, err := urlFromTemplate(urlTmpl, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors)
|
||||
return generateSms(
|
||||
ctx,
|
||||
channels,
|
||||
user,
|
||||
data,
|
||||
args,
|
||||
allowUnverifiedNotificationChannel,
|
||||
triggeringEventType,
|
||||
instanceID,
|
||||
jobID,
|
||||
generatorInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func SendJSON(
|
||||
ctx context.Context,
|
||||
webhookConfig webhook.Config,
|
||||
channels ChannelChains,
|
||||
serializable interface{},
|
||||
triggeringEventType eventstore.EventType,
|
||||
) Notify {
|
||||
return func(_ string, _ map[string]interface{}, _ string, _ bool) error {
|
||||
return handleWebhook(
|
||||
ctx,
|
||||
webhookConfig,
|
||||
channels,
|
||||
serializable,
|
||||
triggeringEventType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func SendSecurityTokenEvent(
|
||||
ctx context.Context,
|
||||
setConfig set.Config,
|
||||
channels ChannelChains,
|
||||
token any,
|
||||
triggeringEventType eventstore.EventType,
|
||||
) Notify {
|
||||
return func(_ string, _ map[string]interface{}, _ string, _ bool) error {
|
||||
return handleSecurityTokenEvent(
|
||||
ctx,
|
||||
setConfig,
|
||||
channels,
|
||||
token,
|
||||
triggeringEventType,
|
||||
)
|
||||
}
|
||||
}
|
29
apps/api/internal/notification/types/otp.go
Normal file
29
apps/api/internal/notification/types/otp.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
)
|
||||
|
||||
func (notify Notify) SendOTPSMSCode(ctx context.Context, code string, expiry time.Duration) error {
|
||||
args := otpArgs(ctx, code, expiry)
|
||||
return notify("", args, domain.VerifySMSOTPMessageType, false)
|
||||
}
|
||||
|
||||
func (notify Notify) SendOTPEmailCode(ctx context.Context, url, code string, expiry time.Duration) error {
|
||||
args := otpArgs(ctx, code, expiry)
|
||||
return notify(url, args, domain.VerifyEmailOTPMessageType, false)
|
||||
}
|
||||
|
||||
func otpArgs(ctx context.Context, code string, expiry time.Duration) map[string]interface{} {
|
||||
domainCtx := http_utils.DomainContext(ctx)
|
||||
args := make(map[string]interface{})
|
||||
args["OTP"] = code
|
||||
args["Origin"] = domainCtx.Origin()
|
||||
args["Domain"] = domainCtx.RequestedDomain()
|
||||
args["Expiry"] = expiry
|
||||
return args
|
||||
}
|
16
apps/api/internal/notification/types/password_change.go
Normal file
16
apps/api/internal/notification/types/password_change.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/console"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
func (notify Notify) SendPasswordChange(ctx context.Context, user *query.NotifyUser) error {
|
||||
url := console.LoginHintLink(http_utils.DomainContext(ctx).Origin(), user.PreferredLoginName)
|
||||
args := make(map[string]interface{})
|
||||
return notify(url, args, domain.PasswordChangeMessageType, true)
|
||||
}
|
27
apps/api/internal/notification/types/password_code.go
Normal file
27
apps/api/internal/notification/types/password_code.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
func (notify Notify) SendPasswordCode(ctx context.Context, user *query.NotifyUser, code, urlTmpl, authRequestID string) error {
|
||||
var url string
|
||||
if urlTmpl == "" {
|
||||
url = login.InitPasswordLink(http_utils.DomainContext(ctx).Origin(), user.ID, code, user.ResourceOwner, authRequestID)
|
||||
} else {
|
||||
var buf strings.Builder
|
||||
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
|
||||
return err
|
||||
}
|
||||
url = buf.String()
|
||||
}
|
||||
args := make(map[string]interface{})
|
||||
args["Code"] = code
|
||||
return notify(url, args, domain.PasswordResetMessageType, true)
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
func (notify Notify) SendPasswordlessRegistrationLink(ctx context.Context, user *query.NotifyUser, code, codeID, urlTmpl string) error {
|
||||
var url string
|
||||
if urlTmpl == "" {
|
||||
url = domain.PasswordlessInitCodeLink(http_utils.DomainContext(ctx).Origin()+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code)
|
||||
} else {
|
||||
var buf strings.Builder
|
||||
if err := domain.RenderPasskeyURLTemplate(&buf, urlTmpl, user.ID, user.ResourceOwner, codeID, code); err != nil {
|
||||
return err
|
||||
}
|
||||
url = buf.String()
|
||||
}
|
||||
return notify(url, nil, domain.PasswordlessRegistrationMessageType, true)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user