forked from casdoor/casdoor
feat: add SCIM 2.0 syncer (#4909)
This commit is contained in:
@@ -53,6 +53,8 @@ func GetSyncerProvider(syncer *Syncer) SyncerProvider {
|
||||
return &LarkSyncerProvider{Syncer: syncer}
|
||||
case "Okta":
|
||||
return &OktaSyncerProvider{Syncer: syncer}
|
||||
case "SCIM":
|
||||
return &SCIMSyncerProvider{Syncer: syncer}
|
||||
case "Keycloak":
|
||||
return &KeycloakSyncerProvider{
|
||||
DatabaseSyncerProvider: DatabaseSyncerProvider{Syncer: syncer},
|
||||
|
||||
337
object/syncer_scim.go
Normal file
337
object/syncer_scim.go
Normal file
@@ -0,0 +1,337 @@
|
||||
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/casdoor/util"
|
||||
)
|
||||
|
||||
// SCIMSyncerProvider implements SyncerProvider for SCIM 2.0 API-based syncers
|
||||
type SCIMSyncerProvider struct {
|
||||
Syncer *Syncer
|
||||
}
|
||||
|
||||
// InitAdapter initializes the SCIM syncer (no database adapter needed)
|
||||
func (p *SCIMSyncerProvider) InitAdapter() error {
|
||||
// SCIM syncer doesn't need database adapter
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOriginalUsers retrieves all users from SCIM API
|
||||
func (p *SCIMSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
|
||||
return p.getSCIMUsers()
|
||||
}
|
||||
|
||||
// AddUser adds a new user to SCIM (not supported for read-only API)
|
||||
func (p *SCIMSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
|
||||
// SCIM syncer is typically read-only
|
||||
return false, fmt.Errorf("adding users to SCIM is not supported")
|
||||
}
|
||||
|
||||
// UpdateUser updates an existing user in SCIM (not supported for read-only API)
|
||||
func (p *SCIMSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
|
||||
// SCIM syncer is typically read-only
|
||||
return false, fmt.Errorf("updating users in SCIM is not supported")
|
||||
}
|
||||
|
||||
// TestConnection tests the SCIM API connection
|
||||
func (p *SCIMSyncerProvider) TestConnection() error {
|
||||
// Test by trying to fetch users with a limit of 1
|
||||
endpoint := p.buildSCIMEndpoint()
|
||||
endpoint = fmt.Sprintf("%s?startIndex=1&count=1", endpoint)
|
||||
|
||||
req, err := p.createSCIMRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("SCIM connection test failed: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes any open connections (no-op for SCIM API-based syncer)
|
||||
func (p *SCIMSyncerProvider) Close() error {
|
||||
// SCIM syncer doesn't maintain persistent connections
|
||||
return nil
|
||||
}
|
||||
|
||||
// SCIMName represents a SCIM user name structure
|
||||
type SCIMName struct {
|
||||
FamilyName string `json:"familyName"`
|
||||
GivenName string `json:"givenName"`
|
||||
Formatted string `json:"formatted"`
|
||||
}
|
||||
|
||||
// SCIMEmail represents a SCIM user email structure
|
||||
type SCIMEmail struct {
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
// SCIMPhoneNumber represents a SCIM user phone number structure
|
||||
type SCIMPhoneNumber struct {
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
// SCIMAddress represents a SCIM user address structure
|
||||
type SCIMAddress struct {
|
||||
StreetAddress string `json:"streetAddress"`
|
||||
Locality string `json:"locality"`
|
||||
Region string `json:"region"`
|
||||
PostalCode string `json:"postalCode"`
|
||||
Country string `json:"country"`
|
||||
Formatted string `json:"formatted"`
|
||||
Type string `json:"type"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
// SCIMUser represents a SCIM 2.0 user resource
|
||||
type SCIMUser struct {
|
||||
ID string `json:"id"`
|
||||
ExternalID string `json:"externalId"`
|
||||
UserName string `json:"userName"`
|
||||
Name SCIMName `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
NickName string `json:"nickName"`
|
||||
ProfileURL string `json:"profileUrl"`
|
||||
Title string `json:"title"`
|
||||
UserType string `json:"userType"`
|
||||
PreferredLan string `json:"preferredLanguage"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
Active bool `json:"active"`
|
||||
Emails []SCIMEmail `json:"emails"`
|
||||
PhoneNumbers []SCIMPhoneNumber `json:"phoneNumbers"`
|
||||
Addresses []SCIMAddress `json:"addresses"`
|
||||
}
|
||||
|
||||
// SCIMListResponse represents a SCIM list response
|
||||
type SCIMListResponse struct {
|
||||
TotalResults int `json:"totalResults"`
|
||||
ItemsPerPage int `json:"itemsPerPage"`
|
||||
StartIndex int `json:"startIndex"`
|
||||
Resources []*SCIMUser `json:"Resources"`
|
||||
}
|
||||
|
||||
// buildSCIMEndpoint builds the SCIM API endpoint URL
|
||||
func (p *SCIMSyncerProvider) buildSCIMEndpoint() string {
|
||||
// syncer.Host should be the SCIM server URL (e.g., https://example.com/scim/v2)
|
||||
host := strings.TrimSuffix(p.Syncer.Host, "/")
|
||||
return fmt.Sprintf("%s/Users", host)
|
||||
}
|
||||
|
||||
// createSCIMRequest creates an HTTP request with proper authentication
|
||||
func (p *SCIMSyncerProvider) createSCIMRequest(method, url string, body io.Reader) (*http.Request, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set SCIM headers
|
||||
req.Header.Set("Content-Type", "application/scim+json")
|
||||
req.Header.Set("Accept", "application/scim+json")
|
||||
|
||||
// Add authentication
|
||||
// syncer.User should be the authentication token or username
|
||||
// syncer.Password should be the password or API key
|
||||
if p.Syncer.User != "" && p.Syncer.Password != "" {
|
||||
// Try Basic Auth
|
||||
req.SetBasicAuth(p.Syncer.User, p.Syncer.Password)
|
||||
} else if p.Syncer.Password != "" {
|
||||
// Try Bearer token (assuming password field contains the token)
|
||||
req.Header.Set("Authorization", "Bearer "+p.Syncer.Password)
|
||||
} else if p.Syncer.User != "" {
|
||||
// Try Bearer token (assuming user field contains the token)
|
||||
req.Header.Set("Authorization", "Bearer "+p.Syncer.User)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// getSCIMUsers retrieves all users from SCIM API with pagination
|
||||
func (p *SCIMSyncerProvider) getSCIMUsers() ([]*OriginalUser, error) {
|
||||
allUsers := []*SCIMUser{}
|
||||
startIndex := 1
|
||||
count := 100 // Fetch 100 users per page
|
||||
|
||||
for {
|
||||
endpoint := p.buildSCIMEndpoint()
|
||||
endpoint = fmt.Sprintf("%s?startIndex=%d&count=%d", endpoint, startIndex, count)
|
||||
|
||||
req, err := p.createSCIMRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("failed to get users: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var listResp SCIMListResponse
|
||||
err = json.Unmarshal(body, &listResp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allUsers = append(allUsers, listResp.Resources...)
|
||||
|
||||
// Check if we've fetched all users
|
||||
if len(allUsers) >= listResp.TotalResults {
|
||||
break
|
||||
}
|
||||
|
||||
// Move to the next page
|
||||
startIndex += count
|
||||
}
|
||||
|
||||
// Convert SCIM users to Casdoor OriginalUser
|
||||
originalUsers := []*OriginalUser{}
|
||||
for _, scimUser := range allUsers {
|
||||
originalUser := p.scimUserToOriginalUser(scimUser)
|
||||
originalUsers = append(originalUsers, originalUser)
|
||||
}
|
||||
|
||||
return originalUsers, nil
|
||||
}
|
||||
|
||||
// scimUserToOriginalUser converts SCIM user to Casdoor OriginalUser
|
||||
func (p *SCIMSyncerProvider) scimUserToOriginalUser(scimUser *SCIMUser) *OriginalUser {
|
||||
user := &OriginalUser{
|
||||
Id: scimUser.ID,
|
||||
ExternalId: scimUser.ExternalID,
|
||||
Name: scimUser.UserName,
|
||||
DisplayName: scimUser.DisplayName,
|
||||
FirstName: scimUser.Name.GivenName,
|
||||
LastName: scimUser.Name.FamilyName,
|
||||
Title: scimUser.Title,
|
||||
Language: scimUser.PreferredLan,
|
||||
Address: []string{},
|
||||
Properties: map[string]string{},
|
||||
Groups: []string{},
|
||||
}
|
||||
|
||||
// If display name is from name structure
|
||||
if user.DisplayName == "" && scimUser.Name.Formatted != "" {
|
||||
user.DisplayName = scimUser.Name.Formatted
|
||||
}
|
||||
|
||||
// If display name is still empty, construct from first and last name
|
||||
if user.DisplayName == "" && (user.FirstName != "" || user.LastName != "") {
|
||||
user.DisplayName = strings.TrimSpace(fmt.Sprintf("%s %s", user.FirstName, user.LastName))
|
||||
}
|
||||
|
||||
// Extract primary email or first email
|
||||
if len(scimUser.Emails) > 0 {
|
||||
for _, email := range scimUser.Emails {
|
||||
if email.Primary {
|
||||
user.Email = email.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
// If no primary email, use the first one
|
||||
if user.Email == "" && len(scimUser.Emails) > 0 {
|
||||
user.Email = scimUser.Emails[0].Value
|
||||
}
|
||||
}
|
||||
|
||||
// Extract primary phone or first phone
|
||||
if len(scimUser.PhoneNumbers) > 0 {
|
||||
for _, phone := range scimUser.PhoneNumbers {
|
||||
if phone.Primary {
|
||||
user.Phone = phone.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
// If no primary phone, use the first one
|
||||
if user.Phone == "" && len(scimUser.PhoneNumbers) > 0 {
|
||||
user.Phone = scimUser.PhoneNumbers[0].Value
|
||||
}
|
||||
}
|
||||
|
||||
// Extract primary address or first address
|
||||
if len(scimUser.Addresses) > 0 {
|
||||
for _, addr := range scimUser.Addresses {
|
||||
if addr.Primary {
|
||||
if addr.Formatted != "" {
|
||||
user.Address = []string{addr.Formatted}
|
||||
} else {
|
||||
user.Address = []string{addr.StreetAddress, addr.Locality, addr.Region, addr.PostalCode, addr.Country}
|
||||
}
|
||||
user.Location = addr.Locality
|
||||
user.Region = addr.Region
|
||||
break
|
||||
}
|
||||
}
|
||||
// If no primary address, use the first one
|
||||
if len(user.Address) == 0 && len(scimUser.Addresses) > 0 {
|
||||
addr := scimUser.Addresses[0]
|
||||
if addr.Formatted != "" {
|
||||
user.Address = []string{addr.Formatted}
|
||||
} else {
|
||||
user.Address = []string{addr.StreetAddress, addr.Locality, addr.Region, addr.PostalCode, addr.Country}
|
||||
}
|
||||
user.Location = addr.Locality
|
||||
user.Region = addr.Region
|
||||
}
|
||||
}
|
||||
|
||||
// Set IsForbidden based on Active status
|
||||
user.IsForbidden = !scimUser.Active
|
||||
|
||||
// Set CreatedTime to current time if not set
|
||||
if user.CreatedTime == "" {
|
||||
user.CreatedTime = util.GetCurrentTime()
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
198
object/syncer_scim_test.go
Normal file
198
object/syncer_scim_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSCIMUserToOriginalUser(t *testing.T) {
|
||||
provider := &SCIMSyncerProvider{
|
||||
Syncer: &Syncer{
|
||||
Host: "https://example.com/scim/v2",
|
||||
User: "testuser",
|
||||
Password: "testtoken",
|
||||
},
|
||||
}
|
||||
|
||||
// Test case 1: Full SCIM user with all fields
|
||||
scimUser := &SCIMUser{
|
||||
ID: "user-123",
|
||||
ExternalID: "ext-123",
|
||||
UserName: "john.doe",
|
||||
DisplayName: "John Doe",
|
||||
Name: SCIMName{
|
||||
GivenName: "John",
|
||||
FamilyName: "Doe",
|
||||
Formatted: "John Doe",
|
||||
},
|
||||
Title: "Software Engineer",
|
||||
PreferredLan: "en-US",
|
||||
Active: true,
|
||||
Emails: []SCIMEmail{
|
||||
{Value: "john.doe@example.com", Primary: true, Type: "work"},
|
||||
{Value: "john@personal.com", Primary: false, Type: "home"},
|
||||
},
|
||||
PhoneNumbers: []SCIMPhoneNumber{
|
||||
{Value: "+1-555-1234", Primary: true, Type: "work"},
|
||||
{Value: "+1-555-5678", Primary: false, Type: "mobile"},
|
||||
},
|
||||
Addresses: []SCIMAddress{
|
||||
{
|
||||
StreetAddress: "123 Main St",
|
||||
Locality: "San Francisco",
|
||||
Region: "CA",
|
||||
PostalCode: "94102",
|
||||
Country: "USA",
|
||||
Formatted: "123 Main St, San Francisco, CA 94102, USA",
|
||||
Primary: true,
|
||||
Type: "work",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
originalUser := provider.scimUserToOriginalUser(scimUser)
|
||||
|
||||
// Verify basic fields
|
||||
if originalUser.Id != "user-123" {
|
||||
t.Errorf("Expected Id to be 'user-123', got '%s'", originalUser.Id)
|
||||
}
|
||||
if originalUser.ExternalId != "ext-123" {
|
||||
t.Errorf("Expected ExternalId to be 'ext-123', got '%s'", originalUser.ExternalId)
|
||||
}
|
||||
if originalUser.Name != "john.doe" {
|
||||
t.Errorf("Expected Name to be 'john.doe', got '%s'", originalUser.Name)
|
||||
}
|
||||
if originalUser.DisplayName != "John Doe" {
|
||||
t.Errorf("Expected DisplayName to be 'John Doe', got '%s'", originalUser.DisplayName)
|
||||
}
|
||||
if originalUser.FirstName != "John" {
|
||||
t.Errorf("Expected FirstName to be 'John', got '%s'", originalUser.FirstName)
|
||||
}
|
||||
if originalUser.LastName != "Doe" {
|
||||
t.Errorf("Expected LastName to be 'Doe', got '%s'", originalUser.LastName)
|
||||
}
|
||||
if originalUser.Title != "Software Engineer" {
|
||||
t.Errorf("Expected Title to be 'Software Engineer', got '%s'", originalUser.Title)
|
||||
}
|
||||
if originalUser.Language != "en-US" {
|
||||
t.Errorf("Expected Language to be 'en-US', got '%s'", originalUser.Language)
|
||||
}
|
||||
|
||||
// Verify primary email is selected
|
||||
if originalUser.Email != "john.doe@example.com" {
|
||||
t.Errorf("Expected Email to be 'john.doe@example.com', got '%s'", originalUser.Email)
|
||||
}
|
||||
|
||||
// Verify primary phone is selected
|
||||
if originalUser.Phone != "+1-555-1234" {
|
||||
t.Errorf("Expected Phone to be '+1-555-1234', got '%s'", originalUser.Phone)
|
||||
}
|
||||
|
||||
// Verify address fields
|
||||
if originalUser.Location != "San Francisco" {
|
||||
t.Errorf("Expected Location to be 'San Francisco', got '%s'", originalUser.Location)
|
||||
}
|
||||
if originalUser.Region != "CA" {
|
||||
t.Errorf("Expected Region to be 'CA', got '%s'", originalUser.Region)
|
||||
}
|
||||
|
||||
// Verify active status is inverted to IsForbidden
|
||||
if originalUser.IsForbidden != false {
|
||||
t.Errorf("Expected IsForbidden to be false for active user, got %v", originalUser.IsForbidden)
|
||||
}
|
||||
|
||||
// Test case 2: Inactive SCIM user
|
||||
inactiveUser := &SCIMUser{
|
||||
ID: "user-456",
|
||||
UserName: "jane.doe",
|
||||
Active: false,
|
||||
}
|
||||
|
||||
inactiveOriginalUser := provider.scimUserToOriginalUser(inactiveUser)
|
||||
if inactiveOriginalUser.IsForbidden != true {
|
||||
t.Errorf("Expected IsForbidden to be true for inactive user, got %v", inactiveOriginalUser.IsForbidden)
|
||||
}
|
||||
|
||||
// Test case 3: SCIM user with no primary email/phone (should use first)
|
||||
noPrimaryUser := &SCIMUser{
|
||||
ID: "user-789",
|
||||
UserName: "bob.smith",
|
||||
Emails: []SCIMEmail{
|
||||
{Value: "bob@example.com", Primary: false, Type: "work"},
|
||||
{Value: "bob@personal.com", Primary: false, Type: "home"},
|
||||
},
|
||||
PhoneNumbers: []SCIMPhoneNumber{
|
||||
{Value: "+1-555-9999", Primary: false, Type: "work"},
|
||||
},
|
||||
}
|
||||
|
||||
noPrimaryOriginalUser := provider.scimUserToOriginalUser(noPrimaryUser)
|
||||
if noPrimaryOriginalUser.Email != "bob@example.com" {
|
||||
t.Errorf("Expected first email when no primary, got '%s'", noPrimaryOriginalUser.Email)
|
||||
}
|
||||
if noPrimaryOriginalUser.Phone != "+1-555-9999" {
|
||||
t.Errorf("Expected first phone when no primary, got '%s'", noPrimaryOriginalUser.Phone)
|
||||
}
|
||||
|
||||
// Test case 4: Display name construction from first/last name when empty
|
||||
noDisplayNameUser := &SCIMUser{
|
||||
ID: "user-101",
|
||||
UserName: "alice.jones",
|
||||
Name: SCIMName{
|
||||
GivenName: "Alice",
|
||||
FamilyName: "Jones",
|
||||
},
|
||||
}
|
||||
|
||||
noDisplayNameOriginalUser := provider.scimUserToOriginalUser(noDisplayNameUser)
|
||||
if noDisplayNameOriginalUser.DisplayName != "Alice Jones" {
|
||||
t.Errorf("Expected DisplayName to be constructed as 'Alice Jones', got '%s'", noDisplayNameOriginalUser.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCIMBuildEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
host string
|
||||
expected string
|
||||
}{
|
||||
{"https://example.com/scim/v2", "https://example.com/scim/v2/Users"},
|
||||
{"https://example.com/scim/v2/", "https://example.com/scim/v2/Users"},
|
||||
{"http://localhost:8080/scim", "http://localhost:8080/scim/Users"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
provider := &SCIMSyncerProvider{
|
||||
Syncer: &Syncer{Host: test.host},
|
||||
}
|
||||
endpoint := provider.buildSCIMEndpoint()
|
||||
if endpoint != test.expected {
|
||||
t.Errorf("For host '%s', expected endpoint '%s', got '%s'", test.host, test.expected, endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSyncerProviderSCIM(t *testing.T) {
|
||||
syncer := &Syncer{
|
||||
Type: "SCIM",
|
||||
Host: "https://example.com/scim/v2",
|
||||
}
|
||||
|
||||
provider := GetSyncerProvider(syncer)
|
||||
|
||||
if _, ok := provider.(*SCIMSyncerProvider); !ok {
|
||||
t.Errorf("Expected SCIMSyncerProvider for type 'SCIM', got %T", provider)
|
||||
}
|
||||
}
|
||||
@@ -637,6 +637,79 @@ class SyncerEditPage extends React.Component {
|
||||
"values": [],
|
||||
},
|
||||
];
|
||||
case "SCIM":
|
||||
return [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "string",
|
||||
"casdoorName": "Id",
|
||||
"isHashed": true,
|
||||
"values": [],
|
||||
},
|
||||
{
|
||||
"name": "userName",
|
||||
"type": "string",
|
||||
"casdoorName": "Name",
|
||||
"isHashed": true,
|
||||
"values": [],
|
||||
},
|
||||
{
|
||||
"name": "displayName",
|
||||
"type": "string",
|
||||
"casdoorName": "DisplayName",
|
||||
"isHashed": true,
|
||||
"values": [],
|
||||
},
|
||||
{
|
||||
"name": "name.givenName",
|
||||
"type": "string",
|
||||
"casdoorName": "FirstName",
|
||||
"isHashed": true,
|
||||
"values": [],
|
||||
},
|
||||
{
|
||||
"name": "name.familyName",
|
||||
"type": "string",
|
||||
"casdoorName": "LastName",
|
||||
"isHashed": true,
|
||||
"values": [],
|
||||
},
|
||||
{
|
||||
"name": "emails",
|
||||
"type": "string",
|
||||
"casdoorName": "Email",
|
||||
"isHashed": true,
|
||||
"values": [],
|
||||
},
|
||||
{
|
||||
"name": "phoneNumbers",
|
||||
"type": "string",
|
||||
"casdoorName": "Phone",
|
||||
"isHashed": true,
|
||||
"values": [],
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"casdoorName": "Title",
|
||||
"isHashed": true,
|
||||
"values": [],
|
||||
},
|
||||
{
|
||||
"name": "preferredLanguage",
|
||||
"type": "string",
|
||||
"casdoorName": "Language",
|
||||
"isHashed": true,
|
||||
"values": [],
|
||||
},
|
||||
{
|
||||
"name": "active",
|
||||
"type": "boolean",
|
||||
"casdoorName": "IsForbidden",
|
||||
"isHashed": true,
|
||||
"values": [],
|
||||
},
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -693,14 +766,14 @@ class SyncerEditPage extends React.Component {
|
||||
});
|
||||
})}>
|
||||
{
|
||||
["Database", "Keycloak", "WeCom", "Azure AD", "Active Directory", "Google Workspace", "DingTalk", "Lark", "Okta"]
|
||||
["Database", "Keycloak", "WeCom", "Azure AD", "Active Directory", "Google Workspace", "DingTalk", "Lark", "Okta", "SCIM"]
|
||||
.map((item, index) => <Option key={index} value={item}>{item}</Option>)
|
||||
}
|
||||
</Select>
|
||||
</Col>
|
||||
</Row>
|
||||
{
|
||||
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Active Directory" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" ? null : (
|
||||
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Active Directory" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("syncer:Database type"), i18next.t("syncer:Database type - Tooltip"))} :
|
||||
@@ -755,7 +828,7 @@ class SyncerEditPage extends React.Component {
|
||||
this.state.syncer.type === "WeCom" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" ? null : (
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(this.state.syncer.type === "Azure AD" ? i18next.t("provider:Tenant ID") : this.state.syncer.type === "Google Workspace" ? i18next.t("syncer:Admin Email") : this.state.syncer.type === "Active Directory" ? i18next.t("ldap:Server") : i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
|
||||
{Setting.getLabel(this.state.syncer.type === "Azure AD" ? i18next.t("provider:Tenant ID") : this.state.syncer.type === "Google Workspace" ? i18next.t("syncer:Admin Email") : this.state.syncer.type === "Active Directory" ? i18next.t("ldap:Server") : this.state.syncer.type === "SCIM" ? i18next.t("syncer:SCIM Server URL") : i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
<Input prefix={<LinkOutlined />} value={this.state.syncer.host} onChange={e => {
|
||||
@@ -766,7 +839,7 @@ class SyncerEditPage extends React.Component {
|
||||
)
|
||||
}
|
||||
{
|
||||
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" ? null : (
|
||||
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(this.state.syncer.type === "Active Directory" ? i18next.t("provider:LDAP port") : i18next.t("provider:Port"), i18next.t("provider:Port - Tooltip"))} :
|
||||
@@ -789,7 +862,8 @@ class SyncerEditPage extends React.Component {
|
||||
this.state.syncer.type === "Lark" ? i18next.t("provider:App ID") :
|
||||
this.state.syncer.type === "Azure AD" ? i18next.t("provider:Client ID") :
|
||||
this.state.syncer.type === "Active Directory" ? i18next.t("syncer:Bind DN") :
|
||||
i18next.t("general:User"),
|
||||
this.state.syncer.type === "SCIM" ? i18next.t("syncer:Username (optional)") :
|
||||
i18next.t("general:User"),
|
||||
i18next.t("general:User - Tooltip")
|
||||
)} :
|
||||
</Col>
|
||||
@@ -809,7 +883,8 @@ class SyncerEditPage extends React.Component {
|
||||
this.state.syncer.type === "Lark" ? i18next.t("provider:App secret") :
|
||||
this.state.syncer.type === "Azure AD" ? i18next.t("provider:Client secret") :
|
||||
this.state.syncer.type === "Google Workspace" ? i18next.t("syncer:Service account key") :
|
||||
i18next.t("general:Password"),
|
||||
this.state.syncer.type === "SCIM" ? i18next.t("syncer:API Token / Password") :
|
||||
i18next.t("general:Password"),
|
||||
i18next.t("general:Password - Tooltip")
|
||||
)} :
|
||||
</Col>
|
||||
@@ -828,7 +903,7 @@ class SyncerEditPage extends React.Component {
|
||||
</Col>
|
||||
</Row>
|
||||
{
|
||||
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" ? null : (
|
||||
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(this.state.syncer.type === "Active Directory" ? i18next.t("syncer:Base DN") : i18next.t("syncer:Database"), i18next.t("syncer:Database - Tooltip"))} :
|
||||
@@ -924,7 +999,7 @@ class SyncerEditPage extends React.Component {
|
||||
) : null
|
||||
}
|
||||
{
|
||||
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" ? null : (
|
||||
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{Setting.getLabel(i18next.t("syncer:Table"), i18next.t("syncer:Table - Tooltip"))} :
|
||||
|
||||
@@ -956,6 +956,9 @@
|
||||
"Copy": "Copy",
|
||||
"Corp ID": "Corp ID",
|
||||
"Corp Secret": "Corp Secret",
|
||||
"SCIM Server URL": "SCIM Server URL",
|
||||
"Username (optional)": "Username (optional)",
|
||||
"API Token / Password": "API Token / Password",
|
||||
"DB test": "DB test",
|
||||
"DB test - Tooltip": "DB test - Tooltip",
|
||||
"Disable SSL": "Disable SSL",
|
||||
|
||||
@@ -941,6 +941,9 @@
|
||||
"Copy": "复制",
|
||||
"Corp ID": "企业 ID",
|
||||
"Corp Secret": "企业密钥",
|
||||
"SCIM Server URL": "SCIM 服务器 URL",
|
||||
"Username (optional)": "用户名(可选)",
|
||||
"API Token / Password": "API 令牌 / 密码",
|
||||
"DB test": "DB test",
|
||||
"DB test - Tooltip": "DB test - Tooltip",
|
||||
"Disable SSL": "禁用SSL",
|
||||
|
||||
Reference in New Issue
Block a user