Skip to main content

OdontoX Mobile App — Foundation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Scaffold odontox-app/ (Expo SDK 55) with complete auth (email/password → mPIN → biometric), role-based navigation shells for all 4 roles (patient, doctor, admin, receptionist), and the mobile permission engine (DB + API + app hook). Architecture: Expo Router v7 file-based routing with native-tabs layout groups per role. JWT lives in expo-secure-store (Keychain/Keystore); mPIN is validated locally as SHA-256 hash; biometrics wrap the PIN gate. Role is stored in Zustand after PIN entry and drives which layout group mounts. One new backend endpoint (POST /api/v1/auth/mobile-signin) skips Turnstile. One new protected endpoint (GET /api/v1/protected/mobile/permissions) returns the role’s enabled module set, seeded per-clinic from a new mobile_role_permissions table. Tech Stack: Expo SDK 55, RN 0.83, Expo Router v7, NativeWind v4, Zustand, expo-secure-store, expo-local-authentication, expo-crypto, React Query v5, jest-expo, @testing-library/react-native, Hono (Cloudflare Workers), Drizzle ORM, Neon Postgres

File Map

New — odontox-app/ (repo root)

odontox-app/
├── app.json
├── eas.json
├── package.json
├── tsconfig.json
├── tailwind.config.js
├── babel.config.js
├── metro.config.js
├── jest.config.js
├── global.css                         ← NativeWind v4 CSS entry
├── app/
│   ├── _layout.tsx                    ← Root: auth gate, routes by state
│   ├── +not-found.tsx
│   ├── auth/
│   │   ├── _layout.tsx                ← Auth stack (no back navigation)
│   │   ├── login.tsx                  ← Email + password login
│   │   ├── set-pin.tsx                ← First-time mPIN setup (4–6 digits)
│   │   └── pin.tsx                    ← Return-user PIN entry + biometric
│   ├── (patient)/
│   │   ├── _layout.tsx                ← Patient native-tabs shell
│   │   └── index.tsx                  ← Patient home placeholder
│   ├── (doctor)/
│   │   ├── _layout.tsx                ← Doctor native-tabs shell
│   │   └── index.tsx                  ← Doctor home placeholder
│   ├── (admin)/
│   │   ├── _layout.tsx                ← Admin native-tabs shell
│   │   └── index.tsx                  ← Admin home placeholder
│   └── (receptionist)/
│       ├── _layout.tsx                ← Receptionist native-tabs shell
│       └── index.tsx                  ← Receptionist home placeholder
├── components/ui/
│   ├── Button.tsx                     ← Brand-styled pressable
│   ├── TextInput.tsx                  ← Branded text input
│   └── PinPad.tsx                     ← 6-dot display + 10-key pad
├── constants/
│   └── theme.ts                       ← Brand colors, typography
├── hooks/
│   ├── useAuth.ts                     ← Selector + action helpers over Zustand
│   └── usePermissions.ts              ← can(module) boolean hook
├── lib/
│   ├── api.ts                         ← Base fetch: JWT inject, 401 refresh, retry
│   ├── secure-storage.ts              ← SecureStore key wrappers (typed)
│   ├── pin.ts                         ← mPIN hash (SHA-256 + device salt) / verify
│   └── permissions.ts                 ← Fetch + cache permission set (TTL 24h)
├── store/
│   └── auth.ts                        ← Zustand: user, jwt, pinHash, initialized
└── __tests__/
    ├── lib/api.test.ts
    ├── lib/secure-storage.test.ts
    ├── lib/pin.test.ts
    ├── lib/permissions.test.ts
    └── store/auth.test.ts

Modified — server/

server/src/
├── routes/
│   ├── auth.ts                        ← Add POST /mobile-signin (no Turnstile)
│   └── mobile.ts                      ← New: GET /protected/mobile/permissions
├── schema/
│   └── mobile_role_permissions.ts     ← New Drizzle table schema
└── drizzle/
    └── 0029_mobile_role_permissions.sql

Modified — ui/

ui/src/components/settings/
└── MobilePermissionsPanel.tsx         ← Toggle panel in existing Staff Settings page

Task 1: Scaffold Expo SDK 55 project

Files: Create odontox-app/ at repo root with app.json, package.json, tsconfig.json, babel.config.js, metro.config.js, tailwind.config.js, global.css, jest.config.js
  • Step 1: Create the project
Run from /Users/ssh/Documents/Beta-App/odontoX:
npx create-expo-app@latest odontox-app --template blank-typescript
Expected output: ✅ Your project is ready!
  • Step 2: Install all dependencies
cd odontox-app
npx expo install expo-router expo-secure-store expo-local-authentication \
  expo-crypto expo-notifications expo-file-system expo-sharing \
  react-native-safe-area-context react-native-screens \
  react-native-gesture-handler react-native-reanimated

npm install nativewind@^4.1.0 tailwindcss@^3.4.0 \
  zustand@^5.0.0 @tanstack/react-query@^5.50.0 \
  @testing-library/react-native@^12.0.0
  • Step 3: Write app.json
{
  "expo": {
    "name": "OdontoX",
    "slug": "odontox",
    "version": "1.0.0",
    "sdkVersion": "55.0.0",
    "scheme": "odontox",
    "orientation": "default",
    "icon": "./assets/icon.png",
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#5048E5"
    },
    "ios": {
      "bundleIdentifier": "io.odontox.app",
      "supportsTablet": true,
      "infoPlist": {
        "NSFaceIDUsageDescription": "Allow OdontoX to use Face ID for secure access"
      }
    },
    "android": {
      "package": "io.odontox.app",
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#5048E5"
      }
    },
    "plugins": [
      "expo-router",
      "expo-secure-store",
      [
        "expo-local-authentication",
        {
          "faceIDPermission": "Allow OdontoX to use Face ID for secure access"
        }
      ],
      [
        "expo-notifications",
        {
          "color": "#5048E5"
        }
      ]
    ],
    "experiments": {
      "typedRoutes": true
    }
  }
}
  • Step 4: Write tsconfig.json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "paths": {
      "@/*": ["./*"]
    }
  }
}
  • Step 5: Write babel.config.js
module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: ['nativewind/babel', 'react-native-reanimated/plugin'],
  };
};
  • Step 6: Write metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');

const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });
  • Step 7: Write tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './app/**/*.{js,jsx,ts,tsx}',
    './components/**/*.{js,jsx,ts,tsx}',
  ],
  presets: [require('nativewind/preset')],
  theme: {
    extend: {
      colors: {
        brand: {
          DEFAULT: '#5048E5',
          dark: '#3730A3',
          light: '#818CF8',
          50: '#EEF2FF',
        },
      },
      fontFamily: {
        sans: ['System'],
      },
    },
  },
};
  • Step 8: Write global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
  • Step 9: Write jest.config.js
module.exports = {
  preset: 'jest-expo',
  setupFilesAfterFramework: ['@testing-library/react-native/extend-expect'],
  transformIgnorePatterns: [
    'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|nativewind|tailwindcss)',
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$1',
  },
};
  • Step 10: Verify build config compiles
npx expo export --platform ios --dev false --output-dir /tmp/odontox-export-test 2>&1 | tail -5
Expected: no fatal errors (asset errors are OK at this stage — no screens yet)
  • Step 11: Commit
git add odontox-app/
git commit -m "feat(mobile): scaffold Expo SDK 55 project with NativeWind + Expo Router v7"

Task 2: Brand theme and shared UI primitives

Files:
  • Create: odontox-app/constants/theme.ts
  • Create: odontox-app/components/ui/Button.tsx
  • Create: odontox-app/components/ui/TextInput.tsx
  • Step 1: Write constants/theme.ts
export const Colors = {
  brand: '#5048E5',
  brandDark: '#3730A3',
  brandLight: '#818CF8',
  brandSurface: '#EEF2FF',
  white: '#FFFFFF',
  background: '#F8FAFC',
  surface: '#FFFFFF',
  border: '#E2E8F0',
  text: '#0F172A',
  textSecondary: '#64748B',
  textMuted: '#94A3B8',
  error: '#EF4444',
  success: '#10B981',
  warning: '#F59E0B',
} as const;

export const Typography = {
  xs: 11,
  sm: 13,
  base: 15,
  md: 17,
  lg: 20,
  xl: 24,
  '2xl': 30,
  '3xl': 36,
} as const;

export const Spacing = {
  xs: 4,
  sm: 8,
  md: 16,
  lg: 24,
  xl: 32,
  '2xl': 48,
} as const;
  • Step 2: Write components/ui/Button.tsx
import { Pressable, Text, ActivityIndicator, PressableProps } from 'react-native';
import { Colors, Typography } from '@/constants/theme';

interface ButtonProps extends PressableProps {
  title: string;
  variant?: 'primary' | 'secondary' | 'ghost';
  loading?: boolean;
}

export function Button({ title, variant = 'primary', loading, disabled, style, ...props }: ButtonProps) {
  const bg = variant === 'primary' ? Colors.brand
    : variant === 'secondary' ? Colors.brandSurface
    : 'transparent';
  const color = variant === 'primary' ? Colors.white
    : variant === 'ghost' ? Colors.brand
    : Colors.brand;

  return (
    <Pressable
      {...props}
      disabled={disabled || loading}
      style={[
        {
          backgroundColor: bg,
          borderRadius: 12,
          paddingVertical: 14,
          paddingHorizontal: 24,
          alignItems: 'center',
          justifyContent: 'center',
          opacity: disabled || loading ? 0.6 : 1,
          flexDirection: 'row',
          gap: 8,
        },
        style as any,
      ]}
    >
      {loading && <ActivityIndicator color={color} size="small" />}
      <Text style={{ color, fontSize: Typography.base, fontWeight: '600' }}>
        {title}
      </Text>
    </Pressable>
  );
}
  • Step 3: Write components/ui/TextInput.tsx
import { TextInput as RNTextInput, TextInputProps, View, Text } from 'react-native';
import { Colors, Typography } from '@/constants/theme';

interface InputProps extends TextInputProps {
  label?: string;
  error?: string;
}

export function TextInput({ label, error, style, ...props }: InputProps) {
  return (
    <View style={{ gap: 6 }}>
      {label && (
        <Text style={{ fontSize: Typography.sm, fontWeight: '500', color: Colors.text }}>
          {label}
        </Text>
      )}
      <RNTextInput
        {...props}
        style={[
          {
            borderWidth: 1,
            borderColor: error ? Colors.error : Colors.border,
            borderRadius: 10,
            paddingHorizontal: 14,
            paddingVertical: 12,
            fontSize: Typography.base,
            color: Colors.text,
            backgroundColor: Colors.white,
          },
          style,
        ]}
        placeholderTextColor={Colors.textMuted}
      />
      {error && (
        <Text style={{ fontSize: Typography.xs, color: Colors.error }}>{error}</Text>
      )}
    </View>
  );
}
  • Step 4: Commit
git add odontox-app/constants/ odontox-app/components/
git commit -m "feat(mobile): add brand theme constants and UI primitives"

Task 3: Secure storage library

Files:
  • Create: odontox-app/lib/secure-storage.ts
  • Create: odontox-app/__tests__/lib/secure-storage.test.ts
  • Step 1: Write failing tests
odontox-app/__tests__/lib/secure-storage.test.ts:
jest.mock('expo-secure-store', () => ({
  getItemAsync: jest.fn(),
  setItemAsync: jest.fn(),
  deleteItemAsync: jest.fn(),
}));

import * as SecureStore from 'expo-secure-store';
import { getJwt, setJwt, getRefreshToken, setRefreshToken, getPinHash, setPinHash, clearAll } from '@/lib/secure-storage';

const mockGet = SecureStore.getItemAsync as jest.Mock;
const mockSet = SecureStore.setItemAsync as jest.Mock;
const mockDelete = SecureStore.deleteItemAsync as jest.Mock;

beforeEach(() => jest.clearAllMocks());

describe('secure-storage', () => {
  it('getJwt returns null when empty', async () => {
    mockGet.mockResolvedValue(null);
    expect(await getJwt()).toBeNull();
    expect(mockGet).toHaveBeenCalledWith('odx_jwt');
  });

  it('setJwt writes to correct key', async () => {
    mockSet.mockResolvedValue(undefined);
    await setJwt('token123');
    expect(mockSet).toHaveBeenCalledWith('odx_jwt', 'token123');
  });

  it('getRefreshToken returns stored value', async () => {
    mockGet.mockResolvedValue('refresh_abc');
    expect(await getRefreshToken()).toBe('refresh_abc');
    expect(mockGet).toHaveBeenCalledWith('odx_refresh');
  });

  it('getPinHash returns stored hash', async () => {
    mockGet.mockResolvedValue('abc123hash');
    expect(await getPinHash()).toBe('abc123hash');
    expect(mockGet).toHaveBeenCalledWith('odx_pin_hash');
  });

  it('clearAll deletes all keys', async () => {
    mockDelete.mockResolvedValue(undefined);
    await clearAll();
    expect(mockDelete).toHaveBeenCalledWith('odx_jwt');
    expect(mockDelete).toHaveBeenCalledWith('odx_refresh');
    expect(mockDelete).toHaveBeenCalledWith('odx_pin_hash');
    expect(mockDelete).toHaveBeenCalledWith('odx_user');
    expect(mockDelete).toHaveBeenCalledWith('odx_permissions');
  });
});
  • Step 2: Run tests — verify they fail
cd odontox-app && npx jest __tests__/lib/secure-storage.test.ts --no-coverage 2>&1 | tail -10
Expected: FAIL — Cannot find module ’@/lib/secure-storage’
  • Step 3: Implement lib/secure-storage.ts
import * as SecureStore from 'expo-secure-store';

const KEYS = {
  JWT: 'odx_jwt',
  REFRESH: 'odx_refresh',
  PIN_HASH: 'odx_pin_hash',
  USER: 'odx_user',
  PERMISSIONS: 'odx_permissions',
} as const;

export const getJwt = () => SecureStore.getItemAsync(KEYS.JWT);
export const setJwt = (v: string) => SecureStore.setItemAsync(KEYS.JWT, v);
export const deleteJwt = () => SecureStore.deleteItemAsync(KEYS.JWT);

export const getRefreshToken = () => SecureStore.getItemAsync(KEYS.REFRESH);
export const setRefreshToken = (v: string) => SecureStore.setItemAsync(KEYS.REFRESH, v);
export const deleteRefreshToken = () => SecureStore.deleteItemAsync(KEYS.REFRESH);

export const getPinHash = () => SecureStore.getItemAsync(KEYS.PIN_HASH);
export const setPinHash = (v: string) => SecureStore.setItemAsync(KEYS.PIN_HASH, v);
export const deletePinHash = () => SecureStore.deleteItemAsync(KEYS.PIN_HASH);

export const getUserJson = () => SecureStore.getItemAsync(KEYS.USER);
export const setUserJson = (v: string) => SecureStore.setItemAsync(KEYS.USER, v);

export const getPermissionsJson = () => SecureStore.getItemAsync(KEYS.PERMISSIONS);
export const setPermissionsJson = (v: string) => SecureStore.setItemAsync(KEYS.PERMISSIONS, v);
export const deletePermissionsJson = () => SecureStore.deleteItemAsync(KEYS.PERMISSIONS);

export async function clearAll(): Promise<void> {
  await Promise.all([
    SecureStore.deleteItemAsync(KEYS.JWT),
    SecureStore.deleteItemAsync(KEYS.REFRESH),
    SecureStore.deleteItemAsync(KEYS.PIN_HASH),
    SecureStore.deleteItemAsync(KEYS.USER),
    SecureStore.deleteItemAsync(KEYS.PERMISSIONS),
  ]);
}
  • Step 4: Run tests — verify they pass
npx jest __tests__/lib/secure-storage.test.ts --no-coverage 2>&1 | tail -5
Expected: PASS __tests__/lib/secure-storage.test.ts
  • Step 5: Commit
git add odontox-app/lib/secure-storage.ts odontox-app/__tests__/lib/secure-storage.test.ts
git commit -m "feat(mobile): add secure storage lib with full test coverage"

Task 4: mPIN hash utility

Files:
  • Create: odontox-app/lib/pin.ts
  • Create: odontox-app/__tests__/lib/pin.test.ts
  • Step 1: Write failing tests
odontox-app/__tests__/lib/pin.test.ts:
jest.mock('expo-crypto', () => ({
  digestStringAsync: jest.fn(),
  CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
}));
jest.mock('expo-secure-store', () => ({
  getItemAsync: jest.fn(),
  setItemAsync: jest.fn(),
}));

import * as Crypto from 'expo-crypto';
import { hashPin, verifyPin } from '@/lib/pin';

const mockDigest = Crypto.digestStringAsync as jest.Mock;

describe('pin', () => {
  it('hashPin returns hex string of expected length', async () => {
    mockDigest.mockResolvedValue('abc123def456'.padEnd(64, '0'));
    const result = await hashPin('1234', 'device-salt');
    expect(typeof result).toBe('string');
    expect(mockDigest).toHaveBeenCalledWith(
      Crypto.CryptoDigestAlgorithm.SHA256,
      'device-salt:1234',
    );
  });

  it('verifyPin returns true when hashes match', async () => {
    const hash = 'abc123def456'.padEnd(64, '0');
    mockDigest.mockResolvedValue(hash);
    expect(await verifyPin('1234', hash, 'device-salt')).toBe(true);
  });

  it('verifyPin returns false when hashes differ', async () => {
    mockDigest.mockResolvedValue('wronghash'.padEnd(64, '0'));
    expect(await verifyPin('9999', 'abc123def456'.padEnd(64, '0'), 'device-salt')).toBe(false);
  });
});
  • Step 2: Run tests — verify they fail
npx jest __tests__/lib/pin.test.ts --no-coverage 2>&1 | tail -5
Expected: FAIL — Cannot find module ’@/lib/pin’
  • Step 3: Implement lib/pin.ts
import * as Crypto from 'expo-crypto';

export async function hashPin(pin: string, salt: string): Promise<string> {
  return Crypto.digestStringAsync(
    Crypto.CryptoDigestAlgorithm.SHA256,
    `${salt}:${pin}`,
  );
}

export async function verifyPin(pin: string, storedHash: string, salt: string): Promise<boolean> {
  const hash = await hashPin(pin, salt);
  return hash === storedHash;
}
  • Step 4: Run tests — verify they pass
npx jest __tests__/lib/pin.test.ts --no-coverage 2>&1 | tail -5
Expected: PASS __tests__/lib/pin.test.ts
  • Step 5: Commit
git add odontox-app/lib/pin.ts odontox-app/__tests__/lib/pin.test.ts
git commit -m "feat(mobile): add mPIN SHA-256 hash/verify utility"

Task 5: API client

Files:
  • Create: odontox-app/lib/api.ts
  • Create: odontox-app/__tests__/lib/api.test.ts
  • Step 1: Write failing tests
odontox-app/__tests__/lib/api.test.ts:
jest.mock('@/lib/secure-storage', () => ({
  getJwt: jest.fn(),
  getRefreshToken: jest.fn(),
  setJwt: jest.fn(),
  setRefreshToken: jest.fn(),
  clearAll: jest.fn(),
}));

import { apiRequest, BASE_URL } from '@/lib/api';
import * as storage from '@/lib/secure-storage';

const mockGetJwt = storage.getJwt as jest.Mock;
const mockGetRefresh = storage.getRefreshToken as jest.Mock;
const mockSetJwt = storage.setJwt as jest.Mock;
const mockClearAll = storage.clearAll as jest.Mock;

const mockFetch = jest.fn();
global.fetch = mockFetch;

beforeEach(() => jest.clearAllMocks());

describe('apiRequest', () => {
  it('injects Authorization header with stored JWT', async () => {
    mockGetJwt.mockResolvedValue('test-jwt');
    mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ data: 'ok' }) });

    await apiRequest('/test');

    expect(mockFetch).toHaveBeenCalledWith(
      `${BASE_URL}/test`,
      expect.objectContaining({
        headers: expect.objectContaining({ Authorization: 'Bearer test-jwt' }),
      }),
    );
  });

  it('retries with refreshed token on 401', async () => {
    mockGetJwt.mockResolvedValue('expired-jwt');
    mockGetRefresh.mockResolvedValue('valid-refresh');
    mockFetch
      .mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ error: 'Unauthorized' }) })
      .mockResolvedValueOnce({
        ok: true, status: 200,
        json: async () => ({ jwt: 'new-jwt', refreshToken: 'new-refresh' }),
      })
      .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ data: 'ok' }) });

    const result = await apiRequest('/protected');
    expect(result.data).toBe('ok');
    expect(mockSetJwt).toHaveBeenCalledWith('new-jwt');
  });

  it('calls clearAll and throws on second 401', async () => {
    mockGetJwt.mockResolvedValue('expired-jwt');
    mockGetRefresh.mockResolvedValue('bad-refresh');
    mockFetch
      .mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}) })
      .mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}) });

    await expect(apiRequest('/protected')).rejects.toThrow('SESSION_EXPIRED');
    expect(mockClearAll).toHaveBeenCalled();
  });
});
  • Step 2: Run tests — verify they fail
npx jest __tests__/lib/api.test.ts --no-coverage 2>&1 | tail -5
Expected: FAIL
  • Step 3: Implement lib/api.ts
import { getJwt, getRefreshToken, setJwt, setRefreshToken, clearAll } from '@/lib/secure-storage';

export const BASE_URL = 'https://api.odontox.io/api/v1';

type FetchOptions = RequestInit & { _retry?: boolean };

export async function apiRequest<T = unknown>(path: string, options: FetchOptions = {}): Promise<T> {
  const jwt = await getJwt();

  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    ...(options.headers as Record<string, string> ?? {}),
  };
  if (jwt) headers['Authorization'] = `Bearer ${jwt}`;

  const response = await fetch(`${BASE_URL}${path}`, { ...options, headers });

  if (response.ok) {
    return response.json() as Promise<T>;
  }

  // Attempt one token refresh on 401
  if (response.status === 401 && !options._retry) {
    const refreshToken = await getRefreshToken();
    if (!refreshToken) {
      await clearAll();
      throw new Error('SESSION_EXPIRED');
    }

    const refreshRes = await fetch(`${BASE_URL}/auth/refresh`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken }),
    });

    if (!refreshRes.ok) {
      await clearAll();
      throw new Error('SESSION_EXPIRED');
    }

    const { jwt: newJwt, refreshToken: newRefresh } = await refreshRes.json() as {
      jwt: string;
      refreshToken: string;
    };
    await setJwt(newJwt);
    await setRefreshToken(newRefresh);

    return apiRequest<T>(path, { ...options, _retry: true });
  }

  const body = await response.json().catch(() => ({})) as Record<string, unknown>;
  throw new Error((body.error as string) ?? `HTTP ${response.status}`);
}

export const api = {
  get: <T>(path: string) => apiRequest<T>(path),
  post: <T>(path: string, data: unknown) =>
    apiRequest<T>(path, { method: 'POST', body: JSON.stringify(data) }),
  patch: <T>(path: string, data: unknown) =>
    apiRequest<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
  delete: <T>(path: string) => apiRequest<T>(path, { method: 'DELETE' }),
};
  • Step 4: Run tests — verify they pass
npx jest __tests__/lib/api.test.ts --no-coverage 2>&1 | tail -5
Expected: PASS __tests__/lib/api.test.ts
  • Step 5: Commit
git add odontox-app/lib/api.ts odontox-app/__tests__/lib/api.test.ts
git commit -m "feat(mobile): add API client with JWT injection and transparent refresh"

Task 6: Zustand auth store

Files:
  • Create: odontox-app/store/auth.ts
  • Create: odontox-app/__tests__/store/auth.test.ts
  • Step 1: Define the User type
Add to odontox-app/store/auth.ts (write full file next step, but define the type first for reference):
export interface AppUser {
  id: string;
  email: string;
  name: string;
  role: 'patient' | 'doctor' | 'admin' | 'receptionist';
  clinicId: string | null;
  status: string;
}
  • Step 2: Write failing tests
odontox-app/__tests__/store/auth.test.ts:
jest.mock('@/lib/secure-storage', () => ({
  getJwt: jest.fn(),
  getRefreshToken: jest.fn(),
  getUserJson: jest.fn(),
  getPinHash: jest.fn(),
  setJwt: jest.fn(),
  setRefreshToken: jest.fn(),
  setUserJson: jest.fn(),
  setPinHash: jest.fn(),
  deletePinHash: jest.fn(),
  clearAll: jest.fn(),
}));
jest.mock('@/lib/pin', () => ({ hashPin: jest.fn() }));

import { useAuthStore } from '@/store/auth';
import * as storage from '@/lib/secure-storage';
import { hashPin } from '@/lib/pin';

const mockGetJwt = storage.getJwt as jest.Mock;
const mockGetUser = storage.getUserJson as jest.Mock;
const mockGetPin = storage.getPinHash as jest.Mock;
const mockHashPin = hashPin as jest.Mock;

beforeEach(() => {
  jest.clearAllMocks();
  useAuthStore.setState({ user: null, jwt: null, pinHash: null, initialized: false });
});

describe('useAuthStore', () => {
  it('initialize loads state from SecureStore', async () => {
    mockGetJwt.mockResolvedValue('stored-jwt');
    mockGetPin.mockResolvedValue('stored-pin-hash');
    mockGetUser.mockResolvedValue(JSON.stringify({ id: '1', email: '[email protected]', role: 'doctor', name: 'Dr A', clinicId: 'c1', status: 'active' }));

    await useAuthStore.getState().initialize();

    const s = useAuthStore.getState();
    expect(s.jwt).toBe('stored-jwt');
    expect(s.pinHash).toBe('stored-pin-hash');
    expect(s.user?.role).toBe('doctor');
    expect(s.initialized).toBe(true);
  });

  it('login persists jwt + user to SecureStore', async () => {
    const user = { id: '1', email: '[email protected]', name: 'A', role: 'admin' as const, clinicId: 'c1', status: 'active' };
    await useAuthStore.getState().login('jwt-val', 'refresh-val', user);

    expect(storage.setJwt).toHaveBeenCalledWith('jwt-val');
    expect(storage.setUserJson).toHaveBeenCalledWith(JSON.stringify(user));
    expect(useAuthStore.getState().user).toEqual(user);
  });

  it('setPin hashes with device salt and stores hash', async () => {
    mockHashPin.mockResolvedValue('hash-value');
    await useAuthStore.getState().setPin('123456');
    expect(storage.setPinHash).toHaveBeenCalledWith('hash-value');
    expect(useAuthStore.getState().pinHash).toBe('hash-value');
  });

  it('logout clears all state and SecureStore', async () => {
    useAuthStore.setState({ user: { id: '1', email: '[email protected]', name: 'A', role: 'doctor', clinicId: null, status: 'active' }, jwt: 'j', pinHash: 'p', initialized: true });
    await useAuthStore.getState().logout();
    expect(storage.clearAll).toHaveBeenCalled();
    const s = useAuthStore.getState();
    expect(s.user).toBeNull();
    expect(s.jwt).toBeNull();
  });
});
  • Step 3: Run tests — verify they fail
npx jest __tests__/store/auth.test.ts --no-coverage 2>&1 | tail -5
Expected: FAIL
  • Step 4: Implement store/auth.ts
import { create } from 'zustand';
import {
  getJwt, getRefreshToken, getUserJson, getPinHash,
  setJwt, setRefreshToken, setUserJson, setPinHash,
  deletePinHash, clearAll,
} from '@/lib/secure-storage';
import { hashPin, verifyPin } from '@/lib/pin';

export interface AppUser {
  id: string;
  email: string;
  name: string;
  role: 'patient' | 'doctor' | 'admin' | 'receptionist';
  clinicId: string | null;
  status: string;
}

const DEVICE_SALT = 'odontox-v1';  // static salt — good enough for local PIN; not a secret

interface AuthState {
  user: AppUser | null;
  jwt: string | null;
  refreshToken: string | null;
  pinHash: string | null;
  initialized: boolean;

  initialize: () => Promise<void>;
  login: (jwt: string, refreshToken: string, user: AppUser) => Promise<void>;
  setPin: (pin: string) => Promise<void>;
  verifyPin: (pin: string) => Promise<boolean>;
  clearPin: () => Promise<void>;
  logout: () => Promise<void>;
}

export const useAuthStore = create<AuthState>((set, get) => ({
  user: null,
  jwt: null,
  refreshToken: null,
  pinHash: null,
  initialized: false,

  initialize: async () => {
    const [jwt, pinHash, userJson, refreshToken] = await Promise.all([
      getJwt(),
      getPinHash(),
      getUserJson(),
      getRefreshToken(),
    ]);
    const user = userJson ? (JSON.parse(userJson) as AppUser) : null;
    set({ jwt, pinHash, user, refreshToken, initialized: true });
  },

  login: async (jwt, refreshToken, user) => {
    await Promise.all([
      setJwt(jwt),
      setRefreshToken(refreshToken),
      setUserJson(JSON.stringify(user)),
    ]);
    set({ jwt, refreshToken, user });
  },

  setPin: async (pin) => {
    const hash = await hashPin(pin, DEVICE_SALT);
    await setPinHash(hash);
    set({ pinHash: hash });
  },

  verifyPin: async (pin) => {
    const { pinHash } = get();
    if (!pinHash) return false;
    return verifyPin(pin, pinHash, DEVICE_SALT);
  },

  clearPin: async () => {
    await deletePinHash();
    set({ pinHash: null });
  },

  logout: async () => {
    await clearAll();
    set({ user: null, jwt: null, refreshToken: null, pinHash: null });
  },
}));
  • Step 5: Run tests — verify they pass
npx jest __tests__/store/auth.test.ts --no-coverage 2>&1 | tail -5
Expected: PASS __tests__/store/auth.test.ts
  • Step 6: Commit
git add odontox-app/store/auth.ts odontox-app/__tests__/store/auth.test.ts
git commit -m "feat(mobile): add Zustand auth store with initialize/login/PIN/logout"

Task 7: PinPad UI component

Files:
  • Create: odontox-app/components/ui/PinPad.tsx
  • Step 1: Write components/ui/PinPad.tsx
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Colors, Typography } from '@/constants/theme';

interface PinPadProps {
  value: string;
  maxLength?: number;
  onChange: (v: string) => void;
  onSubmit?: () => void;
}

const KEYS = ['1','2','3','4','5','6','7','8','9','','0','⌫'];

export function PinPad({ value, maxLength = 6, onChange, onSubmit }: PinPadProps) {
  function press(key: string) {
    if (key === '⌫') {
      onChange(value.slice(0, -1));
      return;
    }
    if (key === '') return;
    if (value.length >= maxLength) return;
    const next = value + key;
    onChange(next);
    if (next.length === maxLength) onSubmit?.();
  }

  return (
    <View style={styles.container}>
      {/* Dot display */}
      <View style={styles.dots}>
        {Array.from({ length: maxLength }).map((_, i) => (
          <View
            key={i}
            style={[styles.dot, i < value.length && styles.dotFilled]}
          />
        ))}
      </View>

      {/* 10-key grid */}
      <View style={styles.grid}>
        {KEYS.map((key, idx) => (
          <Pressable
            key={idx}
            onPress={() => press(key)}
            style={({ pressed }) => [styles.key, pressed && styles.keyPressed]}
          >
            <Text style={styles.keyText}>{key}</Text>
          </Pressable>
        ))}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { alignItems: 'center', gap: 32 },
  dots: { flexDirection: 'row', gap: 16 },
  dot: {
    width: 14, height: 14, borderRadius: 7,
    borderWidth: 2, borderColor: Colors.brand,
  },
  dotFilled: { backgroundColor: Colors.brand },
  grid: { flexDirection: 'row', flexWrap: 'wrap', width: 240, gap: 0 },
  key: {
    width: 80, height: 72,
    alignItems: 'center', justifyContent: 'center',
  },
  keyPressed: { backgroundColor: Colors.brandSurface },
  keyText: { fontSize: Typography.xl, color: Colors.text, fontWeight: '400' },
});
  • Step 2: Commit
git add odontox-app/components/ui/PinPad.tsx
git commit -m "feat(mobile): add PinPad component with dot display and 10-key grid"

Task 8: Auth screens (login, set-pin, pin)

Files:
  • Create: odontox-app/app/auth/_layout.tsx
  • Create: odontox-app/app/auth/login.tsx
  • Create: odontox-app/app/auth/set-pin.tsx
  • Create: odontox-app/app/auth/pin.tsx
  • Step 1: Write app/auth/_layout.tsx
import { Stack } from 'expo-router';

export default function AuthLayout() {
  return (
    <Stack screenOptions={{ headerShown: false, animation: 'fade' }} />
  );
}
  • Step 2: Write app/auth/login.tsx
import { useState } from 'react';
import { View, Text, ScrollView, KeyboardAvoidingView, Platform, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { Button } from '@/components/ui/Button';
import { TextInput } from '@/components/ui/TextInput';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { useAuthStore } from '@/store/auth';
import { api } from '@/lib/api';
import type { AppUser } from '@/store/auth';

interface MobileSigninResponse {
  jwt: string;
  refreshToken: string;
  user: AppUser;
}

export default function LoginScreen() {
  const router = useRouter();
  const { login, pinHash } = useAuthStore();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  async function handleLogin() {
    if (!email.trim() || !password) {
      setError('Email and password are required');
      return;
    }
    setError('');
    setLoading(true);
    try {
      const data = await api.post<MobileSigninResponse>('/auth/mobile-signin', {
        email: email.trim().toLowerCase(),
        password,
      });
      await login(data.jwt, data.refreshToken, data.user);
      // Route to set-pin on first login, else pin entry
      router.replace(pinHash ? '/auth/pin' : '/auth/set-pin');
    } catch (e: unknown) {
      const msg = e instanceof Error ? e.message : 'Login failed';
      setError(msg);
    } finally {
      setLoading(false);
    }
  }

  return (
    <KeyboardAvoidingView
      style={{ flex: 1, backgroundColor: Colors.background }}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
    >
      <ScrollView
        contentContainerStyle={{ flexGrow: 1, justifyContent: 'center', padding: Spacing.xl }}
        keyboardShouldPersistTaps="handled"
      >
        <View style={{ marginBottom: Spacing['2xl'], alignItems: 'center' }}>
          <View style={{
            width: 56, height: 56, borderRadius: 16,
            backgroundColor: Colors.brand, marginBottom: Spacing.md,
          }} />
          <Text style={{ fontSize: Typography['2xl'], fontWeight: '700', color: Colors.text }}>
            OdontoX
          </Text>
          <Text style={{ fontSize: Typography.base, color: Colors.textSecondary, marginTop: 4 }}>
            Sign in to continue
          </Text>
        </View>

        <View style={{ gap: Spacing.md }}>
          <TextInput
            label="Email"
            value={email}
            onChangeText={setEmail}
            autoCapitalize="none"
            keyboardType="email-address"
            returnKeyType="next"
            textContentType="emailAddress"
            placeholder="[email protected]"
          />
          <TextInput
            label="Password"
            value={password}
            onChangeText={setPassword}
            secureTextEntry
            returnKeyType="done"
            textContentType="password"
            placeholder="••••••••"
            onSubmitEditing={handleLogin}
          />
          {error ? (
            <Text style={{ fontSize: Typography.sm, color: Colors.error }}>{error}</Text>
          ) : null}
          <Button title="Sign in" onPress={handleLogin} loading={loading} />
        </View>
      </ScrollView>
    </KeyboardAvoidingView>
  );
}
  • Step 3: Write app/auth/set-pin.tsx
import { useState } from 'react';
import { View, Text, SafeAreaView } from 'react-native';
import { useRouter } from 'expo-router';
import { PinPad } from '@/components/ui/PinPad';
import { Button } from '@/components/ui/Button';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { useAuthStore } from '@/store/auth';

export default function SetPinScreen() {
  const router = useRouter();
  const { setPin, user } = useAuthStore();
  const [pin, setLocalPin] = useState('');
  const [confirm, setConfirm] = useState('');
  const [stage, setStage] = useState<'enter' | 'confirm'>('enter');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  function handleEnter(v: string) {
    setLocalPin(v);
    if (v.length === 6) setStage('confirm');
  }

  async function handleConfirm(v: string) {
    setConfirm(v);
    if (v.length !== 6) return;
    if (v !== pin) {
      setError('PINs do not match. Try again.');
      setLocalPin('');
      setConfirm('');
      setStage('enter');
      return;
    }
    setLoading(true);
    try {
      await setPin(v);
      const roleRoute: Record<string, string> = {
        patient: '/(patient)/',
        doctor: '/(doctor)/',
        admin: '/(admin)/',
        receptionist: '/(receptionist)/',
      };
      router.replace((roleRoute[user?.role ?? ''] ?? '/auth/login') as never);
    } finally {
      setLoading(false);
    }
  }

  return (
    <SafeAreaView style={{ flex: 1, backgroundColor: Colors.background }}>
      <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: Spacing.xl }}>
        <Text style={{ fontSize: Typography.xl, fontWeight: '700', color: Colors.text, marginBottom: 8 }}>
          {stage === 'enter' ? 'Create your PIN' : 'Confirm your PIN'}
        </Text>
        <Text style={{ fontSize: Typography.base, color: Colors.textSecondary, marginBottom: Spacing['2xl'] }}>
          {stage === 'enter' ? 'Set a 6-digit PIN for quick access' : 'Enter the same PIN again'}
        </Text>
        {error ? (
          <Text style={{ fontSize: Typography.sm, color: Colors.error, marginBottom: Spacing.md }}>{error}</Text>
        ) : null}
        <PinPad
          value={stage === 'enter' ? pin : confirm}
          onChange={stage === 'enter' ? setLocalPin : setConfirm}
          onSubmit={() => {
            if (stage === 'enter' && pin.length === 6) setStage('confirm');
            if (stage === 'confirm' && confirm.length === 6) handleConfirm(confirm);
          }}
        />
      </View>
    </SafeAreaView>
  );
}
  • Step 4: Write app/auth/pin.tsx
import { useState, useEffect } from 'react';
import { View, Text, SafeAreaView, Pressable, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import * as LocalAuthentication from 'expo-local-authentication';
import { PinPad } from '@/components/ui/PinPad';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { useAuthStore } from '@/store/auth';

const MAX_ATTEMPTS = 5;

export default function PinScreen() {
  const router = useRouter();
  const { verifyPin, user, logout } = useAuthStore();
  const [pin, setPin] = useState('');
  const [attempts, setAttempts] = useState(0);
  const [error, setError] = useState('');
  const [biometricAvailable, setBiometricAvailable] = useState(false);

  useEffect(() => {
    LocalAuthentication.hasHardwareAsync().then(has => {
      if (has) LocalAuthentication.isEnrolledAsync().then(enrolled => setBiometricAvailable(enrolled));
    });
    // Auto-prompt biometric on mount
    tryBiometric();
  }, []);

  async function tryBiometric() {
    const result = await LocalAuthentication.authenticateAsync({
      promptMessage: 'Use Face ID or fingerprint to sign in',
      fallbackLabel: 'Use PIN',
    });
    if (result.success) navigateToRole();
  }

  async function handlePinSubmit(value: string) {
    const ok = await verifyPin(value);
    if (ok) {
      navigateToRole();
      return;
    }
    const next = attempts + 1;
    setAttempts(next);
    setPin('');
    if (next >= MAX_ATTEMPTS) {
      await logout();
      Alert.alert('Too many attempts', 'You have been signed out for security.', [
        { text: 'OK', onPress: () => router.replace('/auth/login') },
      ]);
      return;
    }
    setError(`Incorrect PIN. ${MAX_ATTEMPTS - next} attempt${MAX_ATTEMPTS - next === 1 ? '' : 's'} remaining.`);
  }

  function navigateToRole() {
    const roleRoute: Record<string, string> = {
      patient: '/(patient)/',
      doctor: '/(doctor)/',
      admin: '/(admin)/',
      receptionist: '/(receptionist)/',
    };
    router.replace((roleRoute[user?.role ?? ''] ?? '/auth/login') as never);
  }

  return (
    <SafeAreaView style={{ flex: 1, backgroundColor: Colors.background }}>
      <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: Spacing.xl }}>
        <Text style={{ fontSize: Typography.xl, fontWeight: '700', color: Colors.text, marginBottom: 8 }}>
          Welcome back
        </Text>
        <Text style={{ fontSize: Typography.base, color: Colors.textSecondary, marginBottom: Spacing['2xl'] }}>
          Enter your PIN to continue
        </Text>
        {error ? (
          <Text style={{ fontSize: Typography.sm, color: Colors.error, marginBottom: Spacing.md }}>{error}</Text>
        ) : null}
        <PinPad
          value={pin}
          onChange={setPin}
          onSubmit={() => pin.length === 6 && handlePinSubmit(pin)}
        />
        {biometricAvailable && (
          <Pressable onPress={tryBiometric} style={{ marginTop: Spacing.xl }}>
            <Text style={{ fontSize: Typography.base, color: Colors.brand, fontWeight: '500' }}>
              Use Face ID / Fingerprint
            </Text>
          </Pressable>
        )}
        <Pressable
          onPress={() => router.replace('/auth/login')}
          style={{ marginTop: Spacing.lg }}
        >
          <Text style={{ fontSize: Typography.sm, color: Colors.textSecondary }}>
            Sign in with a different account
          </Text>
        </Pressable>
      </View>
    </SafeAreaView>
  );
}
  • Step 5: Commit
git add odontox-app/app/auth/ odontox-app/components/ui/PinPad.tsx
git commit -m "feat(mobile): add auth screens — login, set-pin, pin entry with biometric"

Task 9: Root layout auth gate + role shells

Files:
  • Create: odontox-app/app/_layout.tsx
  • Create: odontox-app/app/+not-found.tsx
  • Create: odontox-app/app/(patient)/_layout.tsx
  • Create: odontox-app/app/(patient)/index.tsx
  • Create: odontox-app/app/(doctor)/_layout.tsx
  • Create: odontox-app/app/(doctor)/index.tsx
  • Create: odontox-app/app/(admin)/_layout.tsx
  • Create: odontox-app/app/(admin)/index.tsx
  • Create: odontox-app/app/(receptionist)/_layout.tsx
  • Create: odontox-app/app/(receptionist)/index.tsx
  • Step 1: Write app/_layout.tsx
import { useEffect } from 'react';
import { Slot, useRouter, useSegments } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StatusBar } from 'expo-status-bar';
import { useAuthStore } from '@/store/auth';
import '../global.css';

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
});

function AuthGate() {
  const { jwt, pinHash, user, initialized } = useAuthStore();
  const router = useRouter();
  const segments = useSegments();

  useEffect(() => {
    if (!initialized) return;

    const inAuth = segments[0] === 'auth';
    const inApp = ['(patient)', '(doctor)', '(admin)', '(receptionist)'].includes(segments[0] as string);

    if (!jwt) {
      if (!inAuth || segments[1] !== 'login') router.replace('/auth/login');
      return;
    }
    if (!pinHash) {
      if (!inAuth || segments[1] !== 'set-pin') router.replace('/auth/set-pin');
      return;
    }
    if (!user) {
      if (!inAuth || segments[1] !== 'pin') router.replace('/auth/pin');
      return;
    }
    // Fully authenticated — route to role shell
    if (inAuth) {
      const roleRoute: Record<string, string> = {
        patient: '/(patient)/',
        doctor: '/(doctor)/',
        admin: '/(admin)/',
        receptionist: '/(receptionist)/',
      };
      router.replace((roleRoute[user.role] ?? '/auth/login') as never);
    }
  }, [initialized, jwt, pinHash, user, segments]);

  return <Slot />;
}

export default function RootLayout() {
  const { initialize, initialized } = useAuthStore();

  useEffect(() => {
    if (!initialized) initialize();
  }, []);

  return (
    <QueryClientProvider client={queryClient}>
      <StatusBar style="auto" />
      <AuthGate />
    </QueryClientProvider>
  );
}
  • Step 2: Write app/+not-found.tsx
import { View, Text } from 'react-native';
import { Link } from 'expo-router';
import { Colors, Typography } from '@/constants/theme';

export default function NotFound() {
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }}>
      <Text style={{ fontSize: Typography.xl, fontWeight: '700', color: Colors.text }}>
        Page not found
      </Text>
      <Link href="/" style={{ color: Colors.brand, fontSize: Typography.base }}>
        Go home
      </Link>
    </View>
  );
}
  • Step 3: Write app/(patient)/_layout.tsx
import { Tabs } from 'expo-router';
import { Colors } from '@/constants/theme';

export default function PatientTabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: Colors.brand,
        tabBarInactiveTintColor: Colors.textMuted,
        headerShown: false,
      }}
    >
      <Tabs.Screen name="index" options={{ title: 'Home', tabBarIcon: () => null }} />
      <Tabs.Screen name="appointments/index" options={{ title: 'Appointments', tabBarIcon: () => null }} />
      <Tabs.Screen name="records/index" options={{ title: 'Records', tabBarIcon: () => null }} />
      <Tabs.Screen name="bills/index" options={{ title: 'Bills', tabBarIcon: () => null }} />
      <Tabs.Screen name="chat/index" options={{ title: 'Chat', tabBarIcon: () => null }} />
    </Tabs>
  );
}
  • Step 4: Write app/(patient)/index.tsx
import { View, Text, SafeAreaView } from 'react-native';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { useAuthStore } from '@/store/auth';

export default function PatientHome() {
  const { user } = useAuthStore();
  return (
    <SafeAreaView style={{ flex: 1, backgroundColor: Colors.background }}>
      <View style={{ flex: 1, padding: Spacing.lg }}>
        <Text style={{ fontSize: Typography.xl, fontWeight: '700', color: Colors.text }}>
          Hello, {user?.name?.split(' ')[0] ?? 'there'} 👋
        </Text>
        <Text style={{ fontSize: Typography.base, color: Colors.textSecondary, marginTop: 4 }}>
          Your dental health at a glance
        </Text>
      </View>
    </SafeAreaView>
  );
}
  • Step 5: Write app/(doctor)/_layout.tsx
import { Tabs } from 'expo-router';
import { Colors } from '@/constants/theme';

export default function DoctorTabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: Colors.brand,
        tabBarInactiveTintColor: Colors.textMuted,
        headerShown: false,
      }}
    >
      <Tabs.Screen name="index" options={{ title: 'Home', tabBarIcon: () => null }} />
      <Tabs.Screen name="appointments/index" options={{ title: 'Appointments', tabBarIcon: () => null }} />
      <Tabs.Screen name="patients/index" options={{ title: 'Patients', tabBarIcon: () => null }} />
      <Tabs.Screen name="clinical/index" options={{ title: 'Clinical', tabBarIcon: () => null }} />
      <Tabs.Screen name="chat/index" options={{ title: 'Chat', tabBarIcon: () => null }} />
    </Tabs>
  );
}
  • Step 6: Write app/(doctor)/index.tsx
import { View, Text, SafeAreaView } from 'react-native';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { useAuthStore } from '@/store/auth';

export default function DoctorHome() {
  const { user } = useAuthStore();
  return (
    <SafeAreaView style={{ flex: 1, backgroundColor: Colors.background }}>
      <View style={{ flex: 1, padding: Spacing.lg }}>
        <Text style={{ fontSize: Typography.xl, fontWeight: '700', color: Colors.text }}>
          Dr {user?.name?.split(' ').slice(-1)[0] ?? ''}'s Dashboard
        </Text>
        <Text style={{ fontSize: Typography.base, color: Colors.textSecondary, marginTop: 4 }}>
          Today's schedule
        </Text>
      </View>
    </SafeAreaView>
  );
}
  • Step 7: Write app/(admin)/_layout.tsx
import { Tabs } from 'expo-router';
import { Colors } from '@/constants/theme';

export default function AdminTabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: Colors.brand,
        tabBarInactiveTintColor: Colors.textMuted,
        headerShown: false,
      }}
    >
      <Tabs.Screen name="index" options={{ title: 'Home', tabBarIcon: () => null }} />
      <Tabs.Screen name="appointments/index" options={{ title: 'Appointments', tabBarIcon: () => null }} />
      <Tabs.Screen name="patients/index" options={{ title: 'Patients', tabBarIcon: () => null }} />
      <Tabs.Screen name="clinical/index" options={{ title: 'Clinical', tabBarIcon: () => null }} />
      <Tabs.Screen name="finance/index" options={{ title: 'Finance', tabBarIcon: () => null }} />
      <Tabs.Screen name="chat/index" options={{ title: 'Chat', tabBarIcon: () => null }} />
    </Tabs>
  );
}
  • Step 8: Write app/(admin)/index.tsx
import { View, Text, SafeAreaView } from 'react-native';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { useAuthStore } from '@/store/auth';

export default function AdminHome() {
  const { user } = useAuthStore();
  return (
    <SafeAreaView style={{ flex: 1, backgroundColor: Colors.background }}>
      <View style={{ flex: 1, padding: Spacing.lg }}>
        <Text style={{ fontSize: Typography.xl, fontWeight: '700', color: Colors.text }}>
          Clinic Dashboard
        </Text>
        <Text style={{ fontSize: Typography.base, color: Colors.textSecondary, marginTop: 4 }}>
          {user?.email}
        </Text>
      </View>
    </SafeAreaView>
  );
}
  • Step 9: Write app/(receptionist)/_layout.tsx
import { Tabs } from 'expo-router';
import { Colors } from '@/constants/theme';

export default function ReceptionistTabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: Colors.brand,
        tabBarInactiveTintColor: Colors.textMuted,
        headerShown: false,
      }}
    >
      <Tabs.Screen name="index" options={{ title: 'Home', tabBarIcon: () => null }} />
      <Tabs.Screen name="appointments/index" options={{ title: 'Appointments', tabBarIcon: () => null }} />
      <Tabs.Screen name="patients/index" options={{ title: 'Patients', tabBarIcon: () => null }} />
      <Tabs.Screen name="finance/index" options={{ title: 'Finance', tabBarIcon: () => null }} />
      <Tabs.Screen name="chat/index" options={{ title: 'Chat', tabBarIcon: () => null }} />
    </Tabs>
  );
}
  • Step 10: Write app/(receptionist)/index.tsx
import { View, Text, SafeAreaView } from 'react-native';
import { Colors, Typography, Spacing } from '@/constants/theme';

export default function ReceptionistHome() {
  return (
    <SafeAreaView style={{ flex: 1, backgroundColor: Colors.background }}>
      <View style={{ flex: 1, padding: Spacing.lg }}>
        <Text style={{ fontSize: Typography.xl, fontWeight: '700', color: Colors.text }}>
          Check-in Queue
        </Text>
        <Text style={{ fontSize: Typography.base, color: Colors.textSecondary, marginTop: 4 }}>
          Today's appointments
        </Text>
      </View>
    </SafeAreaView>
  );
}
  • Step 11: Start dev server and verify each role shell navigates correctly
cd odontox-app && npx expo start --ios
Manually test: open app → login screen appears → (you won’t be able to log in yet, backend not done — that’s OK) → confirm no crashes on load.
  • Step 12: Commit
git add odontox-app/app/
git commit -m "feat(mobile): add root auth gate and all 4 role tab shells"

Task 10: Backend — mobile-signin endpoint

Files:
  • Modify: server/src/routes/auth.ts — add POST /mobile-signin
  • Step 1: Add the mobile-signin route to server/src/routes/auth.ts
Find the block after const signinRateLimit = ... line (around line 38) and the auth.post(‘/signin’, …) definition. Add this new route immediately after the existing auth.post('/signin', ...) handler ends (around line 400+). To find the correct insertion point:
grep -n "^auth\.post\|^});" server/src/routes/auth.ts | head -20
Add after the auth.post('/signin', ...) closing });:
// Mobile sign-in — no Turnstile, mobile-specific rate limit
const mobileSigninRateLimit = createRateLimitMiddleware(10, 15 * 60 * 1000);

auth.post('/mobile-signin', mobileSigninRateLimit, async (c) => {
  try {
    const body = await c.req.json() as { email?: string; password?: string };
    const { email, password } = body;

    if (!email || !password) {
      return c.json({ error: 'Email and password are required' }, 400);
    }

    const normalizedEmail = email.toLowerCase().trim();
    const databaseUrl = getDatabaseUrl();
    const db = getReadDb();

    const [user] = await db.select()
      .from(users)
      .where(eq(users.email, normalizedEmail))
      .limit(1);

    if (!user) {
      return c.json({ error: 'The email or password you entered is incorrect.' }, 401);
    }

    // Only active clinic staff and patients may use mobile
    if (user.status !== 'active') {
      return c.json({
        error: 'AccountNotActive',
        message: 'Your account is not active. Please contact your clinic administrator.',
      }, 403);
    }

    // Superadmin accounts must use the web app
    if (user.role === 'superadmin') {
      return c.json({ error: 'SuperadminNotAllowed' }, 403);
    }

    if (!user.passwordHash) {
      return c.json({ error: 'No password set. Please use SSO or reset your password.' }, 401);
    }

    const isValid = await verifyPassword(password, user.passwordHash);
    if (!isValid) {
      return c.json({ error: 'The email or password you entered is incorrect.' }, 401);
    }

    const sessionId = crypto.randomUUID();
    const secretFromEnv = getEnv('JWT_SECRET');
    const kv = (c.env as any)?.OTT_STORE as KVNamespace | undefined;

    const jwt = await generateJWT(
      { sub: user.id, email: user.email, role: user.role, clinicId: user.clinicId, sessionId },
      '7d',
      secretFromEnv,
    );
    const refreshToken = await generateRefreshToken(
      { sub: user.id, email: user.email, sessionId },
      secretFromEnv,
    );

    // Issue refresh token into rotation store
    await issueRefreshToken(
      { hash: await sha256hex(refreshToken), userId: user.id, sessionId, familyId: crypto.randomUUID(), issuedAt: Math.floor(Date.now() / 1000) },
      kv,
    );

    // Update session ID
    const txDb = getTxDb(databaseUrl);
    await txDb.update(users).set({ lastSessionId: sessionId }).where(eq(users.id, user.id));

    return c.json({
      jwt,
      refreshToken,
      user: {
        id: user.id,
        email: user.email,
        name: user.name ?? user.email,
        role: user.role,
        clinicId: user.clinicId,
        status: user.status,
      },
    });
  } catch (error) {
    return handleError(c, error);
  }
});
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | grep -E "error|warning" | head -20
Expected: 0 errors
  • Step 3: Commit
cd .. && git add server/src/routes/auth.ts
git commit -m "feat(server): add POST /auth/mobile-signin — no Turnstile, for Expo app"

Task 11: Backend — mobile permission engine

Files:
  • Create: server/drizzle/0029_mobile_role_permissions.sql
  • Create: server/src/schema/mobile_role_permissions.ts
  • Modify: server/src/schema/index.ts — export new schema
  • Create: server/src/routes/mobile.ts
  • Modify: server/src/api.ts — register mobile route under protected
  • Step 1: Write migration server/drizzle/0029_mobile_role_permissions.sql
CREATE TABLE IF NOT EXISTS "app"."mobile_role_permissions" (
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  "clinic_id" uuid NOT NULL,
  "role" text NOT NULL,
  "module" text NOT NULL,
  "enabled" boolean NOT NULL DEFAULT true,
  "updated_at" timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT "mobile_role_permissions_clinic_id_role_module_unique"
    UNIQUE ("clinic_id", "role", "module")
);
--> statement-breakpoint
ALTER TABLE "app"."mobile_role_permissions"
  ADD CONSTRAINT "mobile_role_permissions_clinic_id_clinics_id_fk"
  FOREIGN KEY ("clinic_id") REFERENCES "app"."clinics"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "mrp_clinic_role_idx"
  ON "app"."mobile_role_permissions" ("clinic_id", "role");
  • Step 2: Write Drizzle schema server/src/schema/mobile_role_permissions.ts
import { pgTable, uuid, text, boolean, timestamptz, unique, index } from 'drizzle-orm/pg-core';
import { clinics } from './clinics';

export const mobileRolePermissions = pgTable(
  'mobile_role_permissions',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    clinicId: uuid('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
    role: text('role').notNull(),
    module: text('module').notNull(),
    enabled: boolean('enabled').notNull().default(true),
    updatedAt: timestamptz('updated_at').notNull().defaultNow(),
  },
  (t) => ({
    uniqueClinicRoleModule: unique('mobile_role_permissions_clinic_id_role_module_unique').on(t.clinicId, t.role, t.module),
    clinicRoleIdx: index('mrp_clinic_role_idx').on(t.clinicId, t.role),
  }),
);

// Phase 1 defaults seeded on clinic creation
export const MOBILE_PERMISSION_DEFAULTS: Record<string, string[]> = {
  patient: [
    'appointments', 'records.treatment_plans', 'records.prescriptions',
    'records.files', 'records.vitals', 'bills', 'chat', 'notifications',
  ],
  doctor: [
    'appointments', 'patients', 'clinical.notes', 'clinical.treatment_plans',
    'clinical.prescriptions', 'clinical.files', 'clinical.vitals',
    'chat', 'notifications', 'stats',
  ],
  admin: [
    'appointments', 'patients', 'clinical.notes', 'clinical.treatment_plans',
    'clinical.prescriptions', 'clinical.files', 'clinical.vitals',
    'billing.invoices', 'billing.receipts', 'billing.payments',
    'chat', 'notifications', 'stats',
  ],
  receptionist: [
    'appointments', 'patients',
    'billing.invoices', 'billing.receipts', 'billing.payments',
    'chat', 'notifications',
  ],
};
  • Step 3: Export from schema index
Open server/src/schema/index.ts and add:
export * from './mobile_role_permissions';
  • Step 4: Write route server/src/routes/mobile.ts
import { Hono } from 'hono';
import { getReadDb } from '../lib/db';
import { mobileRolePermissions, MOBILE_PERMISSION_DEFAULTS } from '../schema/mobile_role_permissions';
import { userClinicAssignments } from '../schema';
import { and, eq } from 'drizzle-orm';
import { getDatabaseUrl } from '../lib/env';
import { handleError } from '../lib/errors';

const mobile = new Hono();

mobile.get('/permissions', async (c) => {
  try {
    const user = c.get('user');
    const db = getReadDb(getDatabaseUrl());

    // Resolve clinic — user.clinicId or first assignment
    let clinicId = user.clinicId;
    if (!clinicId) {
      const [assignment] = await db
        .select({ clinicId: userClinicAssignments.clinicId })
        .from(userClinicAssignments)
        .where(and(
          eq(userClinicAssignments.userId, user.id),
          eq(userClinicAssignments.isActive, true),
        ))
        .limit(1);
      clinicId = assignment?.clinicId ?? null;
    }

    if (!clinicId) {
      return c.json({ modules: MOBILE_PERMISSION_DEFAULTS[user.role] ?? [] });
    }

    const rows = await db
      .select({ module: mobileRolePermissions.module, enabled: mobileRolePermissions.enabled })
      .from(mobileRolePermissions)
      .where(and(
        eq(mobileRolePermissions.clinicId, clinicId),
        eq(mobileRolePermissions.role, user.role),
      ));

    if (rows.length === 0) {
      // Clinic not seeded yet — return defaults
      return c.json({ modules: MOBILE_PERMISSION_DEFAULTS[user.role] ?? [] });
    }

    const modules = rows.filter(r => r.enabled).map(r => r.module);
    return c.json({ modules });
  } catch (error) {
    return handleError(c, error);
  }
});

export default mobile;
  • Step 5: Register route in server/src/api.ts
Find the protectedRoutes section (around api.route('/protected', protectedRoutes)). Add the import at the top of api.ts with the other imports:
import mobileRoute from './routes/mobile';
Then find where protectedRoutes.route(...) calls are and add:
protectedRoutes.route('/mobile', mobileRoute);
  • Step 6: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | grep error | head -10
Expected: 0 errors
  • Step 7: Commit
cd .. && git add server/drizzle/0029_mobile_role_permissions.sql \
  server/src/schema/mobile_role_permissions.ts \
  server/src/schema/index.ts \
  server/src/routes/mobile.ts \
  server/src/api.ts
git commit -m "feat(server): add mobile_role_permissions table + GET /protected/mobile/permissions"

Task 12: Mobile permission hook

Files:
  • Create: odontox-app/lib/permissions.ts
  • Create: odontox-app/hooks/usePermissions.ts
  • Create: odontox-app/__tests__/lib/permissions.test.ts
  • Step 1: Write failing tests
odontox-app/__tests__/lib/permissions.test.ts:
jest.mock('@/lib/api', () => ({ api: { get: jest.fn() } }));
jest.mock('@/lib/secure-storage', () => ({
  getPermissionsJson: jest.fn(),
  setPermissionsJson: jest.fn(),
}));

import { api } from '@/lib/api';
import * as storage from '@/lib/secure-storage';
import { fetchAndCachePermissions, getCachedPermissions } from '@/lib/permissions';

const mockGet = api.get as jest.Mock;
const mockGetPerm = storage.getPermissionsJson as jest.Mock;
const mockSetPerm = storage.setPermissionsJson as jest.Mock;

beforeEach(() => jest.clearAllMocks());

describe('permissions', () => {
  it('fetchAndCachePermissions stores modules + timestamp', async () => {
    mockGet.mockResolvedValue({ modules: ['appointments', 'patients'] });
    const modules = await fetchAndCachePermissions();
    expect(modules).toEqual(['appointments', 'patients']);
    const stored = JSON.parse((mockSetPerm.mock.calls[0][0] as string));
    expect(stored.modules).toEqual(['appointments', 'patients']);
    expect(typeof stored.fetchedAt).toBe('number');
  });

  it('getCachedPermissions returns null when empty', async () => {
    mockGetPerm.mockResolvedValue(null);
    expect(await getCachedPermissions()).toBeNull();
  });

  it('getCachedPermissions returns null when expired (>24h)', async () => {
    const old = { modules: ['x'], fetchedAt: Date.now() - 25 * 60 * 60 * 1000 };
    mockGetPerm.mockResolvedValue(JSON.stringify(old));
    expect(await getCachedPermissions()).toBeNull();
  });

  it('getCachedPermissions returns modules when fresh', async () => {
    const fresh = { modules: ['appointments'], fetchedAt: Date.now() - 1000 };
    mockGetPerm.mockResolvedValue(JSON.stringify(fresh));
    expect(await getCachedPermissions()).toEqual(['appointments']);
  });
});
  • Step 2: Run tests — verify fail
cd odontox-app && npx jest __tests__/lib/permissions.test.ts --no-coverage 2>&1 | tail -5
Expected: FAIL
  • Step 3: Implement lib/permissions.ts
import { api } from '@/lib/api';
import { getPermissionsJson, setPermissionsJson } from '@/lib/secure-storage';

const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours

interface PermissionCache {
  modules: string[];
  fetchedAt: number;
}

export async function fetchAndCachePermissions(): Promise<string[]> {
  const data = await api.get<{ modules: string[] }>('/protected/mobile/permissions');
  const cache: PermissionCache = { modules: data.modules, fetchedAt: Date.now() };
  await setPermissionsJson(JSON.stringify(cache));
  return data.modules;
}

export async function getCachedPermissions(): Promise<string[] | null> {
  const raw = await getPermissionsJson();
  if (!raw) return null;
  try {
    const cache = JSON.parse(raw) as PermissionCache;
    if (Date.now() - cache.fetchedAt > TTL_MS) return null;
    return cache.modules;
  } catch {
    return null;
  }
}

export async function getPermissions(): Promise<string[]> {
  const cached = await getCachedPermissions();
  if (cached) return cached;
  return fetchAndCachePermissions();
}
  • Step 4: Implement hooks/usePermissions.ts
import { useState, useEffect } from 'react';
import { getPermissions } from '@/lib/permissions';

let cachedModules: string[] | null = null;

export function usePermissions() {
  const [modules, setModules] = useState<string[]>(cachedModules ?? []);

  useEffect(() => {
    getPermissions().then(m => {
      cachedModules = m;
      setModules(m);
    });
  }, []);

  function can(module: string): boolean {
    return modules.includes(module);
  }

  return { can, modules };
}
  • Step 5: Run tests — verify pass
npx jest __tests__/lib/permissions.test.ts --no-coverage 2>&1 | tail -5
Expected: PASS __tests__/lib/permissions.test.ts
  • Step 6: Commit
git add odontox-app/lib/permissions.ts odontox-app/hooks/ odontox-app/__tests__/lib/permissions.test.ts
git commit -m "feat(mobile): add permission cache lib and usePermissions hook"

Task 13: Fetch permissions after login

Files:
  • Modify: odontox-app/app/auth/login.tsx
  • Modify: odontox-app/app/auth/pin.tsx
  • Step 1: Call fetchAndCachePermissions in login.tsx after successful login
In app/auth/login.tsx, import and call fetchAndCachePermissions after await login(...):
import { fetchAndCachePermissions } from '@/lib/permissions';
// ... inside handleLogin(), after await login(data.jwt, data.refreshToken, data.user):
await fetchAndCachePermissions();
router.replace(pinHash ? '/auth/pin' : '/auth/set-pin');
  • Step 2: Call fetchAndCachePermissions in pin.tsx after biometric/PIN success
In app/auth/pin.tsx, update navigateToRole:
import { fetchAndCachePermissions } from '@/lib/permissions';

async function navigateToRole() {
  await fetchAndCachePermissions().catch(() => {}); // non-blocking, use stale if fails
  const roleRoute: Record<string, string> = {
    patient: '/(patient)/',
    doctor: '/(doctor)/',
    admin: '/(admin)/',
    receptionist: '/(receptionist)/',
  };
  router.replace((roleRoute[user?.role ?? ''] ?? '/auth/login') as never);
}
  • Step 3: Commit
git add odontox-app/app/auth/login.tsx odontox-app/app/auth/pin.tsx
git commit -m "feat(mobile): fetch and cache mobile permissions on login and PIN entry"

Task 14: Web settings — MobilePermissionsPanel

Files:
  • Create: ui/src/components/settings/MobilePermissionsPanel.tsx
  • Step 1: Find where staff settings renders tabs
grep -rn "Mobile\|staff.*setting\|settings.*staff\|permission.*toggle" ui/src/components/ --include="*.tsx" | grep -i "setting\|staff\|permission" | head -10
Note the file path that renders staff settings sections — you will add a <MobilePermissionsPanel /> call there in Step 3.
  • Step 2: Write ui/src/components/settings/MobilePermissionsPanel.tsx
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';

interface MobileModuleRow {
  module: string;
  label: string;
  enabled: boolean;
}

const MODULE_LABELS: Record<string, string> = {
  appointments: 'Appointments',
  patients: 'Patients',
  'clinical.notes': 'Clinical Notes',
  'clinical.treatment_plans': 'Treatment Plans',
  'clinical.prescriptions': 'Prescriptions',
  'clinical.files': 'Patient Files',
  'clinical.vitals': 'Vital Signs',
  'billing.invoices': 'Invoices',
  'billing.receipts': 'Receipts',
  'billing.payments': 'Payments',
  chat: 'Messaging',
  notifications: 'Notifications',
  stats: 'Dashboard Stats',
};

const ROLE_DEFAULT_MODULES: Record<string, string[]> = {
  doctor: ['appointments','patients','clinical.notes','clinical.treatment_plans','clinical.prescriptions','clinical.files','clinical.vitals','chat','notifications','stats'],
  admin: ['appointments','patients','clinical.notes','clinical.treatment_plans','clinical.prescriptions','clinical.files','clinical.vitals','billing.invoices','billing.receipts','billing.payments','chat','notifications','stats'],
  receptionist: ['appointments','patients','billing.invoices','billing.receipts','billing.payments','chat','notifications'],
};

interface Props {
  clinicId: string;
}

export function MobilePermissionsPanel({ clinicId }: Props) {
  const [activeRole, setActiveRole] = useState<'doctor' | 'admin' | 'receptionist'>('doctor');
  const qc = useQueryClient();

  const { data: rows = [] } = useQuery<MobileModuleRow[]>({
    queryKey: ['mobile-permissions', clinicId, activeRole],
    queryFn: async () => {
      const res = await fetch(`/api/v1/protected/mobile/permissions/clinic/${clinicId}/${activeRole}`, {
        headers: { 'Content-Type': 'application/json' },
      });
      if (!res.ok) throw new Error('Failed to load');
      return res.json();
    },
  });

  const { mutate: toggle } = useMutation({
    mutationFn: async ({ module, enabled }: { module: string; enabled: boolean }) => {
      await fetch(`/api/v1/protected/mobile/permissions/clinic/${clinicId}/${activeRole}/${module}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ enabled }),
      });
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['mobile-permissions', clinicId, activeRole] }),
  });

  const roles = ['doctor', 'admin', 'receptionist'] as const;

  return (
    <div className="space-y-4">
      <div>
        <h3 className="text-sm font-semibold text-foreground">Mobile App Access</h3>
        <p className="text-xs text-muted-foreground mt-1">
          Control which modules each role can access in the OdontoX mobile app.
          Changes take effect within 24 hours (next permission refresh).
        </p>
      </div>

      {/* Role tabs */}
      <div className="flex gap-2">
        {roles.map(role => (
          <button
            key={role}
            onClick={() => setActiveRole(role)}
            className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
              activeRole === role
                ? 'bg-primary text-primary-foreground'
                : 'bg-muted text-muted-foreground hover:bg-muted/80'
            }`}
          >
            {role.charAt(0).toUpperCase() + role.slice(1)}
          </button>
        ))}
      </div>

      {/* Module toggles */}
      <div className="space-y-2">
        {(rows.length > 0 ? rows : ROLE_DEFAULT_MODULES[activeRole]?.map(m => ({ module: m, label: MODULE_LABELS[m] ?? m, enabled: true })) ?? []).map(row => (
          <div key={row.module} className="flex items-center justify-between py-2 border-b border-border/50 last:border-0">
            <div>
              <p className="text-sm font-medium">{row.label ?? MODULE_LABELS[row.module] ?? row.module}</p>
              <p className="text-xs text-muted-foreground font-mono">{row.module}</p>
            </div>
            <Switch
              checked={row.enabled}
              onCheckedChange={(enabled) => toggle({ module: row.module, enabled })}
            />
          </div>
        ))}
      </div>
    </div>
  );
}
  • Step 3: Add GET and PATCH endpoints to server/src/routes/mobile.ts for the web panel
Append to server/src/routes/mobile.ts:
// GET /protected/mobile/permissions/clinic/:clinicId/:role
mobile.get('/permissions/clinic/:clinicId/:role', async (c) => {
  try {
    const user = c.get('user');
    const { clinicId, role } = c.req.param();

    // Only admin and superadmin may read mobile permissions for a clinic
    if (user.role !== 'admin' && user.role !== 'superadmin') {
      return c.json({ error: 'Forbidden' }, 403);
    }
    if (user.role === 'admin' && user.clinicId !== clinicId) {
      return c.json({ error: 'Forbidden' }, 403);
    }

    const db = getReadDb(getDatabaseUrl());
    const rows = await db
      .select({ module: mobileRolePermissions.module, enabled: mobileRolePermissions.enabled })
      .from(mobileRolePermissions)
      .where(and(
        eq(mobileRolePermissions.clinicId, clinicId),
        eq(mobileRolePermissions.role, role),
      ));

    const defaults = MOBILE_PERMISSION_DEFAULTS[role] ?? [];
    if (rows.length === 0) {
      return c.json(defaults.map(m => ({ module: m, label: m, enabled: true })));
    }
    return c.json(rows.map(r => ({ module: r.module, label: r.module, enabled: r.enabled })));
  } catch (error) {
    return handleError(c, error);
  }
});

// PATCH /protected/mobile/permissions/clinic/:clinicId/:role/:module
mobile.patch('/permissions/clinic/:clinicId/:role/:module', async (c) => {
  try {
    const user = c.get('user');
    const { clinicId, role, module } = c.req.param();
    const body = await c.req.json() as { enabled: boolean };

    if (user.role !== 'admin' && user.role !== 'superadmin') {
      return c.json({ error: 'Forbidden' }, 403);
    }
    if (user.role === 'admin' && user.clinicId !== clinicId) {
      return c.json({ error: 'Forbidden' }, 403);
    }

    const db = getTxDb(getDatabaseUrl());
    await db
      .insert(mobileRolePermissions)
      .values({ clinicId, role, module, enabled: body.enabled })
      .onConflictDoUpdate({
        target: [mobileRolePermissions.clinicId, mobileRolePermissions.role, mobileRolePermissions.module],
        set: { enabled: body.enabled, updatedAt: new Date() },
      });

    return c.json({ ok: true });
  } catch (error) {
    return handleError(c, error);
  }
});
You’ll need to import getTxDb at the top of mobile.ts:
import { getReadDb, getTxDb } from '../lib/db';
  • Step 4: Add the panel to the Staff Settings page
In the file found in Step 1 (staff settings), find where the settings sections render and add:
import { MobilePermissionsPanel } from '@/components/settings/MobilePermissionsPanel';
// ... inside the JSX where clinic settings sections appear:
<div className="card p-4">
  <MobilePermissionsPanel clinicId={clinicId} />
</div>
  • Step 5: TypeScript check both packages
cd server && npx tsc --noEmit 2>&1 | grep error | head -5
cd ../ui && npx tsc --noEmit 2>&1 | grep error | head -5
Expected: 0 errors in both
  • Step 6: Commit
git add ui/src/components/settings/MobilePermissionsPanel.tsx server/src/routes/mobile.ts
git commit -m "feat: add MobilePermissionsPanel to web settings + clinic-level permission CRUD"

Task 15: EAS configuration

Files:
  • Create: odontox-app/eas.json
  • Modify: odontox-app/app.json — add extra env block
  • Step 1: Write odontox-app/eas.json
{
  "cli": {
    "version": ">= 12.0.0",
    "requireCommit": true
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": { "simulator": true },
      "env": {
        "EXPO_PUBLIC_API_URL": "https://api.odontox.io/api/v1"
      }
    },
    "preview": {
      "distribution": "internal",
      "env": {
        "EXPO_PUBLIC_API_URL": "https://api.odontox.io/api/v1"
      }
    },
    "production": {
      "autoIncrement": true,
      "env": {
        "EXPO_PUBLIC_API_URL": "https://api.odontox.io/api/v1"
      }
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "[email protected]",
        "ascAppId": "TBD",
        "appleTeamId": "TBD"
      },
      "android": {
        "serviceAccountKeyPath": "./google-service-account.json",
        "track": "production"
      }
    }
  }
}
  • Step 2: Update lib/api.ts to use env variable for BASE_URL
Replace the BASE_URL line in odontox-app/lib/api.ts:
export const BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'https://api.odontox.io/api/v1';
  • Step 3: Run full test suite
cd odontox-app && npx jest --no-coverage 2>&1 | tail -20
Expected: All test suites pass
  • Step 4: Commit
git add odontox-app/eas.json odontox-app/lib/api.ts
git commit -m "feat(mobile): add EAS build config and env-driven API URL"

Self-Review

Spec coverage check:
Spec sectionCovered by task
One binary, role-basedTask 9 (layout groups)
Expo SDK 55 scaffoldTask 1
NativeWind v4Task 1 (tailwind.config, babel, metro)
Expo Router v7Task 1 (app.json, app/_layout.tsx)
JWT in SecureStoreTask 3, 5, 6
mPIN hash + verifyTask 4
Biometric authTask 8 (pin.tsx)
Auth gate (root layout)Task 9
Login screenTask 8
Set PIN screenTask 8
PIN entry screenTask 8
mobile_role_permissions tableTask 11
GET /mobile/permissionsTask 11
POST /auth/mobile-signinTask 10
Permission hook (can())Task 12
Fetch permissions after loginTask 13
Web admin mobile togglesTask 14
EAS configTask 15
Patient tab shellTask 9
Doctor tab shellTask 9
Admin tab shellTask 9
Receptionist tab shellTask 9
Concurrent session detectionHandled by existing server (ConcurrentSession 401 → SESSION_EXPIRED in api.ts Task 5)
Session idle lockNot in Plan 1 — Plan 2
Push registrationNot in Plan 1 — Plan 2
Placeholder scan: No TBD/TODO in implementation steps. eas.json has "ascAppId": "TBD" and "appleTeamId": "TBD" — these require actual App Store Connect registration and cannot be filled in yet. Flagged intentionally. Type consistency:
  • AppUser defined in store/auth.ts, imported in all screens — consistent
  • api.get<T> / api.post<T> generic pattern used in Task 5 and Task 8 — consistent
  • fetchAndCachePermissions returns string[] in Task 12 and consumed in Task 13 — consistent
  • mobileRolePermissions schema exported from schema index and imported in mobile.ts — consistent