Appearance
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 dashboardReturning 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 changesWhat to Collect
Fields on the profile completion form:
| Field | Required | Notes |
|---|---|---|
| First Name | Yes | Pre-filled from Zitadel if available |
| Last Name | Yes | Pre-filled from Zitadel if available |
| Phone Number | No | Optional, for support escalations |
| Job Title | No | e.g. "Engineering Manager" |
| Timezone | Yes | Default 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 InternalUser3. Invite Flow — Send Email from Console
When a user is invited:
- Call
CreateHumanUserwithreturnCode→ getuserId+verificationCode - Generate a secure invite token (random 32 bytes, URL-safe base64)
- Store in the
CustomerUserrecord: hashed invite token + expiry (e.g. 7 days) + Zitadel verification code (encrypted) - 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-inviteRequest body:
json
{
"token": "the-invite-token-from-email",
"password": "user-chosen-password"
}Flow:
- Hash the token, look up
CustomerUserbyinviteToken - Check expiry — reject if expired
- Call Zitadel
VerifyEmail(userId, storedVerificationCode) - Call Zitadel
SetPassword(userId, password) - Clear invite token fields from the user record
- Update user status from
invited→active - 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 fieldsGET /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"
}- Validate required fields (firstName, lastName, timezone)
- Update the user record in PostgreSQL
- Set
profileCompleted = true - Return updated profile
- 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-invitewith 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 logic8. 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-inviteendpoint (e.g. 5 attempts per token per hour)
Zitadel Verification Code
- Returned by
AddHumanUserwithreturnCode - 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-inviteendpoint over TLS - Console immediately forwards it to Zitadel's
SetPasswordAPI - 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
- Update Zitadel client:
returnCodein CreateHumanUser, add VerifyEmail + SetPassword - Add profile fields + invite token fields to user models
- Build invite email sending (Console-branded, with invite token)
- Build
POST /api/v1/accept-inviteendpoint - Build
AcceptInvitePagefrontend - Add
GET/PUT /api/v1/profileendpoints (include tenant list in response) - Update
/api/v1/auth/meto includeprofileCompleted+ tenants - Build
CompleteProfilePagefrontend - Update
CallbackPageredirect logic for incomplete profiles - Build
AccountPagefrontend (profile + My Tenants) - Add "My Account" to sidebar navigation in both layouts
- Test full flow: invite → accept → set password → OIDC login → complete profile → redirect
- Optional: add server-side profile completion guard middleware

