forked from baron/baron-sso
- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping) - Implemented idempotency and visibility restoration for soft-deleted users - Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields - Added logic to reclaim identifiers (login_id) from colliding records - Added frontend E2E and backend unit tests for UUID integrity and conflict handling - Fixed i18n, formatting, and mock tests to satisfy code-check - Applied 'go fix' for 'omitzero' tags and general Go standards
1161 lines
38 KiB
Go
1161 lines
38 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultWorksmobileOAuthScope = "directory"
|
|
)
|
|
|
|
type WorksmobileDirectoryClient interface {
|
|
CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error
|
|
UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error
|
|
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
|
|
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
|
|
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
|
|
DeleteUser(ctx context.Context, userID string) error
|
|
SetUserActive(ctx context.Context, userID string, active bool) error
|
|
ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error)
|
|
ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error)
|
|
}
|
|
|
|
type WorksmobileHTTPClient struct {
|
|
BaseURL string
|
|
DirectoryToken string
|
|
SCIMToken string
|
|
HTTPClient *http.Client
|
|
OAuthConfig WorksmobileOAuthConfig
|
|
DomainIDs []int64
|
|
OrgUnitWriteDelay time.Duration
|
|
tokenCache worksmobileAccessTokenCache
|
|
now func() time.Time
|
|
}
|
|
|
|
type WorksmobileOAuthConfig struct {
|
|
ClientID string
|
|
ClientSecret string
|
|
ServiceAccount string
|
|
PrivateKey string
|
|
Scope string
|
|
TokenURL string
|
|
}
|
|
|
|
type worksmobileAccessTokenCache struct {
|
|
Token string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
func (c WorksmobileOAuthConfig) normalized() WorksmobileOAuthConfig {
|
|
c.ClientID = strings.Trim(strings.TrimSpace(c.ClientID), `"`)
|
|
c.ClientSecret = strings.Trim(strings.TrimSpace(c.ClientSecret), `"`)
|
|
c.ServiceAccount = strings.Trim(strings.TrimSpace(c.ServiceAccount), `"`)
|
|
c.PrivateKey = normalizeWorksmobilePrivateKey(c.PrivateKey)
|
|
c.Scope = strings.TrimSpace(c.Scope)
|
|
if c.Scope == "" {
|
|
c.Scope = defaultWorksmobileOAuthScope
|
|
}
|
|
c.TokenURL = strings.TrimSpace(c.TokenURL)
|
|
return c
|
|
}
|
|
|
|
func (c WorksmobileOAuthConfig) validate() error {
|
|
if strings.TrimSpace(c.ClientID) == "" || strings.TrimSpace(c.ClientSecret) == "" {
|
|
return fmt.Errorf("worksmobile directory token is not configured")
|
|
}
|
|
if strings.TrimSpace(c.ServiceAccount) == "" || strings.TrimSpace(c.PrivateKey) == "" {
|
|
return fmt.Errorf("worksmobile oauth service account is not configured")
|
|
}
|
|
if strings.TrimSpace(c.TokenURL) == "" {
|
|
return fmt.Errorf("worksmobile oauth token url is not configured")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeWorksmobilePrivateKey(value string) string {
|
|
value = strings.Trim(strings.TrimSpace(value), `"`)
|
|
value = strings.ReplaceAll(value, `\n`, "\n")
|
|
return value
|
|
}
|
|
|
|
func buildWorksmobileJWTAssertion(config WorksmobileOAuthConfig, now time.Time) (string, error) {
|
|
privateKey, err := parseWorksmobilePrivateKey(config.PrivateKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
header := map[string]string{"alg": "RS256", "typ": "JWT"}
|
|
payload := map[string]any{
|
|
"iss": config.ClientID,
|
|
"sub": config.ServiceAccount,
|
|
"iat": now.Unix(),
|
|
"exp": now.Add(time.Hour).Unix(),
|
|
}
|
|
encodedHeader, err := encodeWorksmobileJWTPart(header)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
encodedPayload, err := encodeWorksmobileJWTPart(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
signingInput := encodedHeader + "." + encodedPayload
|
|
sum := sha256.Sum256([]byte(signingInput))
|
|
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, sum[:])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
|
}
|
|
|
|
func encodeWorksmobileJWTPart(value any) (string, error) {
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(data), nil
|
|
}
|
|
|
|
func parseWorksmobilePrivateKey(value string) (*rsa.PrivateKey, error) {
|
|
block, _ := pem.Decode([]byte(normalizeWorksmobilePrivateKey(value)))
|
|
if block == nil {
|
|
return nil, fmt.Errorf("worksmobile private key is not a valid PEM block")
|
|
}
|
|
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
|
return key, nil
|
|
}
|
|
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, ok := parsed.(*rsa.PrivateKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("worksmobile private key is not RSA")
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
type WorksmobileHTTPError struct {
|
|
StatusCode int
|
|
Body string
|
|
}
|
|
|
|
func (e WorksmobileHTTPError) Error() string {
|
|
return fmt.Sprintf("worksmobile api failed status=%d body=%s", e.StatusCode, e.Body)
|
|
}
|
|
|
|
func NewWorksmobileHTTPClient(scimToken string) *WorksmobileHTTPClient {
|
|
return &WorksmobileHTTPClient{
|
|
SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`),
|
|
}
|
|
}
|
|
|
|
func NewWorksmobileHTTPClientWithTokens(directoryToken string, scimToken string) *WorksmobileHTTPClient {
|
|
return &WorksmobileHTTPClient{
|
|
DirectoryToken: strings.Trim(strings.TrimSpace(directoryToken), `"`),
|
|
SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`),
|
|
}
|
|
}
|
|
|
|
func NewWorksmobileHTTPClientWithAuth(directoryToken string, scimToken string, oauthConfig WorksmobileOAuthConfig) *WorksmobileHTTPClient {
|
|
return &WorksmobileHTTPClient{
|
|
DirectoryToken: strings.Trim(strings.TrimSpace(directoryToken), `"`),
|
|
SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`),
|
|
OAuthConfig: oauthConfig.normalized(),
|
|
DomainIDs: WorksmobileDomainIDsFromEnv(),
|
|
}
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error {
|
|
if payload.DisplayOrder < 1 {
|
|
payload.DisplayOrder = 1
|
|
}
|
|
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/orgunits", payload)
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error {
|
|
err := c.CreateOrgUnit(ctx, payload)
|
|
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
|
|
return c.BackfillOrgUnitExternalKeyByLocalPart(ctx, payload, matchLocalPart)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) BackfillOrgUnitExternalKeyByLocalPart(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error {
|
|
groups, err := c.ListGroups(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
normalizedMatchLocalPart := worksmobileMailLocalPart(matchLocalPart)
|
|
var localPartMatch *WorksmobileRemoteGroup
|
|
for _, group := range groups {
|
|
if payload.DomainID > 0 && group.DomainID > 0 && payload.DomainID != group.DomainID {
|
|
continue
|
|
}
|
|
if group.ExternalID == payload.OrgUnitExternalKey {
|
|
if strings.TrimSpace(group.ID) == "" {
|
|
return nil
|
|
}
|
|
if delay := c.orgUnitWriteDelay(); delay > 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
return c.PatchOrgUnit(ctx, group.ID, NewWorksmobileOrgUnitPatchPayload(payload))
|
|
}
|
|
if normalizedMatchLocalPart != "" && worksmobileMailLocalPart(group.MailLocalPart) == normalizedMatchLocalPart {
|
|
matched := group
|
|
if localPartMatch != nil && localPartMatch.ID != matched.ID {
|
|
return fmt.Errorf("worksmobile orgunit local-part match is ambiguous: %s", normalizedMatchLocalPart)
|
|
}
|
|
localPartMatch = &matched
|
|
}
|
|
}
|
|
if localPartMatch != nil {
|
|
if strings.TrimSpace(localPartMatch.ID) == "" {
|
|
return nil
|
|
}
|
|
if delay := c.orgUnitWriteDelay(); delay > 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
return c.PatchOrgUnit(ctx, localPartMatch.ID, NewWorksmobileOrgUnitPatchPayload(payload))
|
|
}
|
|
return fmt.Errorf("worksmobile orgunit external key match not found after create conflict: %s", payload.OrgUnitExternalKey)
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) PatchOrgUnit(ctx context.Context, orgUnitID string, payload WorksmobileOrgUnitPatchPayload) error {
|
|
orgUnitID = strings.TrimSpace(orgUnitID)
|
|
if orgUnitID == "" {
|
|
return fmt.Errorf("worksmobile orgunit id is required")
|
|
}
|
|
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), payload)
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) ClearOrgUnitExternalKey(ctx context.Context, orgUnitID string, domainID int64) error {
|
|
orgUnitID = strings.TrimSpace(orgUnitID)
|
|
if orgUnitID == "" {
|
|
return fmt.Errorf("worksmobile orgunit id is required")
|
|
}
|
|
payload := map[string]any{
|
|
"domainId": domainID,
|
|
"orgUnitExternalKey": "",
|
|
}
|
|
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), payload)
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) DeleteOrgUnit(ctx context.Context, orgUnitID string) error {
|
|
orgUnitID = strings.TrimSpace(orgUnitID)
|
|
if orgUnitID == "" {
|
|
return fmt.Errorf("worksmobile orgunit id is required")
|
|
}
|
|
if delay := c.orgUnitWriteDelay(); delay > 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
return c.sendDirectoryJSON(ctx, http.MethodDelete, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), nil)
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
|
|
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload)
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error {
|
|
err := c.CreateUser(ctx, payload)
|
|
if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict {
|
|
identifier := strings.TrimSpace(payload.Email)
|
|
if identifier == "" {
|
|
identifier = strings.TrimSpace(payload.UserExternalKey)
|
|
}
|
|
return c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload))
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error {
|
|
identifier = strings.TrimSpace(identifier)
|
|
if identifier == "" {
|
|
return fmt.Errorf("worksmobile user identifier is required")
|
|
}
|
|
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), payload)
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) error {
|
|
userID = strings.TrimSpace(userID)
|
|
if userID == "" {
|
|
return fmt.Errorf("worksmobile user id is required")
|
|
}
|
|
remote, err := c.FindUser(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if remote == nil {
|
|
return nil
|
|
}
|
|
if c.directoryAuthConfigured() && remote.Email != "" {
|
|
err := c.sendDirectoryJSON(ctx, http.MethodDelete, "/v1.0/users/"+url.PathEscape(remote.Email), nil)
|
|
if err == nil || strings.TrimSpace(c.SCIMToken) == "" {
|
|
return err
|
|
}
|
|
}
|
|
return c.sendJSON(ctx, http.MethodDelete, "/scim/v2/Users/"+url.PathEscape(remote.ID), nil)
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) SetUserActive(ctx context.Context, userID string, active bool) error {
|
|
userID = strings.TrimSpace(userID)
|
|
if userID == "" {
|
|
return fmt.Errorf("worksmobile user id is required")
|
|
}
|
|
if strings.TrimSpace(c.SCIMToken) == "" {
|
|
return fmt.Errorf("worksmobile scim token is not configured")
|
|
}
|
|
remote, err := c.findSCIMUser(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if remote == nil {
|
|
return nil
|
|
}
|
|
return c.sendJSON(ctx, http.MethodPatch, "/scim/v2/Users/"+url.PathEscape(remote.ID), map[string]any{
|
|
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
|
"Operations": []map[string]any{
|
|
{
|
|
"op": "replace",
|
|
"path": "active",
|
|
"value": active,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) FindUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) {
|
|
users, err := c.ListUsers(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
identifier = strings.TrimSpace(identifier)
|
|
for _, user := range users {
|
|
if strings.EqualFold(user.UserName, identifier) || user.ExternalID == identifier || strings.EqualFold(user.Email, identifier) {
|
|
return &user, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) findSCIMUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) {
|
|
identifier = strings.TrimSpace(identifier)
|
|
var matched *WorksmobileRemoteUser
|
|
err := c.listSCIM(ctx, "/scim/v2/Users", func(resource map[string]any) {
|
|
if matched != nil {
|
|
return
|
|
}
|
|
user := parseWorksmobileRemoteUser(resource)
|
|
if strings.EqualFold(user.UserName, identifier) || user.ExternalID == identifier || strings.EqualFold(user.Email, identifier) {
|
|
matched = &user
|
|
}
|
|
})
|
|
return matched, err
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) {
|
|
if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 {
|
|
users, err := c.listDirectoryUsers(ctx, c.DomainIDs)
|
|
if err == nil {
|
|
return users, nil
|
|
}
|
|
if strings.TrimSpace(c.SCIMToken) == "" {
|
|
return nil, err
|
|
}
|
|
}
|
|
var users []WorksmobileRemoteUser
|
|
err := c.listSCIM(ctx, "/scim/v2/Users", func(resource map[string]any) {
|
|
users = append(users, parseWorksmobileRemoteUser(resource))
|
|
})
|
|
return users, err
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) {
|
|
if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 {
|
|
groups, err := c.listDirectoryGroups(ctx, c.DomainIDs)
|
|
if err == nil {
|
|
return groups, nil
|
|
}
|
|
if strings.TrimSpace(c.SCIMToken) == "" {
|
|
return nil, err
|
|
}
|
|
}
|
|
var groups []WorksmobileRemoteGroup
|
|
err := c.listSCIM(ctx, "/scim/v2/Groups", func(resource map[string]any) {
|
|
groups = append(groups, parseWorksmobileRemoteGroup(resource))
|
|
})
|
|
return groups, err
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) listSCIM(ctx context.Context, path string, consume func(map[string]any)) error {
|
|
startIndex := 1
|
|
count := 100
|
|
for {
|
|
var response struct {
|
|
TotalResults int `json:"totalResults"`
|
|
ItemsPerPage int `json:"itemsPerPage"`
|
|
Resources []map[string]any `json:"Resources"`
|
|
}
|
|
if err := c.getJSON(ctx, fmt.Sprintf("%s?startIndex=%d&count=%d", path, startIndex, count), &response); err != nil {
|
|
return err
|
|
}
|
|
for _, resource := range response.Resources {
|
|
consume(resource)
|
|
}
|
|
if len(response.Resources) == 0 || startIndex+len(response.Resources) > response.TotalResults {
|
|
return nil
|
|
}
|
|
startIndex += len(response.Resources)
|
|
}
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) getJSON(ctx context.Context, path string, target any) error {
|
|
token := strings.TrimSpace(c.SCIMToken)
|
|
if token == "" {
|
|
token = strings.TrimSpace(c.DirectoryToken)
|
|
}
|
|
if token == "" {
|
|
return fmt.Errorf("worksmobile read token is not configured")
|
|
}
|
|
requestURL, err := c.requestURL(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)}
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(target)
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) getDirectoryJSON(ctx context.Context, path string, target any) error {
|
|
token, err := c.directoryAccessToken(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
requestURL, err := c.requestURL(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)}
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(target)
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) listDirectoryUsers(ctx context.Context, domainIDs []int64) ([]WorksmobileRemoteUser, error) {
|
|
users := make([]WorksmobileRemoteUser, 0)
|
|
for _, domainID := range uniqueWorksmobileDomainIDs(domainIDs) {
|
|
cursor := ""
|
|
for {
|
|
path := fmt.Sprintf("/v1.0/users?domainId=%d&count=100", domainID)
|
|
if cursor != "" {
|
|
path += "&cursor=" + url.QueryEscape(cursor)
|
|
}
|
|
var response struct {
|
|
Users []map[string]any `json:"users"`
|
|
ResponseMetaData struct {
|
|
NextCursor string `json:"nextCursor"`
|
|
} `json:"responseMetaData"`
|
|
}
|
|
if err := c.getDirectoryJSON(ctx, path, &response); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, raw := range response.Users {
|
|
user := parseWorksmobileDirectoryUser(raw)
|
|
user.DomainID = domainID
|
|
user.DomainName = WorksmobileDomainLabelForID(domainID)
|
|
users = append(users, user)
|
|
}
|
|
cursor = strings.TrimSpace(response.ResponseMetaData.NextCursor)
|
|
if cursor == "" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) listDirectoryGroups(ctx context.Context, domainIDs []int64) ([]WorksmobileRemoteGroup, error) {
|
|
groups := make([]WorksmobileRemoteGroup, 0)
|
|
for _, domainID := range uniqueWorksmobileDomainIDs(domainIDs) {
|
|
cursor := ""
|
|
for {
|
|
path := fmt.Sprintf("/v1.0/orgunits?domainId=%d&count=100", domainID)
|
|
if cursor != "" {
|
|
path += "&cursor=" + url.QueryEscape(cursor)
|
|
}
|
|
var response struct {
|
|
OrgUnits []map[string]any `json:"orgUnits"`
|
|
ResponseMetaData struct {
|
|
NextCursor string `json:"nextCursor"`
|
|
} `json:"responseMetaData"`
|
|
}
|
|
if err := c.getDirectoryJSON(ctx, path, &response); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, raw := range response.OrgUnits {
|
|
group := parseWorksmobileDirectoryGroup(raw)
|
|
group.DomainID = domainID
|
|
group.DomainName = WorksmobileDomainLabelForID(domainID)
|
|
groups = append(groups, group)
|
|
}
|
|
cursor = strings.TrimSpace(response.ResponseMetaData.NextCursor)
|
|
if cursor == "" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return groups, nil
|
|
}
|
|
|
|
func uniqueWorksmobileDomainIDs(domainIDs []int64) []int64 {
|
|
result := make([]int64, 0, len(domainIDs))
|
|
seen := map[int64]bool{}
|
|
for _, id := range domainIDs {
|
|
if id <= 0 || seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
result = append(result, id)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) sendJSON(ctx context.Context, method string, path string, payload any) error {
|
|
token := strings.TrimSpace(c.SCIMToken)
|
|
if token == "" {
|
|
return fmt.Errorf("worksmobile scim token is not configured")
|
|
}
|
|
return c.sendJSONWithToken(ctx, method, path, payload, token)
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) sendDirectoryJSON(ctx context.Context, method string, path string, payload any) error {
|
|
token, err := c.directoryAccessToken(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.sendJSONWithToken(ctx, method, path, payload, token)
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) directoryAccessToken(ctx context.Context) (string, error) {
|
|
if token := strings.TrimSpace(c.DirectoryToken); token != "" {
|
|
return token, nil
|
|
}
|
|
now := c.currentTime()
|
|
if c.tokenCache.Token != "" && now.Before(c.tokenCache.ExpiresAt) {
|
|
return c.tokenCache.Token, nil
|
|
}
|
|
token, expiresAt, err := c.requestDirectoryAccessToken(ctx, now)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
c.tokenCache = worksmobileAccessTokenCache{Token: token, ExpiresAt: expiresAt}
|
|
return token, nil
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) directoryAuthConfigured() bool {
|
|
if strings.TrimSpace(c.DirectoryToken) != "" {
|
|
return true
|
|
}
|
|
return c.OAuthConfig.normalized().validate() == nil
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) requestDirectoryAccessToken(ctx context.Context, now time.Time) (string, time.Time, error) {
|
|
config := c.OAuthConfig.normalized()
|
|
if err := config.validate(); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
assertion, err := buildWorksmobileJWTAssertion(config, now)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
form := url.Values{}
|
|
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
|
form.Set("assertion", assertion)
|
|
form.Set("client_id", config.ClientID)
|
|
form.Set("client_secret", config.ClientSecret)
|
|
form.Set("scope", config.Scope)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.TokenURL, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return "", time.Time{}, WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)}
|
|
}
|
|
var tokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn any `json:"expires_in"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
if strings.TrimSpace(tokenResponse.AccessToken) == "" {
|
|
return "", time.Time{}, fmt.Errorf("worksmobile token response is missing access_token")
|
|
}
|
|
expiresIn := worksmobileTokenExpiresIn(tokenResponse.ExpiresIn)
|
|
if expiresIn <= 0 {
|
|
expiresIn = 3600
|
|
}
|
|
return strings.TrimSpace(tokenResponse.AccessToken), now.Add(time.Duration(expiresIn-60) * time.Second), nil
|
|
}
|
|
|
|
func worksmobileTokenExpiresIn(raw any) int64 {
|
|
switch value := raw.(type) {
|
|
case float64:
|
|
return int64(value)
|
|
case int64:
|
|
return value
|
|
case string:
|
|
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
|
return parsed
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) sendJSONWithToken(ctx context.Context, method string, path string, payload any, token string) error {
|
|
var body io.Reader
|
|
if payload != nil {
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body = bytes.NewReader(data)
|
|
}
|
|
|
|
requestURL, err := c.requestURL(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, requestURL, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token))
|
|
if payload != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return nil
|
|
}
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)}
|
|
}
|
|
|
|
const worksmobileSCIMUserExtensionSchema = "urn:ietf:params:scim:schemas:extension:works:2.0:User"
|
|
|
|
type WorksmobileSCIMUserPayload struct {
|
|
Schemas []string `json:"schemas"`
|
|
UserName string `json:"userName"`
|
|
ExternalID string `json:"externalId"`
|
|
DisplayName string `json:"displayName"`
|
|
Name WorksmobileSCIMName `json:"name"`
|
|
Emails []WorksmobileSCIMEmail `json:"emails"`
|
|
PhoneNumbers []WorksmobileSCIMPhoneNumber `json:"phoneNumbers,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
Active bool `json:"active"`
|
|
PreferredLanguage string `json:"preferredLanguage,omitempty"`
|
|
WorksExtension map[string]any `json:"urn:ietf:params:scim:schemas:extension:works:2.0:User,omitempty"`
|
|
}
|
|
|
|
type WorksmobileSCIMName struct {
|
|
FamilyName string `json:"familyName"`
|
|
}
|
|
|
|
type WorksmobileSCIMEmail struct {
|
|
Value string `json:"value"`
|
|
Primary bool `json:"primary"`
|
|
Type string `json:"type,omitempty"`
|
|
}
|
|
|
|
type WorksmobileSCIMPhoneNumber struct {
|
|
Value string `json:"value"`
|
|
Primary bool `json:"primary"`
|
|
Type string `json:"type,omitempty"`
|
|
}
|
|
|
|
type WorksmobileUserPatchPayload struct {
|
|
DomainID int64 `json:"domainId"`
|
|
Email string `json:"email,omitempty"`
|
|
UserExternalKey string `json:"userExternalKey,omitempty"`
|
|
UserName WorksmobileUserName `json:"userName"`
|
|
CellPhone string `json:"cellPhone,omitempty"`
|
|
EmployeeNumber string `json:"employeeNumber,omitempty"`
|
|
AliasEmails []string `json:"aliasEmails,omitempty"`
|
|
Locale string `json:"locale,omitempty"`
|
|
Task string `json:"task,omitempty"`
|
|
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
|
|
}
|
|
|
|
type WorksmobileOrgUnitPatchPayload struct {
|
|
DomainID int64 `json:"domainId"`
|
|
Email string `json:"email,omitempty"`
|
|
OrgUnitName string `json:"orgUnitName,omitempty"`
|
|
OrgUnitExternalKey string `json:"orgUnitExternalKey,omitempty"`
|
|
ParentOrgUnitID string `json:"parentOrgUnitId,omitempty"`
|
|
DisplayOrder int `json:"displayOrder,omitempty"`
|
|
}
|
|
|
|
type WorksmobileRemoteUser struct {
|
|
ID string `json:"id"`
|
|
ExternalID string `json:"externalId"`
|
|
UserName string `json:"userName"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"displayName"`
|
|
LevelID string `json:"levelId"`
|
|
LevelName string `json:"levelName"`
|
|
Task string `json:"task"`
|
|
DomainID int64 `json:"domainId"`
|
|
DomainName string `json:"domainName"`
|
|
PrimaryOrgUnitID string `json:"primaryOrgUnitId"`
|
|
PrimaryOrgUnitName string `json:"primaryOrgUnitName"`
|
|
PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"`
|
|
PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"`
|
|
PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"`
|
|
Active bool `json:"active"`
|
|
}
|
|
|
|
type WorksmobileRemoteGroup struct {
|
|
ID string `json:"id"`
|
|
ExternalID string `json:"externalId"`
|
|
DisplayName string `json:"displayName"`
|
|
Email string `json:"email,omitempty"`
|
|
MailLocalPart string `json:"mailLocalPart,omitempty"`
|
|
DomainID int64 `json:"domainId"`
|
|
DomainName string `json:"domainName"`
|
|
ParentID string `json:"parentId"`
|
|
ParentName string `json:"parentName"`
|
|
}
|
|
|
|
func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSCIMUserPayload {
|
|
name := strings.TrimSpace(payload.UserName.LastName)
|
|
if name == "" {
|
|
name = strings.TrimSpace(payload.Email)
|
|
}
|
|
result := WorksmobileSCIMUserPayload{
|
|
Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User", worksmobileSCIMUserExtensionSchema},
|
|
UserName: strings.TrimSpace(payload.Email),
|
|
ExternalID: strings.TrimSpace(payload.UserExternalKey),
|
|
DisplayName: name,
|
|
Name: WorksmobileSCIMName{FamilyName: name},
|
|
Emails: []WorksmobileSCIMEmail{{Value: strings.TrimSpace(payload.Email), Primary: true, Type: "other"}},
|
|
Password: payload.PasswordConfig.Password,
|
|
Active: true,
|
|
PreferredLanguage: worksmobileSCIMPreferredLanguage(payload.Locale),
|
|
WorksExtension: map[string]any{
|
|
"employeeNumber": payload.EmployeeNumber,
|
|
"task": payload.Task,
|
|
},
|
|
}
|
|
if strings.TrimSpace(payload.CellPhone) != "" {
|
|
result.PhoneNumbers = []WorksmobileSCIMPhoneNumber{{Value: strings.TrimSpace(payload.CellPhone), Primary: true, Type: "mobile"}}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileUserPatchPayload {
|
|
return WorksmobileUserPatchPayload{
|
|
DomainID: payload.DomainID,
|
|
Email: strings.TrimSpace(payload.Email),
|
|
UserExternalKey: strings.TrimSpace(payload.UserExternalKey),
|
|
UserName: payload.UserName,
|
|
CellPhone: strings.TrimSpace(payload.CellPhone),
|
|
EmployeeNumber: strings.TrimSpace(payload.EmployeeNumber),
|
|
AliasEmails: payload.AliasEmails,
|
|
Locale: strings.TrimSpace(payload.Locale),
|
|
Task: strings.TrimSpace(payload.Task),
|
|
Organizations: payload.Organizations,
|
|
}
|
|
}
|
|
|
|
func NewWorksmobileOrgUnitPatchPayload(payload WorksmobileOrgUnitPayload) WorksmobileOrgUnitPatchPayload {
|
|
return WorksmobileOrgUnitPatchPayload{
|
|
DomainID: payload.DomainID,
|
|
Email: strings.TrimSpace(payload.Email),
|
|
OrgUnitName: strings.TrimSpace(payload.OrgUnitName),
|
|
OrgUnitExternalKey: strings.TrimSpace(payload.OrgUnitExternalKey),
|
|
ParentOrgUnitID: strings.TrimSpace(payload.ParentOrgUnitID),
|
|
DisplayOrder: payload.DisplayOrder,
|
|
}
|
|
}
|
|
|
|
func worksmobileSCIMPreferredLanguage(locale string) string {
|
|
locale = strings.TrimSpace(locale)
|
|
if locale == "" {
|
|
return ""
|
|
}
|
|
return strings.ReplaceAll(locale, "_", "-")
|
|
}
|
|
|
|
func parseWorksmobileRemoteUser(resource map[string]any) WorksmobileRemoteUser {
|
|
user := WorksmobileRemoteUser{
|
|
ID: stringFromMap(resource, "id"),
|
|
ExternalID: stringFromMap(resource, "externalId"),
|
|
UserName: stringFromMap(resource, "userName"),
|
|
DisplayName: stringFromMap(resource, "displayName"),
|
|
Active: boolFromMap(resource, "active"),
|
|
}
|
|
if emails, ok := resource["emails"].([]any); ok {
|
|
for _, raw := range emails {
|
|
email, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if user.Email == "" || boolFromMap(email, "primary") {
|
|
user.Email = stringFromMap(email, "value")
|
|
}
|
|
}
|
|
}
|
|
if user.Email == "" && strings.Contains(user.UserName, "@") {
|
|
user.Email = user.UserName
|
|
}
|
|
user.PrimaryOrgUnitID, user.PrimaryOrgUnitName = parseWorksmobilePrimaryOrgUnit(resource)
|
|
return user
|
|
}
|
|
|
|
func parseWorksmobileRemoteGroup(resource map[string]any) WorksmobileRemoteGroup {
|
|
email := firstStringFromMap(resource, "email", "mail", "groupEmail", "mailingList", "orgUnitEmail", "loginId", "userName")
|
|
group := WorksmobileRemoteGroup{
|
|
ID: stringFromMap(resource, "id"),
|
|
ExternalID: stringFromMap(resource, "externalId"),
|
|
DisplayName: stringFromMap(resource, "displayName"),
|
|
Email: email,
|
|
MailLocalPart: worksmobileMailLocalPart(email),
|
|
}
|
|
group.ParentID, group.ParentName = parseWorksmobileParentOrgUnit(resource)
|
|
return group
|
|
}
|
|
|
|
func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUser {
|
|
email := firstStringFromMap(resource, "email", "loginId", "userName")
|
|
user := WorksmobileRemoteUser{
|
|
ID: firstStringFromMap(resource, "userId", "id"),
|
|
ExternalID: firstStringFromMap(resource, "userExternalKey", "externalKey", "externalId"),
|
|
UserName: email,
|
|
Email: email,
|
|
DisplayName: parseWorksmobileDirectoryUserName(resource),
|
|
LevelID: parseWorksmobileUserLevelID(resource),
|
|
LevelName: parseWorksmobileUserLevelName(resource),
|
|
Task: firstStringFromMap(resource, "task", "job", "jobDescription"),
|
|
Active: true,
|
|
}
|
|
if active, ok := resource["active"].(bool); ok {
|
|
user.Active = active
|
|
}
|
|
primaryOrgUnit := parseWorksmobilePrimaryOrgUnitDetail(resource)
|
|
user.PrimaryOrgUnitID = primaryOrgUnit.ID
|
|
user.PrimaryOrgUnitName = primaryOrgUnit.Name
|
|
user.PrimaryOrgUnitPositionID = primaryOrgUnit.PositionID
|
|
user.PrimaryOrgUnitPositionName = primaryOrgUnit.PositionName
|
|
user.PrimaryOrgUnitIsManager = primaryOrgUnit.IsManager
|
|
return user
|
|
}
|
|
|
|
func parseWorksmobileDirectoryGroup(resource map[string]any) WorksmobileRemoteGroup {
|
|
email := firstStringFromMap(resource, "email", "mail", "groupEmail", "mailingList", "orgUnitEmail", "loginId", "userName")
|
|
return WorksmobileRemoteGroup{
|
|
ID: firstStringFromMap(resource, "orgUnitId", "id"),
|
|
ExternalID: firstStringFromMap(resource, "orgUnitExternalKey", "externalKey", "externalId"),
|
|
DisplayName: firstStringFromMap(resource, "orgUnitName", "displayName", "name"),
|
|
Email: email,
|
|
MailLocalPart: worksmobileMailLocalPart(email),
|
|
ParentID: firstStringFromMap(resource, "parentOrgUnitId", "parentId"),
|
|
ParentName: firstStringFromMap(resource, "parentOrgUnitName", "parentName"),
|
|
}
|
|
}
|
|
|
|
func worksmobileMailLocalPart(value string) string {
|
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
|
if normalized == "" {
|
|
return ""
|
|
}
|
|
if at := strings.Index(normalized, "@"); at >= 0 {
|
|
normalized = normalized[:at]
|
|
}
|
|
return strings.TrimSpace(normalized)
|
|
}
|
|
|
|
func parseWorksmobileDirectoryUserName(resource map[string]any) string {
|
|
if value := firstStringFromMap(resource, "displayName", "name"); value != "" {
|
|
return value
|
|
}
|
|
if name, ok := resource["userName"].(map[string]any); ok {
|
|
if value := firstStringFromMap(name, "fullName", "displayName", "name"); value != "" {
|
|
return value
|
|
}
|
|
if value := joinWorksmobileNameParts(firstStringFromMap(name, "lastName", "familyName"), firstStringFromMap(name, "firstName", "givenName")); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
if name, ok := resource["name"].(map[string]any); ok {
|
|
if value := firstStringFromMap(name, "fullName", "displayName", "name"); value != "" {
|
|
return value
|
|
}
|
|
if value := joinWorksmobileNameParts(firstStringFromMap(name, "lastName", "familyName"), firstStringFromMap(name, "firstName", "givenName")); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func joinWorksmobileNameParts(lastName, firstName string) string {
|
|
lastName = strings.TrimSpace(lastName)
|
|
firstName = strings.TrimSpace(firstName)
|
|
if lastName == "" {
|
|
return firstName
|
|
}
|
|
if firstName == "" {
|
|
return lastName
|
|
}
|
|
return lastName + firstName
|
|
}
|
|
|
|
func parseWorksmobileUserLevelID(resource map[string]any) string {
|
|
if value := firstStringFromMap(resource, "levelId"); value != "" {
|
|
return value
|
|
}
|
|
if level, ok := resource["level"].(map[string]any); ok {
|
|
return firstStringFromMap(level, "levelId", "id", "value")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseWorksmobileUserLevelName(resource map[string]any) string {
|
|
if value := firstStringFromMap(resource, "levelName"); value != "" {
|
|
return value
|
|
}
|
|
if level, ok := resource["level"].(map[string]any); ok {
|
|
return firstStringFromMap(level, "levelName", "displayName", "name")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type worksmobileOrgUnitDetail struct {
|
|
ID string
|
|
Name string
|
|
PositionID string
|
|
PositionName string
|
|
IsManager *bool
|
|
}
|
|
|
|
func (d worksmobileOrgUnitDetail) empty() bool {
|
|
return d.ID == "" && d.Name == "" && d.PositionID == "" && d.PositionName == "" && d.IsManager == nil
|
|
}
|
|
|
|
func parseWorksmobilePrimaryOrgUnit(resource map[string]any) (string, string) {
|
|
detail := parseWorksmobilePrimaryOrgUnitDetail(resource)
|
|
return detail.ID, detail.Name
|
|
}
|
|
|
|
func parseWorksmobilePrimaryOrgUnitDetail(resource map[string]any) worksmobileOrgUnitDetail {
|
|
if detail := parseWorksmobileOrgUnitDetailList(resource["organizations"], true); !detail.empty() {
|
|
return detail
|
|
}
|
|
if detail := parseWorksmobileOrgUnitDetailList(resource["orgUnits"], true); !detail.empty() {
|
|
return detail
|
|
}
|
|
for key, raw := range resource {
|
|
if !strings.Contains(strings.ToLower(key), "works") {
|
|
continue
|
|
}
|
|
if values, ok := raw.(map[string]any); ok {
|
|
if detail := parseWorksmobileOrgUnitDetailList(values["organizations"], true); !detail.empty() {
|
|
return detail
|
|
}
|
|
if detail := parseWorksmobileOrgUnitDetailList(values["orgUnits"], true); !detail.empty() {
|
|
return detail
|
|
}
|
|
}
|
|
}
|
|
return worksmobileOrgUnitDetail{}
|
|
}
|
|
|
|
func parseWorksmobileParentOrgUnit(resource map[string]any) (string, string) {
|
|
id := firstStringFromMap(resource, "parentOrgUnitId", "parentId")
|
|
name := firstStringFromMap(resource, "parentOrgUnitName", "parentName")
|
|
if id != "" || name != "" {
|
|
return id, name
|
|
}
|
|
for _, key := range []string{"parent", "parentOrgUnit"} {
|
|
if values, ok := resource[key].(map[string]any); ok {
|
|
id = firstStringFromMap(values, "id", "orgUnitId", "value")
|
|
name = firstStringFromMap(values, "displayName", "orgUnitName", "name")
|
|
if id != "" || name != "" {
|
|
return id, name
|
|
}
|
|
}
|
|
}
|
|
for key, raw := range resource {
|
|
if !strings.Contains(strings.ToLower(key), "works") {
|
|
continue
|
|
}
|
|
if values, ok := raw.(map[string]any); ok {
|
|
if id, name := parseWorksmobileParentOrgUnit(values); id != "" || name != "" {
|
|
return id, name
|
|
}
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func parseWorksmobileOrgUnitList(raw any, preferPrimary bool) (string, string) {
|
|
detail := parseWorksmobileOrgUnitDetailList(raw, preferPrimary)
|
|
return detail.ID, detail.Name
|
|
}
|
|
|
|
func parseWorksmobileOrgUnitDetailList(raw any, preferPrimary bool) worksmobileOrgUnitDetail {
|
|
values, ok := raw.([]any)
|
|
if !ok {
|
|
return worksmobileOrgUnitDetail{}
|
|
}
|
|
var fallback worksmobileOrgUnitDetail
|
|
for _, item := range values {
|
|
orgUnit, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
detail := worksmobileOrgUnitDetail{
|
|
ID: firstStringFromMap(orgUnit, "orgUnitId", "id", "value"),
|
|
Name: firstStringFromMap(orgUnit, "orgUnitName", "displayName", "name"),
|
|
PositionID: firstStringFromMap(orgUnit, "positionId"),
|
|
PositionName: firstStringFromMap(orgUnit, "positionName"),
|
|
IsManager: boolPointerFromMap(orgUnit, "isManager", "manager"),
|
|
}
|
|
if detail.empty() {
|
|
if nested := parseWorksmobileOrgUnitDetailList(orgUnit["orgUnits"], preferPrimary); !nested.empty() {
|
|
detail = nested
|
|
}
|
|
}
|
|
if fallback.empty() {
|
|
fallback = detail
|
|
}
|
|
if !preferPrimary || boolFromMap(orgUnit, "primary") {
|
|
return detail
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func stringFromMap(values map[string]any, key string) string {
|
|
value, _ := values[key].(string)
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
func firstStringFromMap(values map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value := stringFromMap(values, key); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func boolFromMap(values map[string]any, key string) bool {
|
|
value, _ := values[key].(bool)
|
|
return value
|
|
}
|
|
|
|
func boolPointerFromMap(values map[string]any, keys ...string) *bool {
|
|
for _, key := range keys {
|
|
if value, ok := values[key].(bool); ok {
|
|
return &value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) baseURL() string {
|
|
return c.BaseURL
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) requestURL(path string) (string, error) {
|
|
baseURL := strings.TrimSpace(c.baseURL())
|
|
if baseURL == "" {
|
|
return "", fmt.Errorf("worksmobile api base url is not configured")
|
|
}
|
|
return strings.TrimRight(baseURL, "/") + path, nil
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) httpClient() *http.Client {
|
|
if c.HTTPClient != nil {
|
|
return c.HTTPClient
|
|
}
|
|
return &http.Client{Timeout: 15 * time.Second}
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) currentTime() time.Time {
|
|
if c.now != nil {
|
|
return c.now()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
func (c *WorksmobileHTTPClient) orgUnitWriteDelay() time.Duration {
|
|
if c.OrgUnitWriteDelay < 0 {
|
|
return 0
|
|
}
|
|
if c.OrgUnitWriteDelay > 0 {
|
|
return c.OrgUnitWriteDelay
|
|
}
|
|
return 1100 * time.Millisecond
|
|
}
|