User Profile & Settings — Feature & Testing Guide
Overview
User management in Objectuve covers authentication (Clerk-based sign-in/sign-up), onboarding for new users, profile management (name, username, avatar), app preferences (theme, notifications), and account actions (sign out, delete). Clerk is the auth provider — all authentication flows go through Clerk, and profile updates sync back to Clerk when needed.
Authentication
Sign In
Route: /sign-in
Clerk handles all authentication. The sign-in page renders the Clerk sign-in component with support for:
- Email/password sign-in
- Social OAuth providers (Google, etc.)
- Demo account: Visit
/sign-in?demoto reveal a demo account button - Fallback auth: Visit
/sign-in?fallbackfor testing (requiresFALLBACK_AUTH_SECRETenv var)
Sign Up
Route: /sign-up
Clerk handles registration. The sign-up page renders the Clerk sign-up component with:
- Email/password registration
- Social OAuth registration
- Email verification flow
Authentication Flow
- User signs in via Clerk
- Clerk issues an RS256 JWT
- Frontend calls
SYNC_USER_MUTATIONwith Clerk user data - Backend verifies JWT via
ClerkJwtVerifier(JWKS-based) - Backend finds or creates local user via
ClerkUserSyncinteraction - Returns
user,isFirstSignIn(triggers onboarding if true) - JWT is sent as
SessionTokenheader (PascalCase) on all subsequent GraphQL requests
Key Behaviors
- The
SessionTokenheader is PascalCase — notsession-tokenorAuthorization: Bearer - Clerk's
getToken()is called by Apollo middleware before each request useClerkSynccomposable provides reactive state:synced,currentUserId,isFirstSignIn- Router guard in
src/router/index.tssyncs user before rendering protected pages
Onboarding
Profile Completion Modal
After first sign-in, new users see a ProfileCompletionModal prompting them to complete their profile:
| Field | Type | Required | Description |
|---|---|---|---|
| First Name | Text | No | User's first name |
| Last Name | Text | No | User's last name |
| Username | Text | No | Unique handle, validated via CHECK_VALID_USERNAME_MUTATION |
| Avatar | Image Upload | No | Profile photo via ActiveStorage (GCS in production) |
- Progress indicator shows completion level
- Skip button dismisses without saving
- Save button submits via
UPDATE_USER_MUTATION
Onboarding Wizard
See onboarding.md for the 13-slide onboarding wizard that appears on first sign-in.
Settings Page
Route: /settings
At lg and up, the Settings page uses a tabbed interface with sections: Profile, Personalization, Supporter, Account, Privacy, and Feedback (the Notifications tab appears conditionally when enabled via feature flag). This section documents that desktop layout and the form fields/mutations shared by both surfaces.
Below lg (v4.28+): /settings instead renders a hub of priority tiles that push into sub-pages, replacing the tab strip on mobile only — desktop is unaffected. See Settings — mobile IA for the hub/sub-page model, the full ?tab= slug map, and the mobile-only locked decisions.
Deep link to a specific tab via query parameter: /settings?tab=personalization
Profile Tab
Avatar Section:
- Circular avatar display (photo or initials fallback)
- Camera button overlay to change avatar
- Upload via
usePhotoGallerycomposable - Saved via
UPDATE_USER_PHOTO_MUTATION
Form Fields:
| Field | Type | Validation |
|---|---|---|
| First Name | Text Input | Optional |
| Last Name | Text Input | Optional |
| Username | Text Input | Checked for uniqueness via CHECK_VALID_USERNAME_MUTATION |
| Text Input | Displayed, editable |
Appearance Redirect:
- Links to the Supporter tab for theme and appearance customization
- "Customize themes and appearance in the Supporter tab" button
Dashboard Mode:
- 4-option grid (
DashboardModeControl): Focus (one card at a time), Standard (the essentials), Detailed (everything, expanded), Auto (adapts to your day — Focus when caught up, Standard when there's work to do) - Persisted to localStorage and synced to the server via
UPDATE_DASHBOARD_PREFERENCES_MUTATION
Save Changes Button:
- Calls
UPDATE_USER_MUTATIONwithuserId,firstName,lastName,username,email - Disabled during loading, shows "Saving..." text
- Success/error toast notifications
Personalization Tab
The Personalization tab lets users customize how Coach shows up for them and track their personalized profile answers.
Goal Coach Card:
- Configured state: Displays current Coach persona (with icon and tagline), tone preference (direct/encouraging/balanced), and frequency. "Customize Coach" link routes to
/meet-coachfor changes. - Unconfigured state: Shows "Set up your Goal Coach to get personalized advice that matches your style" with "Meet Your Coach" link.
Enneagram Assessment:
- Completed state: Displays the user's latest Enneagram type with dominant number, wing, detailed description, and scores across all 9 types. Shows assessment completion date.
- History: Lists all previous Enneagram assessments with dates and dominant types.
- Retake button: "Retake assessment" button navigates back to the Enneagram assessment flow.
- Incomplete state: Shows progress counter ("X of 9 questions complete") if the user has started but not finished. Shows "Start your Enneagram assessment" or "Continue assessment" CTA.
Goal Motivation Snapshot:
- Empty state: Shows "Answer 4 quick questions so Coach knows how you work best." with "Set goal style" CTA. Appears when no answers have been provided yet.
- Configured state: Displays a summary of the user's 4 answers (experience, work time, challenge, social context) with human-readable labels in a definition-list format. "Edit" affordance opens a bottom-sheet modal.
- Bottom-sheet edit flow:
IonModalform that pre-fills current answers fromgoalMotivationProfile. Users can update any subset of the 4 questions and tap "Save". On success, shows "Goal style saved." toast and closes the sheet. On error, displays inline error message in an alert region without closing the sheet. - Data persistence: Answers persist immediately after save via
updateGoalMotivationSnapshotmutation; no full profile refetch needed.
Supporter Tab
For active supporters:
- Supporter badge display with current tier
- Plan name and next billing date
- Manage Billing button — opens Stripe billing portal via
GET_BILLING_PORTAL_URL_MUTATION - Cancel button — cancels subscription at period end via
CANCEL_SUPPORTER_SUBSCRIPTION_MUTATION - Lifetime supporters see a permanent status card with no cancel/billing options
For non-supporters:
- "Support the Mission" card with feature list (9 custom themes, supporter badge, early access)
- View Plans button — opens
SupporterUpgradeModalwith pricing tiers ($4/mo, $36/yr, $99 lifetime)
Theme Selector (all users):
ThemeSelectorcomponent with light/dark mode toggle- 10 color themes: Default + 9 supporter-exclusive themes (Sunset, Ocean, Forest, Midnight, Rose, Ember, Lavender, Gold Rush, Slate)
- Non-supporters see locked themes with a lock icon; clicking triggers upgrade modal
- Active theme saved via
useTheme()composable (localStorage)
Account Tab
Email Preferences:
The Account tab includes options for managing email communications:
- Weekly digest: Toggle to enable/disable the weekly progress email. When enabled, you'll receive a summary of your week's activity, streaks, XP, and updates from accountability partners (schedule configurable by admins via
DIGEST_ROLLOUT_DENOMINATOR). - Unsubscribe via email: Each weekly digest email includes an unsubscribe link at the footer. Clicking it shows a confirmation page (protecting against accidental unsubscribes); confirming the action disables future digests. See Weekly Digest Email for the full unsubscribe flow and security details.
Change Password:
- New password input
- Confirm password input
- Validation: passwords must match
- "Update Password" button
Danger zone (red border):
- Sign out: "Sign out of your account on this device" — calls
Session.logout() - Delete account: "Permanently delete your account and all data" — self-service account deletion (App Store Guideline 5.1.1(v)). Shows a confirmation alert (
Delete your account?) with cancel/delete buttons. On confirm, calls thedeleteOwnAccountGraphQL mutation (Gdpr::DeleteSelfServiceAccount), scoped tocontext[:current_user]with no arguments — it can never target another account. Success replaces the view with a persistent/account-deletedconfirmation screen (views/AccountDeleted.vue) — no toast, no auto-dismiss timer — stating plainly that the account and its data are gone;Session.logout(apolloClient, { navigate: false })runs behind that screen to clear the Apollo store and local session state without navigating away from it. The only way off the screen is tapping Done (or back), which lands on/sign-inwith noredirectquery param — never a link back into a deleted account's settings. Failure shows the mutation's error in a toast and leaves the account untouched. Distinct from thedeleteUserboolean argument onUPDATE_USER_MUTATION(UserIdentity::UpdateUser), which only soft-deletes theUserrecord (no PII scrub or cascade) and isn't what this button calls.
Changelog:
- "Version {appVersion} — View changelog" link at bottom
- Opens
ChangelogModalwith version history
Feedback Tab
Embeds the FeedbackView component directly within Settings (with embedded: true prop), providing in-context access to the feature request board without navigating away from the Settings page. See the Feedback Board guide for full details.
Username Validation
How It Works
- User types a username in the settings form
- On blur or submit,
CHECK_VALID_USERNAME_MUTATIONis called - Backend checks uniqueness against all existing usernames (case-insensitive)
- Returns
{ valid: true/false } - If invalid: error message shown below the field
Rules
- Must be unique (case-insensitive check)
- Frontend does not enforce format constraints beyond what Clerk accepts
- Username changes also sync to Clerk via
UpdateUserinteraction
Avatar Upload
Flow
- Tap the camera icon on the avatar
usePhotoGallery.handleUpload()is called- Photo is captured from camera or gallery (Capacitor on mobile, file input on web)
- Image is uploaded via ActiveStorage (Google Cloud Storage in production, disk in development)
- URL is saved via
UPDATE_USER_PHOTO_MUTATIONwithimageUrlanduserId - Avatar updates immediately in the UI
Theme Preferences
useTheme Composable
- Manages dark/light mode state
- Reads initial preference from localStorage
- Applies
darkclass to<html>element for Tailwind dark mode - Provides
theme(reactive ref) andsetTheme(mode)method - Available from Dashboard header and Settings page
Public Profiles
Overview
Public profiles allow authenticated users to view other users' profile information, including avatar, basic stats, achievements, and public goals. Each user's public profile is accessible via a shareable URL: https://objectuve.com/u/{username}.
Route: /u/:username
How It Works
- User navigates to
/u/{username}or clicks a profile link - Frontend calls
userByUsername(username: "{username}")GraphQL query - Backend looks up the user by username (case-insensitive, auth-required)
- Backend returns a
PublicProfileTypewith only public-safe fields - Frontend renders the
PublicProfileview with avatar, stats, achievements, and public goals - If user is not found or soft-deleted, a 404 state is displayed
Accessing Public Profiles
From Ally Cards:
- Clicking on a partner/ally's name or avatar navigates to their public profile
- Components:
PartnerWidget,AllyCard(in communities)
From Settings:
- The username helper in Settings reads as a shareable link:
objectuve.com/u/{username} - Users can copy and share this link with others
Direct URL:
- Users can type the URL directly:
/u/alice,/u/john-doe, etc.
PublicProfile View
Displays the following information:
| Section | Content | Always Visible |
|---|---|---|
| Header | Avatar, first name, last name, username | Yes |
| Stats | Total goals, completed goals, current streak | Yes |
| Achievements | Badges and achievements earned by the user (public-only) | Conditional (if earned) |
| Public Goals | List of the user's public goals (marked private: false and completed: false) | Conditional (if exist) |
| 404 State | "User not found" message with action to return to Dashboard | If user doesn't exist |
Visibility Rules
What's visible on a public profile:
- Avatar, first name, last name, username
- Achievement badges and awards
- Public goals (only those with
private: falseandcompleted: false) - Aggregate stats (total goals, completed, streak)
What's hidden:
- Private goals
- Completed goals
- Email address
- Supporter status
- Settings/preferences
Rate Limiting
The userByUsername query is rate-limited to 30 requests per minute per authenticated user. Exceeding the limit returns a GraphQL error with code RATE_LIMITED and a user-friendly message: "You're browsing profiles a bit fast — slow down and try again."
Changelog Modal
Content
- Version history with entries for each release
- Each entry: version number, date, list of changes
- Categories: New Features, Bug Fixes, Improvements
- Scrollable content area
UI Components
SignIn / SignUp Views
- Clerk component wrapper with theme customization
- Demo button (conditional on
?demoquery param) - Fallback auth link (conditional on
?fallbackquery param) useThemefor dark/light mode styling
Settings View
- Tab bar: Profile (User icon) | Account (Shield icon) | Privacy (ShieldCheck icon) | Coaching (Compass icon) | Supporter (Heart icon) | Feedback (MessageSquare icon) | Notifications (Bell icon, shown only when the
critical-path-full-productfeature flag is enabled) - Conditional rendering via
v-show/v-ifon active tab - Form inputs with
ion-inputandion-textarea - Toast notifications for success/error feedback
ProfileCompletionModal
- Overlay modal with form fields
- Progress indicator
- Skip/Save buttons
- Triggered on first sign-in for incomplete profiles
ChangelogModal
IonModalwrapper with scrollable content- Version entries with date and category badges
Testing the Feature
Manual Testing Checklist
1. Sign In
- [ ] Navigate to
/sign-in - [ ] Sign in with email/password
- [ ] Verify redirect to Dashboard
- [ ] Verify greeting shows your name
2. Sign Up
- [ ] Navigate to
/sign-up - [ ] Create a new account
- [ ] Verify onboarding wizard appears
- [ ] Verify profile completion modal appears
- [ ] Complete profile → verify data saves
3. Demo Account
- [ ] Navigate to
/sign-in?demo - [ ] Verify demo account button appears
- [ ] Tap demo button → verify sign-in succeeds
4. Profile Settings — Edit Name
- [ ] Navigate to Settings → Profile tab
- [ ] Change first name and last name
- [ ] Tap Save Changes
- [ ] Verify success toast
- [ ] Navigate to Dashboard → verify greeting shows new name
5. Profile Settings — Username
- [ ] Navigate to Settings → Profile tab
- [ ] Change username to a unique value
- [ ] Submit → verify username updates
- [ ] Try a taken username → verify error message
6. Avatar Upload
- [ ] Navigate to Settings → Profile tab
- [ ] Tap the camera icon on the avatar
- [ ] Upload a new photo
- [ ] Verify avatar updates in settings
- [ ] Navigate to Dashboard → verify avatar shows in header/menu
7. Theme Toggle
- [ ] In Settings → Profile tab, tap Dark theme button
- [ ] Verify selected indicator appears on Dark
- [ ] Verify entire UI switches to dark mode
- [ ] Tap Light → verify switch back
- [ ] Reload page → verify theme persists
8. Change Password
- [ ] Navigate to Settings → Account tab
- [ ] Enter new password and confirm password
- [ ] Tap Update Password
- [ ] Verify success toast
- [ ] Sign out and sign in with new password → verify success
9. Sign out
- [ ] Navigate to Settings → Account tab
- [ ] Tap Sign out
- [ ] Verify redirect to sign-in page
- [ ] Navigate to Dashboard URL → verify redirect to sign-in
10. Delete account
- [ ] Navigate to Settings → Account tab
- [ ] Tap Delete account (in Danger zone)
- [ ] Verify confirmation dialog appears
- [ ] Tap Cancel → verify nothing happens, account is untouched
- [ ] Tap Delete Account → verify the app lands on a persistent "Your account is deleted." confirmation screen (
/account-deleted) — no toast, does not auto-dismiss - [ ] Tap Done → verify redirect to
/sign-inwith noredirectquery param - [ ] Attempt to sign back in with the deleted account's credentials → verify sign-in fails (account and data are gone, not queued for later deletion)
11. Email Verification
- [ ] Sign up with a new email (don't verify)
- [ ] Verify email verification banner appears on Dashboard
- [ ] Tap resend verification → verify confirmation
- [ ] Verify email → verify banner disappears on reload
12. Changelog
- [ ] Navigate to Settings → Account tab
- [ ] Tap "View changelog" link
- [ ] Verify modal opens with version history
- [ ] Verify entries have version numbers, dates, and change descriptions
- [ ] Close modal
Smoke Tests
Automated smoke test:
ionic_frontend/tests/smoke-playwright/specs/auth-onboarding/clerk-sign-in.spec.ts— Signs in and syncs user- Lifecycle teardown covered internally in the smoke suite (no standalone cleanup spec)
Run locally:
cd ionic_frontend
npm run smoke:playwright:stagingBackend Unit Tests
cd rails_api
# Interaction specs
bundle exec rspec spec/interactions/user_identity/clerk_user_sync_spec.rb
bundle exec rspec spec/interactions/user_identity/update_user_spec.rb
bundle exec rspec spec/interactions/user_identity/update_user_photo_spec.rb
bundle exec rspec spec/interactions/user_identity/record_sign_in_spec.rb
# GraphQL specs
bundle exec rspec spec/requests/mutations/sync_user_spec.rb
bundle exec rspec spec/requests/mutations/update_user_spec.rb
bundle exec rspec spec/requests/mutations/check_valid_username_spec.rb
# Service specs
bundle exec rspec spec/services/clerk_jwt_verifier_spec.rbFrontend Unit Tests
cd ionic_frontend
npm run test:unit -- --run -t "Settings"
npm run test:unit -- --run -t "SignIn"
npm run test:unit -- --run -t "WelcomeLayout"
npm run test:unit -- --run -t "ProfileCompletionModal"
npm run test:unit -- --run -t "ChangelogModal"
npm run test:unit -- --run -t "useClerkSync"
npm run test:unit -- --run -t "useTheme"GraphQL API Reference
Mutations
mutation SyncUser($clerkUserId: String!, $email: String, $firstName: String, $lastName: String, $photoUrl: String) {
syncUser(clerkUserId: $clerkUserId, email: $email, firstName: $firstName, lastName: $lastName, photoUrl: $photoUrl) {
user { publicId firstName lastName email username }
firstSignIn # Boolean — true on first-ever sign-in
errors
}
}
mutation UpdateUser($userId: ID!, $firstName: String, $lastName: String, $username: String, $email: String, $password: String, $passwordConfirmation: String, $deleteUser: Boolean) {
updateUser(userId: $userId, firstName: $firstName, lastName: $lastName, username: $username, email: $email, password: $password, passwordConfirmation: $passwordConfirmation, deleteUser: $deleteUser) {
user { publicId firstName lastName username email }
errors
}
}
mutation UpdateUserPhoto($imageUrl: String!, $userId: ID!) {
updateUserPhoto(imageUrl: $imageUrl, userId: $userId) {
userPhoto { id imageUrl }
}
}
mutation CheckValidUsername($username: String!) {
checkValidUsername(username: $username) {
result { success }
}
}
mutation StoreUserDetails($appVersion: String, $appFeedback: String) {
storeUserDetails(appVersion: $appVersion, appFeedback: $appFeedback) {
result { success }
}
}Known Behaviors & Edge Cases
Clerk is the auth provider: Do not add custom sign-in/sign-up mutations, password hashing, or JWT generation. All auth flows go through Clerk. Profile updates that change name or username sync to Clerk via the
UpdateUserinteraction.SessionToken header: The JWT is sent as a
SessionTokenheader (PascalCase). This is set by Apollo middleware viaClerk.session.getToken(). Using lowercase or Bearer prefix will fail authentication.First sign-in detection:
isFirstSignInis returned bySYNC_USER_MUTATION. The backend sets this to true whenClerkUserSynccreates a new user (not found byclerk_user_id). Subsequent syncs return false.Username uniqueness: The check is case-insensitive. "JohnDoe" and "johndoe" are considered the same username.
Account deletion:
deleteUser: trueinUPDATE_USER_MUTATIONtriggers soft deletion viaacts_as_paranoid. The user's data is preserved withdeleted_atbut cannot be accessed. There is no self-serve recovery — admin intervention is required.Email verification: Clerk handles email verification. The
emailVerifiedfield on the User model reflects Clerk's verification status, updated on each sync.Theme persistence: Theme preference is stored in localStorage (not on the server). Switching devices means the theme preference doesn't follow — each device has its own setting.
Password change: Passwords are managed by Clerk. The settings form sends the password change to
UpdateUserinteraction which forwards it to Clerk's API.Demo account: The demo button only appears when the URL includes
?demo. The demo account uses a pre-configured email/password. This is a convenience for testing and demos.Fallback auth: When
FALLBACK_AUTH_SECRETis set,/sign-in?fallbackenables a bypass auth mechanism usingFallback {SECRET}:{email}token format. This is used by Cypress smoke tests and should only be enabled in non-production environments.
Last updated: 2026-08-31