feat: add SCIM 2.0 syncer (#4909)

This commit is contained in:
Yang Luo
2026-01-27 01:47:50 +08:00
parent 639a8a47b1
commit fcea1e4c07
6 changed files with 626 additions and 8 deletions

View File

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

View File

@@ -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"))} :

View File

@@ -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",

View File

@@ -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",