Skip to content

User Invite, Profile Completion & My Account

Overview

Sign-up is disabled in Zitadel. All users are created through Console invites. When an admin invites a user, Console creates the Zitadel user with returnCode (instead of sendCode), receives the verification code in the API response, and sends its own branded invite email. The user never interacts with Zitadel's UI directly until the OIDC login step.

After setting their password and logging in, Console redirects new users to a Complete Profile page to collect additional details. A persistent My Account page allows users to update their profile and see their tenant/instance access at any time.


Invite & Onboarding Flow

First-Time User (Accept Invite)

1. Admin invites user in Console
2. Console calls Zitadel AddHumanUser with returnCode
   → Zitadel creates user, returns { userId, verificationCode }
   → Zitadel does NOT send any email
3. Console sends branded invite email:
   "You've been invited to <CustomerName>"
   Link: https://console.example.com/accept-invite?userId=XXX&code=YYY
4. User clicks link → Console /accept-invite page
   → Shows "Set your password" form
   → On submit: Console backend calls Zitadel VerifyEmail + SetPassword APIs
5. Console initiates OIDC login (redirect to Zitadel /authorize)
   → Zitadel authenticates (password was just set, so instant login)
   → Redirects back to Console /callback
6. Console /callback calls /api/v1/auth/me
   → Backend returns profileCompleted: false
7. Frontend redirects to /complete-profile
   → User fills required fields (name, timezone, etc.)
   → PUT /api/v1/profile → profileCompleted = true
8. Frontend redirects to target:
   → Single tenant assignment → tenant/instance URL
   → Multiple tenants → My Account page (tenant list)
   → Internal user → Console admin dashboard

Returning User (Normal Login)

Login → Zitadel OIDC → Console /callback → /api/v1/auth/me
  → profileCompleted: true → normal routing (dashboard or last visited)

My Account (Anytime)

Sidebar → "My Account" → /account
  → View/edit profile fields
  → See all tenant/instance assignments with navigation links
  → Links to Zitadel for password/MFA changes

What to Collect

Fields on the profile completion form:

FieldRequiredNotes
First NameYesPre-filled from Zitadel if available
Last NameYesPre-filled from Zitadel if available
Phone NumberNoOptional, for support escalations
Job TitleNoe.g. "Engineering Manager"
TimezoneYesDefault from browser, dropdown

These fields live in Console's PostgreSQL database only. Zitadel remains the source of truth for email, password, and MFA.


Backend Changes

1. Update Zitadel Client

internal/zitadel/client.go — Modify user creation and add new methods.

Change CreateHumanUser to use returnCode

Return the verification code instead of having Zitadel send an email:

go
func (c *Client) CreateHumanUser(ctx context.Context, orgID, firstName, lastName, email string) (userID string, verificationCode string, err error) {
    var resp struct {
        UserID   string `json:"userId"`
        EmailCode string `json:"emailCode"`
    }
    err = c.managementRequestWithOrg(ctx, http.MethodPost, "/zitadel.user.v2.UserService/AddHumanUser", orgID, map[string]any{
        "organization": map[string]any{"orgId": orgID},
        "profile": map[string]any{
            "givenName":  firstName,
            "familyName": lastName,
        },
        "email": map[string]any{
            "email": email,
            "verification": map[string]any{
                "returnCode": map[string]any{},
            },
        },
    }, &resp)
    if err != nil {
        return "", "", fmt.Errorf("create human user: %w", err)
    }
    return resp.UserID, resp.EmailCode, nil
}

Add VerifyEmail

go
func (c *Client) VerifyEmail(ctx context.Context, userID, code string) error {
    return c.managementRequest(ctx, http.MethodPost, "/zitadel.user.v2.UserService/VerifyEmail", map[string]any{
        "userId":           userID,
        "verificationCode": code,
    }, nil)
}

Add SetPassword

go
func (c *Client) SetPassword(ctx context.Context, userID, password string) error {
    return c.managementRequest(ctx, http.MethodPost, "/zitadel.user.v2.UserService/SetPassword", map[string]any{
        "userId": userID,
        "newPassword": map[string]any{
            "password": password,
        },
    }, nil)
}

2. Update User Models

internal/model/user.go — Add profile fields and invite token to both CustomerUser and InternalUser:

go
// Add to CustomerUser struct
FirstName        string `bson:"firstName,omitempty" json:"firstName,omitempty"`
LastName         string `bson:"lastName,omitempty" json:"lastName,omitempty"`
Phone            string `bson:"phone,omitempty" json:"phone,omitempty"`
JobTitle         string `bson:"jobTitle,omitempty" json:"jobTitle,omitempty"`
Timezone         string `bson:"timezone,omitempty" json:"timezone,omitempty"`
ProfileCompleted bool   `bson:"profileCompleted" json:"profileCompleted"`

// Invite token fields (used for accept-invite flow)
InviteToken      string `bson:"inviteToken,omitempty" json:"-"`       // hashed token stored in DB
InviteExpiresAt  time.Time `bson:"inviteExpiresAt,omitempty" json:"-"`

// Same fields for InternalUser

3. Invite Flow — Send Email from Console

When a user is invited:

  1. Call CreateHumanUser with returnCode → get userId + verificationCode
  2. Generate a secure invite token (random 32 bytes, URL-safe base64)
  3. Store in the CustomerUser record: hashed invite token + expiry (e.g. 7 days) + Zitadel verification code (encrypted)
  4. Send branded email from Console:
Subject: You've been invited to {CustomerName}
Body:
  Hi {firstName},

  You've been invited to join {CustomerName} on Exto.

  Click below to set your password and get started:
  https://console.example.com/accept-invite?token={inviteToken}

  This link expires in 7 days.

The invite token maps back to the user record. The Zitadel userId and verification code are stored server-side — never exposed in the URL.

4. Accept Invite API Endpoint

internal/handler/users/handler.go — New public endpoint (no JWT required):

POST /api/v1/accept-invite

Request body:

json
{
  "token": "the-invite-token-from-email",
  "password": "user-chosen-password"
}

Flow:

  1. Hash the token, look up CustomerUser by inviteToken
  2. Check expiry — reject if expired
  3. Call Zitadel VerifyEmail(userId, storedVerificationCode)
  4. Call Zitadel SetPassword(userId, password)
  5. Clear invite token fields from the user record
  6. Update user status from invitedactive
  7. Return success with a redirect URL to initiate OIDC login

Response:

json
{
  "success": true,
  "loginUrl": "/login?hint={email}"
}

5. Update /api/v1/auth/me Response

internal/handler/consoleauth/handler.go — Add profileCompleted and tenant list:

go
type AuthMeResponse struct {
    Sub              string              `json:"sub"`
    Email            string              `json:"email"`
    Name             string              `json:"name"`
    Role             string              `json:"role"`
    UserType         string              `json:"userType"`
    CustomerID       string              `json:"customerId,omitempty"`
    ProfileCompleted bool                `json:"profileCompleted"`
    Tenants          []TenantAccessEntry `json:"tenants,omitempty"`
}

type TenantAccessEntry struct {
    TenantID   string `json:"tenantId"`
    TenantName string `json:"tenantName"`
    Role       string `json:"role"`
    InstanceURL string `json:"instanceUrl,omitempty"`
}

6. Profile API Endpoints

internal/handler/users/handler.go — Self-service endpoints:

GET  /api/v1/profile   → Get current user's profile + tenant list
PUT  /api/v1/profile   → Update profile fields

GET /api/v1/profile

Returns:

json
{
  "firstName": "Jane",
  "lastName": "Smith",
  "email": "jane@example.com",
  "phone": "+1-555-0100",
  "jobTitle": "Engineering Manager",
  "timezone": "America/New_York",
  "profileCompleted": true,
  "tenants": [
    {
      "tenantId": "t_abc",
      "tenantName": "Acme Production",
      "role": "tenant_admin",
      "instanceUrl": "https://acme.exto360.com"
    },
    {
      "tenantId": "t_def",
      "tenantName": "Acme Staging",
      "role": "tenant_user",
      "instanceUrl": "https://acme-staging.exto360.com"
    }
  ]
}

PUT /api/v1/profile

Request body:

json
{
  "firstName": "Jane",
  "lastName": "Smith",
  "phone": "+1-555-0100",
  "jobTitle": "Engineering Manager",
  "timezone": "America/New_York"
}
  1. Validate required fields (firstName, lastName, timezone)
  2. Update the user record in PostgreSQL
  3. Set profileCompleted = true
  4. Return updated profile
  5. Emit audit log entry

7. Profile Completion Guard (Optional Server-Side)

Middleware to reject API requests from users with profileCompleted == false:

json
{
  "error": "profile_incomplete",
  "message": "Please complete your profile before accessing this resource."
}

Exempt endpoints: /api/v1/profile, /api/v1/auth/me, /api/v1/accept-invite.


Frontend Changes

1. Update Types

web/src/types/index.ts:

typescript
interface ConsoleUser {
  // ... existing fields
  profileCompleted: boolean;
  tenants?: TenantAccessEntry[];
}

interface TenantAccessEntry {
  tenantId: string;
  tenantName: string;
  role: string;
  instanceUrl?: string;
}

interface UserProfile {
  firstName: string;
  lastName: string;
  phone?: string;
  jobTitle?: string;
  timezone: string;
  email: string;
  profileCompleted: boolean;
  tenants: TenantAccessEntry[];
}

2. API Client

web/src/api/profile.ts (new file):

typescript
export async function acceptInvite(token: string, password: string): Promise<{ loginUrl: string }> { ... }
export async function getProfile(): Promise<UserProfile> { ... }
export async function updateProfile(data: UpdateProfileRequest): Promise<UserProfile> { ... }

3. Accept Invite Page

web/src/pages/AcceptInvitePage.tsx (new file):

  • Route: /accept-invite?token=XXX
  • No authentication required (public page)
  • Shows: "Set your password" form (password + confirm password)
  • Password strength requirements (match Zitadel's policy)
  • On submit: POST /api/v1/accept-invite with token + password
  • On success: redirect to OIDC login flow (with email hint for convenience)
  • Error states: expired token, already accepted, invalid token

4. Complete Profile Page

web/src/pages/CompleteProfilePage.tsx (new file):

  • Route: /complete-profile (requires authentication)
  • Form: firstName, lastName, phone, jobTitle, timezone
  • Pre-fill name from JWT claims if available
  • Timezone defaults to Intl.DateTimeFormat().resolvedOptions().timeZone
  • On submit: PUT /api/v1/profile → redirect to target
  • No skip button — required before accessing the platform

Redirect after completion:

  • Single tenant → navigate to that instance URL
  • Multiple tenants → navigate to /account (My Account with tenant list)
  • Internal user → navigate to /dashboard

5. My Account Page

web/src/pages/AccountPage.tsx (new file):

  • Route: /account (requires authentication)
  • Two sections:

Profile Section:

  • Editable: firstName, lastName, phone, jobTitle, timezone
  • Read-only: email (managed in Zitadel)
  • "Change Password" → links to Zitadel password change
  • "Manage MFA" → links to Zitadel MFA settings
  • Save button calls PUT /api/v1/profile

My Tenants Section:

  • List of all tenant assignments for the current user
  • Each entry shows:
    • Tenant name
    • Role (tenant_admin / tenant_user)
    • Instance URL
    • "Open" button → navigates to the instance
  • Empty state: "You don't have access to any tenants yet."

Example layout:

┌─────────────────────────────────────────────┐
│ My Account                                  │
├─────────────────────────────────────────────┤
│ Profile                                     │
│ ┌─────────────┐ ┌─────────────┐            │
│ │ First Name  │ │ Last Name   │            │
│ │ Jane        │ │ Smith       │            │
│ └─────────────┘ └─────────────┘            │
│ ┌─────────────┐ ┌─────────────┐            │
│ │ Email (ro)  │ │ Phone       │            │
│ │ jane@co.com │ │ +1-555-0100 │            │
│ └─────────────┘ └─────────────┘            │
│ ┌─────────────┐ ┌─────────────┐            │
│ │ Job Title   │ │ Timezone    │            │
│ │ Eng Manager │ │ US/Eastern  │            │
│ └─────────────┘ └─────────────┘            │
│                          [Save Changes]     │
│                                             │
│ Security                                    │
│ [Change Password ↗]  [Manage MFA ↗]        │
│                                             │
├─────────────────────────────────────────────┤
│ My Tenants                                  │
│ ┌───────────────────────────────────────┐   │
│ │ Acme Production    tenant_admin       │   │
│ │ https://acme.exto360.com        [Open →]  │   │
│ ├───────────────────────────────────────┤   │
│ │ Acme Staging       tenant_user        │   │
│ │ https://acme-stg.exto360.com    [Open →]  │   │
│ └───────────────────────────────────────┘   │
└─────────────────────────────────────────────┘

6. Route Changes

web/src/routes/index.tsx:

tsx
// Public (no auth required)
<Route path="/accept-invite" element={<AcceptInvitePage />} />

// Authenticated but profile not required
<Route path="/complete-profile" element={<RequireAuth><CompleteProfilePage /></RequireAuth>} />

// Add to both ConsoleLayout and CustomerPortalLayout children
<Route path="/account" element={<AccountPage />} />

7. Callback Redirect Logic

web/src/auth/CallbackPage.tsx — After the /auth/me call:

typescript
if (!meData.profileCompleted) {
  navigate("/complete-profile", { replace: true });
  return;
}
// ... existing routing logic

8. Navigation Update

Add "My Account" to sidebar in both layouts:

  • ConsoleLayout: User profile section at sidebar bottom → clicking user name/avatar opens /account
  • CustomerPortalLayout: Same placement
  • Show as active nav item when on /account

Security Considerations

Invite Token

  • 32 random bytes, base64url encoded
  • Stored as SHA-256 hash in PostgreSQL (same pattern as instance tokens)
  • Expires after 7 days
  • Single-use: cleared after acceptance
  • Rate limit the /accept-invite endpoint (e.g. 5 attempts per token per hour)

Zitadel Verification Code

  • Returned by AddHumanUser with returnCode
  • Stored encrypted in Console's DB (AES-256-GCM), never exposed to the user
  • Used server-side when calling VerifyEmail

Password Handling

  • User's password is sent to Console's /accept-invite endpoint over TLS
  • Console immediately forwards it to Zitadel's SetPassword API
  • Console never stores or logs the password
  • Password validation follows Zitadel's configured password policy

Migration

No data migration needed. profileCompleted defaults to false (Go zero value).

Option A: Require all users to complete profile on next login.

  • Existing users see the Complete Profile page on their next session.
  • Ensures all users have complete profiles.

Option B: Auto-mark existing active users as completed.

  • Run: db.customer_users.updateMany({status: "active"}, {$set: {profileCompleted: true}})
  • Same for internal_users.
  • Only new invites go through the completion flow.

Recommendation: Option A for a clean rollout.


Implementation Order

  1. Update Zitadel client: returnCode in CreateHumanUser, add VerifyEmail + SetPassword
  2. Add profile fields + invite token fields to user models
  3. Build invite email sending (Console-branded, with invite token)
  4. Build POST /api/v1/accept-invite endpoint
  5. Build AcceptInvitePage frontend
  6. Add GET/PUT /api/v1/profile endpoints (include tenant list in response)
  7. Update /api/v1/auth/me to include profileCompleted + tenants
  8. Build CompleteProfilePage frontend
  9. Update CallbackPage redirect logic for incomplete profiles
  10. Build AccountPage frontend (profile + My Tenants)
  11. Add "My Account" to sidebar navigation in both layouts
  12. Test full flow: invite → accept → set password → OIDC login → complete profile → redirect
  13. Optional: add server-side profile completion guard middleware