YuHeng (玉衡)

A local nutrition tracking app powered by AI

View the Project on GitHub formaxcn/yuheng

Multi-User Support

Implementation Status: **Completed & Stable

YuHeng supports multiple user profiles on the same device, ideal for family or shared household use. Each user has their own isolated data, settings, and nutritional targets.

Implementation Summary

**What’s implemented:

Core Principles

Local-First, Password Authentication

Complete Data Isolation

Each user’s data is fully separated:

Profile Management


Data Model

Users Table

CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL,
    email TEXT UNIQUE,               -- for future SSO
    password_hash TEXT,              -- bcrypt hash (required in multi-user mode)
    avatar TEXT,                     -- emoji or color
    is_active BOOLEAN DEFAULT true,
    is_default BOOLEAN DEFAULT false,
    role TEXT DEFAULT 'user',        -- user, admin (for future SSO)
    created_at TIMESTAMP DEFAULT NOW(),
    last_login_at TIMESTAMP,
    sso_provider TEXT,               -- oauth, oidc, etc. (future)
    sso_id TEXT                      -- external user ID (future)
);

-- Default system user for migration (no password - single-user mode)
INSERT INTO users (id, name, is_default) 
VALUES ('00000000-0000-0000-0000-000000000000', 'Default', true);

Multi-User Mode Toggle

Global setting controls behavior:

-- When enabled: show login screen, require password
INSERT INTO settings (key, value) 
VALUES ('multi_user_enabled', 'false');

Single-User Mode (default):

Multi-User Mode:

Schema Changes for All Tables

Add user_id column with foreign key constraint:

-- recipes
ALTER TABLE recipes ADD COLUMN user_id UUID 
    DEFAULT '00000000-0000-0000-0000-000000000000' 
    REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX idx_recipes_user_id ON recipes(user_id);

-- entries
ALTER TABLE entries ADD COLUMN user_id UUID 
    DEFAULT '00000000-0000-0000-0000-000000000000' 
    REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX idx_entries_user_id ON entries(user_id);

-- dishes
ALTER TABLE dishes ADD COLUMN user_id UUID 
    DEFAULT '00000000-0000-0000-0000-000000000000' 
    REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX idx_dishes_user_id ON dishes(user_id);

-- settings (composite primary key)
ALTER TABLE settings RENAME TO settings_old;
CREATE TABLE settings (
    user_id UUID REFERENCES users(id) ON DELETE CASCADE,
    key TEXT NOT NULL,
    value TEXT,
    PRIMARY KEY (user_id, key)
);
CREATE INDEX idx_settings_user_id ON settings(user_id);

-- migration: copy old settings to default user
INSERT INTO settings (user_id, key, value)
SELECT '00000000-0000-0000-0000-000000000000', key, value 
FROM settings_old;
DROP TABLE settings_old;

-- recognition_tasks
ALTER TABLE recognition_tasks ADD COLUMN user_id UUID 
    DEFAULT '00000000-0000-0000-0000-000000000000' 
    REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX idx_recognition_tasks_user_id ON recognition_tasks(user_id);

Architecture

User Context Layer

// lib/db/user-context.ts
export class UserContext {
    private static currentUserId: string = '00000000-0000-0000-0000-000000000000';
    
    static set(userId: string): void {
        this.currentUserId = userId;
    }
    
    static get(): string {
        return this.currentUserId;
    }
    
    static isDefault(): boolean {
        return this.currentUserId === '00000000-0000-0000-0000-000000000000';
    }
}

Database Adapter Integration

All queries automatically filter by user_id:

// All queries include user_id filter
async getRecipe(name: string): Promise<Recipe | undefined> {
    const rows = await this.sql`
        SELECT * FROM recipes 
        WHERE name = ${name} 
        AND user_id = ${UserContext.get()}
    `;
    return rows[0] as Recipe | undefined;
}

async createRecipe(recipe: Omit<Recipe, 'id' | 'created_at'>): Promise<Recipe> {
    const rows = await this.sql`
        INSERT INTO recipes (name, energy, ..., user_id)
        VALUES (${recipe.name}, ..., ${UserContext.get()})
        RETURNING id
    `;
    return { ...recipe, id: rows[0].id } as Recipe;
}

Settings Per User

Settings become user-scoped with fallback to global defaults:

async getSetting(key: string): Promise<string | undefined> {
    // First try user-specific
    const rows = await this.sql`
        SELECT value FROM settings 
        WHERE key = ${key} 
        AND user_id = ${UserContext.get()}
    `;
    if (rows.length > 0) return rows[0].value;
    
    // Fallback to default user (global defaults)
    const fallback = await this.sql`
        SELECT value FROM settings 
        WHERE key = ${key} 
        AND user_id = '00000000-0000-0000-0000-000000000000'
    `;
    return fallback[0]?.value;
}

Frontend Integration

User Switcher Component

┌─────────────────────────────────┐
│ 👤 Alice  ▼                     │  ← Header dropdown
├─────────────────────────────────┤
│ 👤 Alice  ✓                     │
│ 👤 Bob                          │
│ 👤 Charlie                      │
│ ──────────────────────────────  │
│ ➕ Add Profile                  │
│ ⚙️ Manage Profiles              │
└─────────────────────────────────┘

Profile Management Page

Session Management

Authentication Flow:

// lib/auth/auth.ts
export class AuthService {
    static async login(userId: string, password: string): Promise<boolean> {
        const user = await db.getUser(userId);
        if (!user?.password_hash) return false;
        return bcrypt.compare(password, user.password_hash);
    }
    
    static async setPassword(userId: string, password: string): Promise<void> {
        const hash = await bcrypt.hash(password, 12);
        await db.updateUser(userId, { password_hash: hash });
    }
    
    static async enableMultiUserMode(adminPassword: string): Promise<void> {
        // 1. Set password for default user
        await this.setPassword(DEFAULT_USER_ID, adminPassword);
        // 2. Toggle multi-user mode setting
        await db.saveSetting('multi_user_enabled', 'true');
    }
}

Session State:

Enabling Multi-User Mode:

  1. User goes to Settings → Enable Multi-User
  2. Prompt to create admin password for default user
  3. Migrate existing data to default user
  4. Show login screen on next app load

Migration Strategy

Phase 1: Schema Migration (Backward Compatible)

  1. Add user_id columns with default value to all tables
  2. Create users table and insert default user
  3. Migrate settings to composite key
  4. Existing data automatically belongs to default user

Phase 2: DB Layer Integration

  1. Add UserContext class
  2. Update all queries to filter/insert user_id
  3. No behavior change yet - always uses default user

Phase 3: Frontend UI

  1. User Switcher component in header
  2. Profile management page
  3. User CRUD API endpoints

Phase 4: Optional Features


API Endpoints

// Authentication
POST   /api/auth/login          Login with user ID + password
POST   /api/auth/logout         Clear session
POST   /api/auth/enable         Enable multi-user mode (set admin password)
POST   /api/auth/disable        Disable multi-user mode (admin only)
GET    /api/auth/status         Get auth mode and current session

// User management
GET    /api/users               List all users (name, avatar only)
POST   /api/users               Create user (admin only)
PATCH  /api/users/:id           Update user (admin or self)
DELETE /api/users/:id           Delete user (admin only)
POST   /api/users/:id/password  Set/change password (admin or self)

// Session
GET    /api/users/me            Get current user profile

Edge Cases & Considerations

Default User

Data Deletion

Recipe Sharing

Future: Family Mode


SSO Readiness

Current architecture supports future SSO integration:

Future SSO Integration Path:

  1. Add OAuth provider config (Google, Apple, etc.)
  2. Create callback endpoints for each provider
  3. Link SSO identities to existing local users
  4. Optional: Migrate local password accounts to SSO

Implementation Checklist

Phase 1: Database Schema ✅

Phase 2: Backend Core ✅

Phase 3: API Endpoints ✅

Phase 4: Frontend - Single-User Mode (Default) ✅

Phase 5: Frontend - Multi-User Mode ✅

Phase 6: Testing & Migration ✅