# hazo_auth Setup Checklist

This checklist provides step-by-step instructions for setting up the `hazo_auth` package in your Next.js project. AI assistants can follow this guide to ensure complete and correct setup.

## v9.1.1 — Next.js 16 / Turbopack setup (new installs and migrations)

### Add to `serverExternalPackages`

hazo_core and hazo_config use CJS dependencies (`ini`, optional peer probes) that Turbopack cannot bundle cleanly. Add them to the external packages list in `next.config.mjs` / `next.config.js`:

```js
const nextConfig = {
  serverExternalPackages: [
    "hazo_notify", "argon2",
    "hazo_core", "hazo_config",  // required for Next 16 + Turbopack
  ],
};
```

### Avoid dotted section names in INI config files

`ini` v4 parses dots in section headers as nesting separators (`[log.overrides]` becomes `parsed.log.overrides`). hazo_config uses flat sections — use underscores instead:

```ini
# Bad: creates a nested null-prototype object that hazo_config cannot stringify
[log.overrides]

# Good:
[log_overrides]
```

---

## v8.0.0 Migration (from v7.x)

### Breaking changes

Remove the deprecated page-component imports if you used them:

```diff
- import LoginPage from 'hazo_auth/page_components/login';
- import RegisterPage from 'hazo_auth/page_components/register';
+ // Use the Layout components directly in a server component instead
+ import LoginLayout from 'hazo_auth/components/layouts/login';
+ import RegisterLayout from 'hazo_auth/components/layouts/register';
```

### New migrations (run in order)

```bash
npm run migrate migrations/017_legal_acceptance_column.sql
npm run migrate migrations/018_hazo_legal_acceptances.sql
npm run migrate migrations/019_hazo_legal_doc_versions.sql
```

### Legal docs setup (optional)

1. Add `[hazo_auth__legal_docs]` to `config/hazo_auth_config.ini` (see commented sample at the bottom of that file)
2. Create markdown files at the configured paths (e.g. `legal/tos.md`, `legal/privacy.md`)
3. Wrap your app layout with `<LegalAcceptanceGate>` from `hazo_auth/client`
4. Go to User Management → Legal Docs tab, click "Publish current version" per doc to activate gating for existing users

---

## v5.3.0 Migration (from v5.2.x)

If you are already on hazo_auth `5.2.x`, the only mandatory work is wiring up hazo_notify's template manager at boot. Apps that don't use the templated email path (only the plain `send_email`) keep working unchanged after bumping the peer dep.

**1. Bump the peer dep.** Update your app's `package.json`:

```diff
- "hazo_notify": "^1.1.0",
+ "hazo_notify": "^3.0.0",
```

Then `npm install`.

**2. Apply the hazo_notify template-manager migration.** hazo_notify 3.0.0 introduces two new tables (`hazo_notify_template_cat`, `hazo_notify_templates`). If you are using SQLite via the existing schema seed, the tables are created on first connect when `initial_sql` is provided to the adapter (see step 4 below). If you are on PostgREST or you manage migrations explicitly, run `migrations/002_template_manager.sql` from hazo_notify.

**3. Register the `notify_templates_admin` permission (optional).** Users with this permission can edit auth email templates via the hazo_notify admin UI. Grant `notify_templates_super_admin` to also allow deleting system templates. These are hazo_notify permissions, declared in your app's permission set; hazo_auth does not grant them itself.

**4. Wire up `init_template_manager` and `set_hazo_notify_connect` at boot.** In your Next.js `instrumentation.ts` (or equivalent), feed hazo_auth's manifest into hazo_notify and register the connect instance with hazo_auth:

```ts
// instrumentation.ts
import { init_template_manager } from "hazo_notify/template_manager";
import { hazo_auth_template_manifest } from "hazo_auth/server-lib";
import { set_hazo_notify_connect } from "hazo_auth/server-lib"; // re-exported from email_service
import { build_my_hazo_notify_connect } from "./lib/hazo_notify_connect";

export async function register() {
  if (process.env.NEXT_RUNTIME !== "nodejs") return;

  const notify_connect = build_my_hazo_notify_connect();

  await init_template_manager({
    hazo_connect_factory: () => notify_connect,
    manifests: [...hazo_auth_template_manifest],
  });

  set_hazo_notify_connect(notify_connect);
}
```

`build_my_hazo_notify_connect()` is consuming-app code that wraps your `hazo_connect` adapter into the `HazoConnectInstance` shape hazo_notify expects (`list`/`findById`/`findBy`/`insert`/`updateById`/`deleteById`/`query`). hazo_auth's own test-app ships an example wrapper at `instrumentation_hazo_notify_connect.ts` you can copy.

**5. Migrate any custom templates.** The `[hazo_auth__email] email_template_main_directory` config key is deprecated and no longer overrides anything — a one-time `warn` log will fire on first send if it is set. Move any custom HTML/text into the admin UI as scope-specific (or super-admin-edited global) templates before `hazo_auth@6.0.0` removes the key.

**6. (Optional) Mount the template admin UI.** Use `<TemplateManagerAdmin />` from `hazo_notify/template_manager_admin` to give your admins a UI for managing the auth templates. See hazo_notify's docs for routing and permission-gating.

## Quick Start (Recommended)

The fastest way to set up hazo_auth:

```bash
# 1. Install the package and required peer dependencies
npm install hazo_auth hazo_config hazo_connect hazo_logs next react react-dom next-auth

# 2. Initialize project (creates directories, config files, database, images)
npx hazo_auth init

# 3. Configure cookie prefix (REQUIRED)
# Edit config/hazo_auth_config.ini and set a unique prefix:
#   [hazo_auth__cookies]
#   cookie_prefix = myapp_

# 4. Set up environment variables
cp .env.local.example .env.local
# Edit .env.local and set:
#   HAZO_AUTH_COOKIE_PREFIX=myapp_   (MUST match cookie_prefix above)
#   JWT_SECRET=<generate with: openssl rand -base64 32>
#   ZEPTOMAIL_API_KEY=your_key_here

# 5. Initialize default permissions and roles
npx hazo_auth init-users

# 6. Generate API routes and pages
npx hazo_auth generate-routes --pages

# 7. Configure navbar logo and company name (IMPORTANT)
# Edit config/hazo_auth_config.ini and set:
#   [hazo_auth__navbar]
#   logo_path = /logo.png
#   company_name = My Company
# Note: Copy your logo to public/logo.png

# 8. Start dev server and test
npm run dev
# Visit http://localhost:3000/hazo_auth/login
```

If this works, skip to [Phase 6: Verification Tests](#phase-6-verification-tests).

---

## Pre-flight Checks

Before starting, verify your project meets these requirements:

- [ ] Node.js 18+ installed
- [ ] Project uses Next.js 14+ with App Router
- [ ] npm or pnpm package manager
- [ ] Project has `app/` directory (Next.js App Router structure)

**Verify Node.js version:**
```bash
node --version
# Expected: v18.x.x or higher
```

---

## Phase 1: Installation & Config Files

### Step 1.1: Install the package and peer dependencies

```bash
# Required peer dependencies
npm install hazo_auth hazo_config hazo_connect hazo_logs next react react-dom next-auth

# UI peer dependencies (required if using hazo_auth UI components)
npm install hazo_ui lucide-react sonner next-themes @radix-ui/react-accordion @radix-ui/react-alert-dialog @radix-ui/react-avatar @radix-ui/react-checkbox @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-hover-card @radix-ui/react-label @radix-ui/react-select @radix-ui/react-separator @radix-ui/react-slot @radix-ui/react-switch @radix-ui/react-tabs @radix-ui/react-tooltip
```

**Note (v5.2.0+):** `next`, `react`, `react-dom`, `next-auth`, and UI packages are peer dependencies. You must install them in your consuming project. This ensures a single copy of each package is used, preventing version conflicts and bundle duplication.

**Next.js config for server-only packages:**

If using password features (login, register, password reset), add `argon2` to your `next.config.mjs`:

```javascript
const nextConfig = {
  serverExternalPackages: ['argon2'],
};
export default nextConfig;
```

This tells Next.js to load `argon2` at runtime instead of trying to bundle it.

**Verify installation:**
```bash
ls node_modules/hazo_auth/package.json
# Expected: file exists
```

### Step 1.2: Add Toast Notifications

All shadcn/ui components are bundled with hazo_auth — you do NOT need to install them separately.

**Add Toaster to your app layout** (required for toast notifications):

Edit `app/layout.tsx` and add the Toaster component:

```tsx
import { Toaster } from "sonner";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Toaster />
      </body>
    </html>
  );
}
```

### Step 1.4: Configure Navbar (Logo and Company Name)

The navbar appears on all auth pages (login, register, etc.) when using standalone layout mode. **By default, the logo and company name are NOT displayed** - you must configure them.

**Edit `hazo_auth_config.ini`:**
```ini
[hazo_auth__navbar]
# Enable navbar (default: true)
enable_navbar = true

# Logo - place your logo in public folder and set the path
logo_path = /logo.png

# Logo size (default: 28x28 - fits slim 48px navbar)
logo_width = 28
logo_height = 28

# Company name displayed next to logo
company_name = My Company

# Home link (right side of navbar)
show_home_link = true
home_path = /
home_label = Home

# Navbar height (default: 48px for slim appearance)
height = 48
```

**Important:**
- If `logo_path` is empty, no logo will be displayed
- If `company_name` is empty, no company name will be displayed
- The default navbar height is 48px (slim design)

**Checklist:**
- [ ] Logo file exists in `public/` folder (e.g., `public/logo.png`)
- [ ] `logo_path` configured in `hazo_auth_config.ini`
- [ ] `company_name` configured in `hazo_auth_config.ini`

---

### Step 1.5: Enable Dark Mode Support (Optional)

hazo_auth components support dark mode via CSS custom properties. Add the CSS variables to your global styles:

**Copy the CSS variables file:**
```bash
cp node_modules/hazo_auth/src/styles/hazo-auth-variables.css ./app/hazo-auth-theme.css
```

**Import in your global styles (`app/globals.css`):**
```css
@import "./hazo-auth-theme.css";
```

Or add the variables directly to your CSS - see the file for all available variables.

### Step 1.5: Initialize project (Recommended)

Use the CLI to automatically set up directories and copy config files:

```bash
npx hazo_auth init
```

This command:
- Creates `public/profile_pictures/library/` directory
- Creates `public/profile_pictures/uploads/` directory
- Creates `data/` directory (for SQLite database)
- Copies `hazo_auth_config.ini` and `hazo_notify_config.ini`
- Copies profile picture library images
- Copies default auth page images (login, register, forgot password, etc.)
- Creates `.env.local.example` template
- Creates SQLite database with all required tables (if `better-sqlite3` is installed)

**Additional CLI commands:**
```bash
# Create/recreate SQLite database with schema (standalone)
npx hazo_auth init-db

# Print the canonical SQLite schema SQL
npx hazo_auth schema
```

### Step 1.2b: Manual config setup (Alternative)

If you prefer manual setup:

```bash
cp node_modules/hazo_auth/hazo_auth_config.example.ini ./hazo_auth_config.ini
cp node_modules/hazo_auth/hazo_notify_config.example.ini ./hazo_notify_config.ini
```

**Verify config files exist:**
```bash
ls -la hazo_auth_config.ini hazo_notify_config.ini
# Expected: both files exist in project root
```

### Step 1.3: Configure database connection

Edit `hazo_auth_config.ini`:

**For SQLite (development):**
```ini
[hazo_connect]
type = sqlite
sqlite_path = ./data/hazo_auth.sqlite
```

**For PostgreSQL (production):**
```ini
[hazo_connect]
type = postgrest
postgrest_url = https://your-postgrest-url.com
```

### Step 1.4: Configure UI shell mode

Edit `hazo_auth_config.ini`:
```ini
[hazo_auth__ui_shell]
# Use 'standalone' for consuming projects (inherits your app's layout)
# Use 'test_sidebar' only for hazo_auth package development
layout_mode = standalone
```

**Checklist:**
- [ ] `hazo_auth_config.ini` exists in project root
- [ ] `hazo_notify_config.ini` exists in project root
- [ ] Database type configured (sqlite or postgrest)
- [ ] UI shell mode set to `standalone`

---

## Phase 2: Environment Variables

### Step 2.1: Create .env.local file

Create `.env.local` in your project root:

```env
# REQUIRED: Cookie prefix (MUST match cookie_prefix in hazo_auth_config.ini)
# Each app using hazo_auth needs a unique prefix to prevent cookie conflicts
HAZO_AUTH_COOKIE_PREFIX=myapp_

# Required for JWT authentication
JWT_SECRET=your_secure_random_string_at_least_32_characters

# Required for email functionality (Zeptomail)
ZEPTOMAIL_API_KEY=your_zeptomail_api_key_here

# Required for PostgreSQL/PostgREST (if using)
HAZO_CONNECT_POSTGREST_API_KEY=your_postgrest_api_key_here

# Optional: Cookie domain (for cross-subdomain sharing)
HAZO_AUTH_COOKIE_DOMAIN=
```

**Generate a secure JWT secret:**
```bash
openssl rand -base64 32
```

**Cookie Prefix (REQUIRED):**
Every app using hazo_auth MUST set a unique cookie prefix. This prevents cookie conflicts between apps. The value must be set in TWO places:

1. **Config file** (`config/hazo_auth_config.ini`):
   ```ini
   [hazo_auth__cookies]
   cookie_prefix = myapp_
   ```

2. **Environment variable** (`.env.local`):
   ```env
   HAZO_AUTH_COOKIE_PREFIX=myapp_
   ```

Both values MUST match. The env var is needed because Edge runtime (middleware/proxy) cannot read config files.

**Examples for multiple apps:**
- App 1 (port 3000): `cookie_prefix = app1_` / `HAZO_AUTH_COOKIE_PREFIX=app1_`
- App 2 (port 3001): `cookie_prefix = app2_` / `HAZO_AUTH_COOKIE_PREFIX=app2_`

### Step 2.2: Configure email settings

Edit `hazo_notify_config.ini`:
```ini
[emailer]
emailer_module = zeptoemail_api
from_email = noreply@yourdomain.com
from_name = Your App Name
```

**Checklist:**
- [ ] `.env.local` file created
- [ ] `HAZO_AUTH_COOKIE_PREFIX` set (REQUIRED - must match config file)
- [ ] `JWT_SECRET` set (required for JWT session tokens)
- [ ] `ZEPTOMAIL_API_KEY` set (or email will not work)
- [ ] `from_email` configured in `hazo_notify_config.ini`

---

## Phase 3: Database Setup

### Option A: SQLite (Development)

Create the data directory:
```bash
mkdir -p data
```

The SQLite database will be created automatically on first use if using hazo_connect's SQLite adapter.

**Recommended:** Run the canonical SQLite schema via the CLI. This is the single source of truth and creates ALL required tables (mirrors `src/lib/schema/sqlite_schema.ts`):

```bash
npx hazo_auth init-db          # Create/recreate the SQLite database with full schema
npx hazo_auth schema           # Print the canonical schema SQL (does not modify DB)
```

**Manual creation (if needed):**
```bash
# Create database with the full v5.x schema (all tables, including scopes + relationships)
cat << 'EOF' | sqlite3 data/hazo_auth.sqlite
-- ============================================================
-- hazo_auth canonical SQLite schema (v5.x)
-- Mirrors src/lib/schema/sqlite_schema.ts
-- ============================================================

-- Users table (status enum + managed sub-profile support)
CREATE TABLE IF NOT EXISTS hazo_users (
  id TEXT PRIMARY KEY,
  email_address TEXT NOT NULL UNIQUE,
  password_hash TEXT,
  name TEXT,
  email_verified INTEGER DEFAULT 0,
  login_attempts INTEGER DEFAULT 0,
  last_logon TEXT,
  profile_picture_url TEXT,
  profile_source TEXT CHECK(profile_source IN ('gravatar', 'custom', 'predefined')),
  mfa_secret TEXT,
  url_on_logon TEXT,
  google_id TEXT UNIQUE,
  auth_providers TEXT DEFAULT 'email',
  user_type TEXT,
  app_user_data TEXT,
  status TEXT DEFAULT 'ACTIVE' CHECK(status IN ('PENDING', 'ACTIVE', 'BLOCKED')),
  managed_by_user_id TEXT REFERENCES hazo_users(id) ON DELETE SET NULL,
  pin_hash TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  changed_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_hazo_users_email ON hazo_users(email_address);
CREATE INDEX IF NOT EXISTS idx_hazo_users_google_id ON hazo_users(google_id);
CREATE INDEX IF NOT EXISTS idx_hazo_users_status ON hazo_users(status);
CREATE INDEX IF NOT EXISTS idx_hazo_users_managed_by ON hazo_users(managed_by_user_id);

-- Refresh tokens (also used for password reset / email verification tokens)
CREATE TABLE IF NOT EXISTS hazo_refresh_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
  token TEXT NOT NULL UNIQUE,
  token_type TEXT DEFAULT 'refresh',
  expires_at TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_hazo_refresh_tokens_user ON hazo_refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_hazo_refresh_tokens_token ON hazo_refresh_tokens(token);

-- Roles
CREATE TABLE IF NOT EXISTS hazo_roles (
  id TEXT PRIMARY KEY,
  role_name TEXT NOT NULL UNIQUE,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  changed_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Permissions
CREATE TABLE IF NOT EXISTS hazo_permissions (
  id TEXT PRIMARY KEY,
  permission_name TEXT NOT NULL UNIQUE,
  description TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  changed_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Role-permission assignments (composite PK, no id column)
CREATE TABLE IF NOT EXISTS hazo_role_permissions (
  role_id TEXT NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,
  permission_id TEXT NOT NULL REFERENCES hazo_permissions(id) ON DELETE CASCADE,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  PRIMARY KEY (role_id, permission_id)
);

-- Unified scopes table (hierarchical multi-tenancy with firm branding)
-- v5.0+ replaces hazo_org and hazo_scopes_l1..l7 with a single self-referencing table
CREATE TABLE IF NOT EXISTS hazo_scopes (
  id TEXT PRIMARY KEY,
  parent_id TEXT REFERENCES hazo_scopes(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  level TEXT NOT NULL,
  logo_url TEXT,
  primary_color TEXT,
  secondary_color TEXT,
  tagline TEXT,
  slug TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  changed_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_hazo_scopes_parent ON hazo_scopes(parent_id);
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_level ON hazo_scopes(level);
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);

-- Reserved system scopes
-- Note: Super Admin scope retired in v10; use hazo_org_global_admin permission instead (see migration 020)
INSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
VALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', datetime('now'), datetime('now'));

-- User-scope assignments (membership model with scope-specific roles)
-- NOTE: replaces the old hazo_user_roles table from v4.x
CREATE TABLE IF NOT EXISTS hazo_user_scopes (
  user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
  scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
  root_scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
  role_id TEXT NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,
  status TEXT DEFAULT 'ACTIVE' CHECK (status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'DEPARTED')),
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  changed_at TEXT NOT NULL DEFAULT (datetime('now')),
  PRIMARY KEY (user_id, scope_id)
);

CREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_scope ON hazo_user_scopes(scope_id);
CREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_root ON hazo_user_scopes(root_scope_id);
CREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_role ON hazo_user_scopes(role_id);

-- Invitations (onboard new users into existing scopes)
CREATE TABLE IF NOT EXISTS hazo_invitations (
  id TEXT PRIMARY KEY,
  email_address TEXT NOT NULL,
  token TEXT NOT NULL UNIQUE,
  scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
  root_scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
  role_id TEXT NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,
  invited_by TEXT REFERENCES hazo_users(id) ON DELETE SET NULL,
  status TEXT NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED')),
  expires_at TEXT NOT NULL,
  accepted_at TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  changed_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_hazo_invitations_email ON hazo_invitations(email_address);
CREATE INDEX IF NOT EXISTS idx_hazo_invitations_token ON hazo_invitations(token);
CREATE INDEX IF NOT EXISTS idx_hazo_invitations_scope ON hazo_invitations(scope_id);
CREATE INDEX IF NOT EXISTS idx_hazo_invitations_status ON hazo_invitations(status);
CREATE INDEX IF NOT EXISTS idx_hazo_invitations_expires ON hazo_invitations(expires_at);

-- Managed sub-profile relationships (parent/child accounts on shared devices)
CREATE TABLE IF NOT EXISTS hazo_user_relationships (
  id TEXT PRIMARY KEY,
  parent_user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
  child_user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
  relationship_type TEXT NOT NULL DEFAULT 'parent',
  can_view_progress INTEGER DEFAULT 1,
  can_edit_profile INTEGER DEFAULT 1,
  can_delete INTEGER DEFAULT 0,
  is_self INTEGER DEFAULT 0,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  UNIQUE(parent_user_id, child_user_id)
);

CREATE INDEX IF NOT EXISTS idx_hazo_user_relationships_parent ON hazo_user_relationships(parent_user_id);
CREATE INDEX IF NOT EXISTS idx_hazo_user_relationships_child ON hazo_user_relationships(child_user_id);

-- Built-in firm_admin role (used when a user creates their first firm)
INSERT OR IGNORE INTO hazo_roles (id, role_name, created_at, changed_at)
VALUES (
  lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6))),
  'firm_admin',
  datetime('now'),
  datetime('now')
);
EOF
```

**Verify SQLite database:**
```bash
sqlite3 data/hazo_auth.sqlite ".tables"
# Expected (9 tables):
#   hazo_users hazo_refresh_tokens hazo_roles hazo_permissions hazo_role_permissions
#   hazo_scopes hazo_user_scopes hazo_invitations hazo_user_relationships
```

> **v4.x → v5.x:** the legacy `hazo_org`, `hazo_scopes_l1..l7`, and `hazo_user_roles` tables have been removed. Roles are now assigned per-scope via `hazo_user_scopes.role_id`. If upgrading, run `migrations/009_scope_consolidation.sql` first.

### Option B: PostgreSQL (Production)

Run this SQL script in your PostgreSQL database. It creates the full v5.x schema, including the unified `hazo_scopes` model, `hazo_user_scopes`, `hazo_invitations`, and `hazo_user_relationships`.

**Important:** Run the entire script in order — enum types and parent tables must be created before tables that depend on them.

```sql
-- ============================================================
-- hazo_auth canonical PostgreSQL schema (v5.x)
-- Single source of truth for production deployments
-- ============================================================

SET search_path TO public;

-- 1. Enum types
CREATE TYPE hazo_enum_profile_source_enum AS ENUM ('gravatar', 'custom', 'predefined');
CREATE TYPE hazo_enum_user_status AS ENUM ('PENDING', 'ACTIVE', 'BLOCKED');
CREATE TYPE hazo_enum_user_scope_status_type AS ENUM ('INVITED', 'ACTIVE', 'SUSPENDED', 'DEPARTED');
CREATE TYPE hazo_enum_invitation_status AS ENUM ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED');

-- 2. Users (status enum + managed sub-profile support)
CREATE TABLE hazo_users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email_address TEXT NOT NULL UNIQUE,
    password_hash TEXT,                                 -- NULL for OAuth-only users
    name TEXT,
    email_verified BOOLEAN NOT NULL DEFAULT FALSE,
    login_attempts INTEGER NOT NULL DEFAULT 0,
    last_logon TIMESTAMP WITH TIME ZONE,
    profile_picture_url TEXT,
    profile_source hazo_enum_profile_source_enum,
    mfa_secret TEXT,
    url_on_logon TEXT,
    google_id TEXT UNIQUE,                              -- Google OAuth ID
    auth_providers TEXT DEFAULT 'email',                -- 'email', 'google', or 'email,google'
    user_type TEXT,                                     -- Optional user categorization
    app_user_data JSONB,                                -- Custom JSON data for consuming apps
    status hazo_enum_user_status NOT NULL DEFAULT 'ACTIVE',
    managed_by_user_id UUID REFERENCES hazo_users(id) ON DELETE SET NULL,  -- managed sub-profiles
    pin_hash TEXT,                                      -- simple PIN auth on shared devices
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_hazo_users_email ON hazo_users(email_address);
CREATE INDEX idx_hazo_users_status ON hazo_users(status);
CREATE INDEX idx_hazo_users_user_type ON hazo_users(user_type);
CREATE UNIQUE INDEX idx_hazo_users_google_id ON hazo_users(google_id);
CREATE INDEX idx_hazo_users_managed_by ON hazo_users(managed_by_user_id);

-- 3. Refresh tokens (also used for password reset / email verification)
CREATE TABLE hazo_refresh_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
    token_hash TEXT NOT NULL,
    token_type TEXT NOT NULL DEFAULT 'refresh',         -- 'refresh' | 'password_reset' | 'email_verification'
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_hazo_refresh_tokens_user_id ON hazo_refresh_tokens(user_id);
CREATE INDEX idx_hazo_refresh_tokens_token_type ON hazo_refresh_tokens(token_type);

-- 4. Roles
CREATE TABLE hazo_roles (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    role_name TEXT NOT NULL UNIQUE,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- 5. Permissions
CREATE TABLE hazo_permissions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    permission_name TEXT NOT NULL UNIQUE,
    description TEXT,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- 6. Role-permission assignments (composite PK, NO id column)
CREATE TABLE hazo_role_permissions (
    role_id UUID NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,
    permission_id UUID NOT NULL REFERENCES hazo_permissions(id) ON DELETE CASCADE,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    PRIMARY KEY (role_id, permission_id)
);

-- 7. Unified scopes (hierarchical multi-tenancy with firm branding)
--    v5.0+ replaces the old hazo_org and hazo_scopes_l1..l7 tables.
CREATE TABLE hazo_scopes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    parent_id UUID REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    level TEXT NOT NULL,                                -- descriptive label e.g. 'HQ', 'Division', 'Team'
    logo_url TEXT,
    primary_color TEXT,
    secondary_color TEXT,
    tagline TEXT,
    slug TEXT,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_hazo_scopes_parent ON hazo_scopes(parent_id);
CREATE INDEX idx_hazo_scopes_level ON hazo_scopes(level);
CREATE INDEX idx_hazo_scopes_slug ON hazo_scopes(slug);

-- 7a. Reserved system scopes
-- Note: Super Admin scope retired in v10; use hazo_org_global_admin permission instead (see migration 020)
INSERT INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
VALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', NOW(), NOW())
ON CONFLICT (id) DO NOTHING;

-- 8. User-scope assignments (membership model with scope-specific roles)
--    Replaces the v4.x hazo_user_roles table.
CREATE TABLE hazo_user_scopes (
    user_id UUID NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
    scope_id UUID NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    root_scope_id UUID NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    role_id UUID NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,
    status hazo_enum_user_scope_status_type NOT NULL DEFAULT 'ACTIVE',
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    PRIMARY KEY (user_id, scope_id)
);
CREATE INDEX idx_hazo_user_scopes_scope ON hazo_user_scopes(scope_id);
CREATE INDEX idx_hazo_user_scopes_root ON hazo_user_scopes(root_scope_id);
CREATE INDEX idx_hazo_user_scopes_role ON hazo_user_scopes(role_id);

-- 9. Invitations (onboard new users into existing scopes)
CREATE TABLE hazo_invitations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email_address TEXT NOT NULL,
    token TEXT NOT NULL UNIQUE,
    scope_id UUID NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    root_scope_id UUID NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    role_id UUID NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,
    invited_by UUID REFERENCES hazo_users(id) ON DELETE SET NULL,
    status hazo_enum_invitation_status NOT NULL DEFAULT 'PENDING',
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
    accepted_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_hazo_invitations_email ON hazo_invitations(email_address);
CREATE INDEX idx_hazo_invitations_token ON hazo_invitations(token);
CREATE INDEX idx_hazo_invitations_scope ON hazo_invitations(scope_id);
CREATE INDEX idx_hazo_invitations_status ON hazo_invitations(status);
CREATE INDEX idx_hazo_invitations_expires ON hazo_invitations(expires_at);

-- 10. Managed sub-profile relationships (parent/child accounts on shared devices)
CREATE TABLE hazo_user_relationships (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    parent_user_id UUID NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
    child_user_id UUID NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
    relationship_type TEXT NOT NULL DEFAULT 'parent',
    can_view_progress BOOLEAN NOT NULL DEFAULT TRUE,
    can_edit_profile BOOLEAN NOT NULL DEFAULT TRUE,
    can_delete BOOLEAN NOT NULL DEFAULT FALSE,
    is_self BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    UNIQUE (parent_user_id, child_user_id)
);
CREATE INDEX idx_hazo_user_relationships_parent ON hazo_user_relationships(parent_user_id);
CREATE INDEX idx_hazo_user_relationships_child ON hazo_user_relationships(child_user_id);

-- 11. Built-in firm_admin role (used when a user creates their first firm)
INSERT INTO hazo_roles (id, role_name, created_at, changed_at)
VALUES (gen_random_uuid(), 'firm_admin', NOW(), NOW())
ON CONFLICT (role_name) DO NOTHING;
```

> **Migrating from v4.x?** The legacy `hazo_org`, `hazo_scopes_l1..l7`, and `hazo_user_roles` tables have been removed. Run `migrations/009_scope_consolidation.sql` against your existing database — it drops the old tables and creates the new schema in place.

**Verify PostgreSQL tables:**
```sql
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_name LIKE 'hazo_%'
ORDER BY table_name;
-- Expected (9 tables):
--   hazo_invitations, hazo_permissions, hazo_refresh_tokens, hazo_role_permissions,
--   hazo_roles, hazo_scopes, hazo_users, hazo_user_relationships, hazo_user_scopes
```

**Grant access to admin user:**

After creating the tables, grant appropriate permissions to your admin database user. Replace `your_admin_user` with your actual PostgreSQL username:

```sql
-- Grant usage on schema (usually 'public')
GRANT USAGE ON SCHEMA public TO your_admin_user;

-- Grant all privileges on all hazo_* tables
GRANT ALL PRIVILEGES ON TABLE hazo_users TO your_admin_user;
GRANT ALL PRIVILEGES ON TABLE hazo_refresh_tokens TO your_admin_user;
GRANT ALL PRIVILEGES ON TABLE hazo_roles TO your_admin_user;
GRANT ALL PRIVILEGES ON TABLE hazo_permissions TO your_admin_user;
GRANT ALL PRIVILEGES ON TABLE hazo_role_permissions TO your_admin_user;
GRANT ALL PRIVILEGES ON TABLE hazo_scopes TO your_admin_user;
GRANT ALL PRIVILEGES ON TABLE hazo_user_scopes TO your_admin_user;
GRANT ALL PRIVILEGES ON TABLE hazo_invitations TO your_admin_user;
GRANT ALL PRIVILEGES ON TABLE hazo_user_relationships TO your_admin_user;

-- Grant usage on enum types
GRANT USAGE ON TYPE hazo_enum_profile_source_enum TO your_admin_user;
GRANT USAGE ON TYPE hazo_enum_user_status TO your_admin_user;
GRANT USAGE ON TYPE hazo_enum_user_scope_status_type TO your_admin_user;
GRANT USAGE ON TYPE hazo_enum_invitation_status TO your_admin_user;

-- Optional: Grant privileges on future tables (if you plan to add more hazo_* tables)
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO your_admin_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO your_admin_user;
```

**For PostgREST/API access (if using PostgREST):**

If you're using PostgREST, you'll typically use an `anon` role for unauthenticated access and an `authenticated` role for authenticated users. Grant appropriate permissions:

```sql
-- Create roles if they don't exist
-- CREATE ROLE anon;
-- CREATE ROLE authenticated;

-- Grant usage on schema
GRANT USAGE ON SCHEMA public TO anon, authenticated;

-- Grant select on tables for anon (public read access)
GRANT SELECT ON TABLE hazo_users TO anon;
GRANT SELECT ON TABLE hazo_permissions TO anon;
GRANT SELECT ON TABLE hazo_roles TO anon;
GRANT SELECT ON TABLE hazo_role_permissions TO anon;
GRANT SELECT ON TABLE hazo_scopes TO anon;

-- Grant full access to authenticated users (adjust based on your RLS policies)
GRANT ALL PRIVILEGES ON TABLE hazo_users TO authenticated;
GRANT ALL PRIVILEGES ON TABLE hazo_refresh_tokens TO authenticated;
GRANT ALL PRIVILEGES ON TABLE hazo_permissions TO authenticated;
GRANT ALL PRIVILEGES ON TABLE hazo_roles TO authenticated;
GRANT ALL PRIVILEGES ON TABLE hazo_role_permissions TO authenticated;
GRANT ALL PRIVILEGES ON TABLE hazo_scopes TO authenticated;
GRANT ALL PRIVILEGES ON TABLE hazo_user_scopes TO authenticated;
GRANT ALL PRIVILEGES ON TABLE hazo_invitations TO authenticated;
GRANT ALL PRIVILEGES ON TABLE hazo_user_relationships TO authenticated;

-- Grant usage on enum types
GRANT USAGE ON TYPE hazo_enum_profile_source_enum TO anon, authenticated;
GRANT USAGE ON TYPE hazo_enum_user_status TO anon, authenticated;
GRANT USAGE ON TYPE hazo_enum_user_scope_status_type TO anon, authenticated;
GRANT USAGE ON TYPE hazo_enum_invitation_status TO anon, authenticated;
```

**Checklist:**
- [ ] Database created (SQLite file or PostgreSQL)
- [ ] All enum types created (PostgreSQL only):
  - [ ] `hazo_enum_profile_source_enum`
  - [ ] `hazo_enum_user_status`
  - [ ] `hazo_enum_user_scope_status_type`
  - [ ] `hazo_enum_invitation_status`
- [ ] All core tables exist (9 total):
  - [ ] `hazo_users` (with `status`, `google_id`, `auth_providers`, `app_user_data`, `user_type`, `managed_by_user_id`, `pin_hash` fields)
  - [ ] `hazo_refresh_tokens`
  - [ ] `hazo_roles`
  - [ ] `hazo_permissions`
  - [ ] `hazo_role_permissions` (composite PK, no `id` column)
  - [ ] `hazo_scopes` (unified hierarchy with branding + `slug`)
  - [ ] `hazo_user_scopes` (composite PK on `user_id`, `scope_id`)
  - [ ] `hazo_invitations`
  - [ ] `hazo_user_relationships` (managed sub-profile parent/child links)
- [ ] Reserved system scopes inserted:
  - [ ] `00000000-0000-0000-0000-000000000001` (System / non-multi-tenancy default)
- [ ] Migration 020 run: Super Admin scope retired, `hazo_org_global_admin` permission granted to affected roles
- [ ] `firm_admin` role inserted into `hazo_roles`

---

## Phase 3.1: Configure Default Permissions (Optional)

The `hazo_auth_config.ini` file includes default permissions that will be available in the Permissions tab. These defaults are already configured when you run `npx hazo_auth init`.

**Default permissions included:**
- `admin_user_management`
- `admin_role_management`
- `admin_permission_management`

**To customize permissions:**

Edit `hazo_auth_config.ini`:
```ini
[hazo_auth__user_management]
application_permission_list_defaults = admin_user_management,admin_role_management,admin_permission_management
```

**To initialize permissions and create a super user:**

After setting up your database and configuring permissions, you can run:
```bash
npm run init-users
```

This script will:
1. Create all permissions from `application_permission_list_defaults`
2. Create a `default_super_user_role` role with all permissions
3. Assign the role to the user specified in `default_super_user_email` (configure in `[hazo_auth__initial_setup]` section)

**Checklist:**
- [ ] Default permissions configured in `hazo_auth_config.ini` (already set by default)
- [ ] `default_super_user_email` configured if you want to use `init-users` script

---

## Phase 3.2: Google OAuth Setup (Optional)

Google OAuth Sign-In allows users to authenticate with their Google accounts. This section is optional - skip if you don't need OAuth.

### Step 3.2.1: Get Google OAuth Credentials

1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a project or select an existing project
3. Enable **Google+ API** (or Google Identity Services)
4. Navigate to **Credentials** → **Create Credentials** → **OAuth 2.0 Client ID**
5. Configure OAuth consent screen if prompted
6. Set **Application type** to "Web application"
7. Add **Authorized JavaScript origins**:
   - Development: `http://localhost:3000`
   - Production: `https://yourdomain.com`
8. Add **Authorized redirect URIs**:
   - Development: `http://localhost:3000/api/auth/callback/google`
   - Production: `https://yourdomain.com/api/auth/callback/google`
9. Copy the **Client ID** and **Client Secret**

### Step 3.2.2: Add OAuth Environment Variables

Add to your `.env.local`:

```env
# NextAuth.js configuration (REQUIRED for OAuth)
NEXTAUTH_SECRET=your_secure_random_string_at_least_32_characters
NEXTAUTH_URL=http://localhost:3000  # Change to production URL in production

# Google OAuth credentials (from Google Cloud Console)
HAZO_AUTH_GOOGLE_CLIENT_ID=your_google_client_id_from_step_1
HAZO_AUTH_GOOGLE_CLIENT_SECRET=your_google_client_secret_from_step_1
```

**Generate NEXTAUTH_SECRET:**
```bash
openssl rand -base64 32
```

### Step 3.2.3: Run OAuth Database Migration

Add OAuth fields to the `hazo_users` table:

```bash
npm run migrate migrations/005_add_oauth_fields_to_hazo_users.sql
```

**Verify migration applied:**

**PostgreSQL:**
```sql
SELECT column_name FROM information_schema.columns
WHERE table_name = 'hazo_users'
  AND column_name IN ('google_id', 'auth_providers');
-- Expected: 2 rows returned
```

**SQLite:**
```bash
sqlite3 data/hazo_auth.sqlite ".schema hazo_users" | grep -E "google_id|auth_providers"
# Expected: google_id TEXT UNIQUE, auth_providers TEXT DEFAULT 'email'
```

**Manual migration (if needed):**

**PostgreSQL:**
```sql
ALTER TABLE hazo_users ADD COLUMN google_id TEXT UNIQUE;
ALTER TABLE hazo_users ADD COLUMN auth_providers TEXT DEFAULT 'email';
CREATE INDEX IF NOT EXISTS idx_hazo_users_google_id ON hazo_users(google_id);
```

**SQLite:**
```sql
ALTER TABLE hazo_users ADD COLUMN google_id TEXT;
ALTER TABLE hazo_users ADD COLUMN auth_providers TEXT DEFAULT 'email';
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_users_google_id_unique ON hazo_users(google_id);
CREATE INDEX IF NOT EXISTS idx_hazo_users_google_id ON hazo_users(google_id);
```

### Step 3.2.4 (Optional): Run Email OTP Migration

Required only if you plan to enable `enable_email_otp = true` in `[hazo_auth__otp]`.

```bash
npx hazo_auth migrate migrations/015_email_otp.sql
```

Creates `hazo_email_otps` table for OTP codes and rate limiting.

### Step 3.2.4b (Optional): Run Facebook OAuth Migration

Required only if `enable_facebook_oauth = true` in `[hazo_auth__oauth]`.

```bash
npx hazo_auth migrate migrations/016_add_facebook_id_to_hazo_users.sql
```

Adds `facebook_id` column to `hazo_users`.

### Step 3.2.5: Configure OAuth in hazo_auth_config.ini

Add (or modify) the OAuth section:

```ini
[hazo_auth__oauth]
# Enable Google OAuth login (default: true)
enable_google = true

# Enable traditional email/password login (default: true)
enable_email_password = true

# Auto-link Google login to existing unverified email/password accounts (default: true)
auto_link_unverified_accounts = true

# Customize button text (optional)
google_button_text = Continue with Google
oauth_divider_text = or

# NextAuth page paths (v5.1.32+)
# Consumers should set these to their actual login page routes
# sign_in_page = /login
# error_page = /login

# Post-Login Redirect Configuration (v5.1.16+)
# URL for users who need to create a firm (default: /hazo_auth/create_firm)
# create_firm_url = /hazo_auth/create_firm

# Default redirect after OAuth login for users with scopes (default: /)
# default_redirect = /

# Skip invitation table check (set true if not using invitations)
# skip_invitation_check = false

# Redirect when skip_invitation_check=true and user has no scope (default: /)
# no_scope_redirect = /
```

**Configuration Options:**

- **Google-only authentication** (no email/password):
  ```ini
  enable_google = true
  enable_email_password = false
  ```

- **Email/password only** (no OAuth):
  ```ini
  enable_google = false
  enable_email_password = true
  ```

- **Both methods** (recommended):
  ```ini
  enable_google = true
  enable_email_password = true
  ```

### Step 3.2.5: Create OAuth API Routes

**Option A: Use CLI generator (Recommended):**
```bash
npx hazo_auth generate-routes --oauth
```

**Option B: Create manually:**

Create `app/api/auth/[...nextauth]/route.ts`:
```typescript
export { GET, POST } from "hazo_auth/server/routes/nextauth";
```

Create `app/api/hazo_auth/oauth/google/callback/route.ts`:
```typescript
export { GET } from "hazo_auth/server/routes/oauth_google_callback";
```

Create `app/api/hazo_auth/set_password/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/set_password";
```

### Step 3.2.6: Verify OAuth API Routes

```bash
ls app/api/auth/\[...nextauth\]/route.ts
ls app/api/hazo_auth/oauth/google/callback/route.ts
ls app/api/hazo_auth/set_password/route.ts
# All files should exist
```

**OAuth Setup Checklist:**
- [ ] Google OAuth credentials obtained
- [ ] `NEXTAUTH_SECRET` and `NEXTAUTH_URL` set in `.env.local`
- [ ] `HAZO_AUTH_GOOGLE_CLIENT_ID` and `HAZO_AUTH_GOOGLE_CLIENT_SECRET` set
- [ ] OAuth migration applied (google_id and auth_providers columns added)
- [ ] `[hazo_auth__oauth]` section configured in `hazo_auth_config.ini`
- [ ] OAuth API routes created (`[...nextauth]`, `oauth/google/callback`, `set_password`)
- [ ] Post-login redirect configured (if not using invitations, set `skip_invitation_check = true`)

---

## Phase 4: API Routes

Create API route files in your project. Each file re-exports handlers from hazo_auth.

### Step 4.1: Generate routes automatically (Recommended)

```bash
npx hazo_auth generate-routes
```

This creates all required API routes automatically.

### Step 4.2: Manual route creation (Alternative)

If automatic generation doesn't work, create these files manually:

**Required routes (create all of these):**

```
app/api/hazo_auth/
├── login/route.ts
├── register/route.ts
├── logout/route.ts
├── me/route.ts
├── forgot_password/route.ts
├── reset_password/route.ts
├── verify_email/route.ts
├── resend_verification/route.ts
├── update_user/route.ts
├── change_password/route.ts
├── upload_profile_picture/route.ts
├── remove_profile_picture/route.ts
├── library_photos/route.ts
├── get_auth/route.ts
├── validate_reset_token/route.ts
├── profile_picture/
│   └── [filename]/route.ts
└── user_management/
    ├── users/route.ts
    ├── permissions/route.ts
    ├── roles/route.ts
    └── users/
        └── roles/route.ts
```

**Example route file content:**

`app/api/hazo_auth/login/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/login";
```

`app/api/hazo_auth/register/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/register";
```

`app/api/hazo_auth/logout/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/logout";
```

`app/api/hazo_auth/me/route.ts`:
```typescript
export { GET } from "hazo_auth/server/routes/me";
```

`app/api/hazo_auth/forgot_password/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/forgot_password";
```

`app/api/hazo_auth/reset_password/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/reset_password";
```

`app/api/hazo_auth/verify_email/route.ts`:
```typescript
export { GET } from "hazo_auth/server/routes/verify_email";
```

`app/api/hazo_auth/resend_verification/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/resend_verification";
```

`app/api/hazo_auth/update_user/route.ts`:
```typescript
export { PATCH } from "hazo_auth/server/routes/update_user";
```

`app/api/hazo_auth/change_password/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/change_password";
```

`app/api/hazo_auth/upload_profile_picture/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/upload_profile_picture";
```

`app/api/hazo_auth/remove_profile_picture/route.ts`:
```typescript
export { DELETE } from "hazo_auth/server/routes/remove_profile_picture";
```

`app/api/hazo_auth/library_photos/route.ts`:
```typescript
export { GET } from "hazo_auth/server/routes/library_photos";
```

`app/api/hazo_auth/get_auth/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/get_auth";
```

`app/api/hazo_auth/validate_reset_token/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/validate_reset_token";
```

`app/api/hazo_auth/profile_picture/[filename]/route.ts`:
```typescript
export { GET } from "hazo_auth/server/routes/profile_picture_filename";
```

**User Management routes (optional - required if using UserManagementLayout):**

`app/api/hazo_auth/user_management/users/route.ts`:
```typescript
export { GET, PATCH, POST } from "hazo_auth/server/routes";
```

`app/api/hazo_auth/user_management/permissions/route.ts`:
```typescript
export { GET, POST, PUT, DELETE } from "hazo_auth/server/routes";
```

`app/api/hazo_auth/user_management/roles/route.ts`:
```typescript
export { GET, POST, PUT } from "hazo_auth/server/routes";
```

`app/api/hazo_auth/user_management/users/roles/route.ts`:
```typescript
export { GET, POST, PUT } from "hazo_auth/server/routes";
```

**Note:** The `generate-routes` command automatically creates all user_management routes. These routes are required if you plan to use the `UserManagementLayout` component for managing users, roles, and permissions.

**Checklist:**
- [ ] All 16 core API route files created
- [ ] User management routes created (if using UserManagementLayout)
- [ ] Each file exports the correct HTTP method (POST, GET, PATCH, DELETE, PUT)

---

## Phase 5: Page Routes

Create page files for each auth flow.

### Step 5.1: Generate pages automatically (Recommended)

```bash
npx hazo_auth generate-routes --pages
```

This generates both API routes and page routes. The generated pages use zero-config components that work out of the box.

**Generated pages:**
```
app/hazo_auth/
├── login/page.tsx
├── register/page.tsx
├── forgot_password/page.tsx
├── reset_password/page.tsx
├── verify_email/page.tsx
└── my_settings/page.tsx
```

### Step 5.2: Manual page creation (Alternative)

If you prefer manual setup or need custom paths, create these files:

**Login page** - `app/hazo_auth/login/page.tsx`:
```typescript
import { LoginPage } from "hazo_auth/pages/login";
export default LoginPage;
```

**Register page** - `app/hazo_auth/register/page.tsx`:
```typescript
import { RegisterPage } from "hazo_auth/pages/register";
export default RegisterPage;
```

**Forgot password page** - `app/hazo_auth/forgot_password/page.tsx`:
```typescript
import { ForgotPasswordPage } from "hazo_auth/pages/forgot_password";
export default ForgotPasswordPage;
```

**Reset password page** - `app/hazo_auth/reset_password/page.tsx`:
```typescript
import { ResetPasswordPage } from "hazo_auth/pages/reset_password";
export default ResetPasswordPage;
```

**Email verification page** - `app/hazo_auth/verify_email/page.tsx`:
```typescript
import { VerifyEmailPage } from "hazo_auth/pages/verify_email";
export default VerifyEmailPage;
```

**My settings page** - `app/hazo_auth/my_settings/page.tsx`:
```typescript
import { MySettingsPage } from "hazo_auth/pages/my_settings";
export default MySettingsPage;
```

### Step 5.3: Custom page routes (Advanced)

If you need custom paths or want to wrap pages with your own layout:

```typescript
// app/(auth)/login/page.tsx
import { LoginPage } from "hazo_auth/pages/login";

export default function CustomLoginPage() {
  return (
    <div className="min-h-screen flex items-center justify-center">
      <LoginPage 
        redirectRoute="/dashboard"
        successMessage="Welcome back!"
      />
    </div>
  );
}
```

**Checklist:**
- [ ] Login page created (`/hazo_auth/login`)
- [ ] Register page created (`/hazo_auth/register`)
- [ ] Forgot password page created (`/hazo_auth/forgot_password`)
- [ ] Reset password page created (`/hazo_auth/reset_password`)
- [ ] Email verification page created (`/hazo_auth/verify_email`)
- [ ] My settings page created (`/hazo_auth/my_settings`)

---

## Phase 5.1: Proxy/Middleware Setup (Optional)

**Note:** Next.js is migrating from `middleware.ts` to `proxy.ts` (see [Next.js documentation](https://nextjs.org/docs/messages/middleware-to-proxy)). The functionality remains the same - both work, but `proxy.ts` is the new convention.

If you want to protect routes at the Edge Runtime level (before pages load), create a proxy/middleware file:

### Step 5.1.1: Create Proxy File (Recommended)

Create `proxy.ts` in your project root (or `middleware.ts` - both work):

```typescript
// proxy.ts (or middleware.ts)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { validate_session_cookie } from "hazo_auth/server/middleware";

export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // Protect your routes (e.g., /members, /dashboard, etc.)
  if (pathname.startsWith("/members")) {
    const { valid } = await validate_session_cookie(request);
    
    if (!valid) {
      const login_url = new URL("/hazo_auth/login", request.url);
      login_url.searchParams.set("redirect", pathname);
      return NextResponse.redirect(login_url);
    }
  }
  
  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
```

### Step 5.1.2: Simple Cookie Check (Alternative)

If you prefer a simpler approach without JWT validation:

```typescript
// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

// Cookie prefix must match HAZO_AUTH_COOKIE_PREFIX env var
const COOKIE_PREFIX = process.env.HAZO_AUTH_COOKIE_PREFIX || "";

export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;

  if (pathname.startsWith("/members")) {
    const user_id = request.cookies.get(`${COOKIE_PREFIX}hazo_auth_user_id`)?.value;
    const user_email = request.cookies.get(`${COOKIE_PREFIX}hazo_auth_user_email`)?.value;

    if (!user_id || !user_email) {
      const login_url = new URL("/hazo_auth/login", request.url);
      login_url.searchParams.set("redirect", pathname);
      return NextResponse.redirect(login_url);
    }
  }

  return NextResponse.next();
}
```

**Important Notes:**
- Proxy/middleware runs in Edge Runtime (cannot use Node.js APIs like SQLite)
- JWT validation (`validate_session_cookie`) provides better security
- Simple cookie check is faster but doesn't validate token integrity
- Full user validation (e.g., deactivated accounts) happens in API routes/layouts
- Both `proxy.ts` and `middleware.ts` work - Next.js recommends `proxy.ts`

**Checklist:**
- [ ] Proxy/middleware file created (optional - only if you need route protection)
- [ ] Protected routes configured
- [ ] JWT validation used (recommended) or simple cookie check

---

## Phase 5.2: Relationship Accounts Setup (Optional)

If your app needs managed sub-profiles (parent managing children, teacher managing students):

### Step 5.2.1: Enable in config

Add to `config/hazo_auth_config.ini`:
```ini
[hazo_auth__relationships]
enabled = true
allowed_types = parent, teacher, manager, caregiver
default_type = parent
```

### Step 5.2.2: Run migration

```bash
npm run migrate migrations/013_relationships.sql
```

### Step 5.2.3: Create API routes

```bash
npx hazo_auth generate-routes
```

Or manually create:

`app/api/hazo_auth/relationships/route.ts`:
```typescript
export { GET, POST, PATCH, DELETE } from "hazo_auth/server/routes/relationships";
```

`app/api/hazo_auth/relationships/self/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/relationship_self";
```

`app/api/hazo_auth/relationships/upgrade/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/relationship_upgrade";
```

`app/api/hazo_auth/pin-login/route.ts`:
```typescript
export { POST } from "hazo_auth/server/routes/pin_login";
```

---

## Phase 6: Verification Tests

Run these tests to verify your setup is working correctly.

### Test 1: API Health Check - Standardized `/api/hazo_auth/me` Endpoint

**⚠️ IMPORTANT: Use `/api/hazo_auth/me` for all client-side authentication checks. It always returns a standardized format with permissions.**

```bash
curl -s http://localhost:3000/api/hazo_auth/me | jq
```

**Expected response (not authenticated):**
```json
{
  "authenticated": false
}
```

**Expected response (authenticated - standardized format):**
```json
{
  "authenticated": true,
  "user_id": "28fe0aff-29c7-407e-b92e-bf11a6a3332f",
  "email": "test@example.com",
  "name": "Test User",
  "email_verified": false,
  "last_logon": "2025-01-27T16:18:00.054Z",
  "profile_picture_url": "https://gravatar.com/avatar/...",
  "profile_source": "gravatar",
  "user": {
    "id": "28fe0aff-29c7-407e-b92e-bf11a6a3332f",
    "email_address": "test@example.com",
    "name": "Test User",
    "is_active": true,
    "profile_picture_url": "https://gravatar.com/avatar/..."
  },
  "permissions": [],
  "permission_ok": true
}
```

**Key Points:**
- ✅ Always returns the same standardized format
- ✅ Always includes `permissions` and `permission_ok` fields
- ✅ Top-level fields (`user_id`, `email`, `name`) for backward compatibility
- ✅ `user` object contains full user details
- ✅ Use this endpoint instead of `/api/hazo_auth/get_auth` for client-side code

### Test 2: Registration API

```bash
curl -X POST http://localhost:3000/api/hazo_auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"TestPass123!","name":"Test User"}'
```

**Expected response:**
```json
{
  "success": true,
  "message": "Registration successful. Please check your email to verify your account."
}
```

### Test 3: Email Sending

After registration, check the inbox for `test@example.com` for a verification email.

**If email not received, check:**
- `ZEPTOMAIL_API_KEY` is set in `.env.local`
- `from_email` is configured and verified in Zeptomail
- Check server logs for email errors

### Test 4: Login API

```bash
curl -X POST http://localhost:3000/api/hazo_auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"TestPass123!"}'
```

**Expected response (success):**
```json
{
  "success": true,
  "user": {
    "id": "...",
    "email": "test@example.com",
    "name": "Test User"
  }
}
```

### Test 5: Profile Picture Library

```bash
curl -s http://localhost:3000/api/hazo_auth/library_photos | jq
```

**Expected response:**
```json
{
  "success": true,
  "categories": ["Cars", "Young Cartoons"],
  "source": "project"
}
```

The `source` field indicates where photos are served from:
- `"project"` - Photos are in your project's `public/profile_pictures/library/`
- `"node_modules"` - Photos are served from `node_modules/hazo_auth/public/` via API route

**Test paginated photos:**
```bash
curl -s "http://localhost:3000/api/hazo_auth/library_photos?category=Cars&page=1&page_size=5" | jq
```

**Note:** Library photos work automatically whether they're copied to your project or still in node_modules. The API will serve them from node_modules as a fallback.

### Test 6: Visit Pages in Browser

Start your dev server:
```bash
npm run dev
```

Visit each page and verify it loads:
- [ ] `http://localhost:3000/hazo_auth/login` - Login form displays
- [ ] `http://localhost:3000/hazo_auth/register` - Registration form displays
- [ ] `http://localhost:3000/hazo_auth/forgot_password` - Forgot password form displays
- [ ] `http://localhost:3000/hazo_auth/my_settings` - Settings page displays (after login)
- [ ] `http://localhost:3000/hazo_auth/profile_stamp_test` - ProfileStamp component examples display

### Test 7: Google OAuth (if configured)

If you completed Phase 3.2 (Google OAuth Setup):

**Check login page:**
1. Visit `http://localhost:3000/hazo_auth/login`
2. Verify "Sign in with Google" button appears
3. Verify divider with "or" text (if email/password is also enabled)

**Test Google sign-in:**
1. Click "Sign in with Google" button
2. You should be redirected to Google's login page
3. Sign in with your Google account
4. You should be redirected back to your app and logged in
5. Visit `/hazo_auth/my_settings`
6. Verify "Connected Accounts" section shows Google as connected

**Test Google-only user features:**
1. If you signed in with Google (and didn't have a password account first):
2. Visit `/hazo_auth/my_settings`
3. Verify "Set Password" section appears
4. Set a password
5. Log out and try logging in with email/password (should work)

**Test forgot password with Google-only user:**
1. Create a new user with Google OAuth only
2. Try visiting `/hazo_auth/forgot_password`
3. Enter the Google user's email
4. Should show message: "You registered with Google. Please sign in with Google instead."

**OAuth Test Checklist:**
- [ ] "Sign in with Google" button appears on login page
- [ ] OAuth divider appears (if both auth methods enabled)
- [ ] Google OAuth flow completes successfully
- [ ] User is logged in after OAuth callback
- [ ] Connected Accounts section shows Google in My Settings
- [ ] Set Password feature works for Google-only users
- [ ] Forgot password shows appropriate message for Google-only users

---

## Phase 7: HRBAC Setup (Optional)

Hierarchical Role-Based Access Control (HRBAC) extends the standard RBAC with 7 hierarchical scope levels. This phase is optional - only complete it if you need scope-based access control.

### Step 7.1: Enable HRBAC in Configuration

Add to your `hazo_auth_config.ini`:

```ini
[hazo_auth__scope_hierarchy]
enable_hrbac = true
# Note: No default_org needed - org determined from user authentication
scope_cache_ttl_minutes = 15
scope_cache_max_entries = 5000

# Note: In v5.0+, scope levels are stored as the "level" column in hazo_scopes
# Examples: "HQ", "Division", "Department", "Team", etc.
# The level field is a descriptive string, not a fixed L1-L7 hierarchy
```

### Step 7.2: Add HRBAC Permissions

Add the HRBAC management permissions to your `application_permission_list_defaults`:

```ini
[hazo_auth__user_management]
application_permission_list_defaults = admin_user_management,admin_role_management,admin_permission_management,admin_scope_hierarchy_management,admin_user_scope_assignment
```

### Step 7.3: Create HRBAC Database Tables

> **Note (v5.0+):** `hazo_scopes`, `hazo_user_scopes`, and `hazo_invitations` are now part of the **core schema** in Phase 3. If you ran the canonical SQLite or PostgreSQL script there (or `npx hazo_auth init-db`), these tables already exist — you can skip ahead to Step 7.4. The scripts below are kept for upgrades from v4.x or for partial recovery.

**Upgrading from v4.x?** Run the consolidation migration which drops the legacy tables (`hazo_org`, `hazo_scopes_l1..l7`, `hazo_user_roles`) and creates the new schema:

```bash
npm run migrate migrations/009_scope_consolidation.sql
```

If you prefer manual setup, use the scripts below:

#### PostgreSQL

```sql
-- =============================================
-- HRBAC Database Setup Script (PostgreSQL)
-- v5.0+ Unified Scope Model
-- =============================================

-- 1. Create unified hazo_scopes table (with branding columns)
CREATE TABLE IF NOT EXISTS hazo_scopes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    parent_id UUID REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    level TEXT NOT NULL,
    logo_url TEXT,
    primary_color TEXT,
    secondary_color TEXT,
    tagline TEXT,
    slug TEXT,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- 2. Create indexes for hierarchy traversal
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_parent ON hazo_scopes(parent_id);
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_level ON hazo_scopes(level);
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);

-- 3. Create system scopes
-- Note: Super Admin scope retired in v10; use hazo_org_global_admin permission instead (see migration 020)
INSERT INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
VALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', NOW(), NOW())
ON CONFLICT (id) DO NOTHING;

-- 4. Create user scopes junction table (membership-based multi-tenancy)
CREATE TABLE IF NOT EXISTS hazo_user_scopes (
    user_id UUID NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
    scope_id UUID NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    root_scope_id UUID NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    role_id UUID NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,
    status TEXT DEFAULT 'ACTIVE' CHECK (status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'DEPARTED')),
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    PRIMARY KEY (user_id, scope_id)
);

CREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_scope ON hazo_user_scopes(scope_id);
CREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_root ON hazo_user_scopes(root_scope_id);
CREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_role ON hazo_user_scopes(role_id);

-- 5. Create invitations table (for onboarding new users)
CREATE TABLE IF NOT EXISTS hazo_invitations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email_address TEXT NOT NULL,
    scope_id UUID NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    role_id UUID NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,
    status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED')),
    invited_by UUID REFERENCES hazo_users(id) ON DELETE SET NULL,
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
    accepted_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_hazo_invitations_email ON hazo_invitations(email_address);
CREATE INDEX IF NOT EXISTS idx_hazo_invitations_scope ON hazo_invitations(scope_id);
CREATE INDEX IF NOT EXISTS idx_hazo_invitations_status ON hazo_invitations(status);

-- 6. Create firm_admin role (for firm creators)
INSERT INTO hazo_roles (id, role_name, created_at, changed_at)
VALUES (gen_random_uuid(), 'firm_admin', NOW(), NOW())
ON CONFLICT DO NOTHING;
```

#### PostgreSQL Grant Scripts

After creating the tables, grant appropriate permissions:

```sql
-- Grant to your admin user (replace 'your_admin_user' with actual username)
GRANT ALL PRIVILEGES ON TABLE hazo_scopes TO your_admin_user;
GRANT ALL PRIVILEGES ON TABLE hazo_user_scopes TO your_admin_user;
GRANT ALL PRIVILEGES ON TABLE hazo_invitations TO your_admin_user;

-- For PostgREST authenticated role
GRANT ALL PRIVILEGES ON TABLE hazo_scopes TO authenticated;
GRANT ALL PRIVILEGES ON TABLE hazo_user_scopes TO authenticated;
GRANT ALL PRIVILEGES ON TABLE hazo_invitations TO authenticated;
```

#### SQLite

```sql
-- =============================================
-- HRBAC Database Setup Script (SQLite)
-- v5.0+ Unified Scope Model
-- =============================================

-- 1. Create unified hazo_scopes table (with branding columns)
CREATE TABLE IF NOT EXISTS hazo_scopes (
    id TEXT PRIMARY KEY,
    parent_id TEXT REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    level TEXT NOT NULL,
    logo_url TEXT,
    primary_color TEXT,
    secondary_color TEXT,
    tagline TEXT,
    slug TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    changed_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- 2. Create indexes for hierarchy traversal
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_parent ON hazo_scopes(parent_id);
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_level ON hazo_scopes(level);
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);

-- 3. Create system scopes
-- Note: Super Admin scope retired in v10; use hazo_org_global_admin permission instead (see migration 020)
INSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
VALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', datetime('now'), datetime('now'));

-- 4. Create user scopes junction table (membership-based multi-tenancy)
CREATE TABLE IF NOT EXISTS hazo_user_scopes (
    user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
    scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    root_scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    role_id TEXT NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,
    status TEXT DEFAULT 'ACTIVE' CHECK (status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'DEPARTED')),
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    changed_at TEXT NOT NULL DEFAULT (datetime('now')),
    PRIMARY KEY (user_id, scope_id)
);

CREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_scope ON hazo_user_scopes(scope_id);
CREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_root ON hazo_user_scopes(root_scope_id);
CREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_role ON hazo_user_scopes(role_id);

-- 5. Create invitations table (for onboarding new users)
CREATE TABLE IF NOT EXISTS hazo_invitations (
    id TEXT PRIMARY KEY,
    email_address TEXT NOT NULL,
    scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,
    role_id TEXT NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,
    status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED')),
    invited_by TEXT REFERENCES hazo_users(id) ON DELETE SET NULL,
    expires_at TEXT NOT NULL,
    accepted_at TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    changed_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_hazo_invitations_email ON hazo_invitations(email_address);
CREATE INDEX IF NOT EXISTS idx_hazo_invitations_scope ON hazo_invitations(scope_id);
CREATE INDEX IF NOT EXISTS idx_hazo_invitations_status ON hazo_invitations(status);
```

### Step 7.4: Verify HRBAC Tables

#### PostgreSQL
```sql
SELECT table_name FROM information_schema.tables
WHERE table_name IN ('hazo_scopes', 'hazo_user_scopes', 'hazo_invitations');
-- Expected: 3 tables (hazo_scopes, hazo_user_scopes, hazo_invitations)
```

#### SQLite
```bash
sqlite3 data/hazo_auth.sqlite ".tables" | grep -E "hazo_scopes|hazo_user_scopes|hazo_invitations"
```

### Step 7.5: Add Slug Column to hazo_scopes (Optional - for Tenant Auth)

If you plan to use tenant-aware authentication with URL-friendly identifiers, add the `slug` column to the `hazo_scopes` table:

```bash
npm run migrate migrations/012_add_slug_to_hazo_scopes.sql
```

**Manual Migration (if needed):**

**PostgreSQL:**
```sql
ALTER TABLE hazo_scopes ADD COLUMN slug TEXT;
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);
```

**SQLite:**
```sql
ALTER TABLE hazo_scopes ADD COLUMN slug TEXT;
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);
```

**What is slug?**
- URL-friendly identifier for scopes (e.g., "acme-corp", "sales-division")
- Enables tenant context via URL paths (e.g., `/org/:slug/dashboard`)
- Not enforced as unique to allow flexibility

### Step 7.6: Test HRBAC

1. Start your dev server: `npm run dev`
2. Log in with a user that has `admin_scope_hierarchy_management` permission
3. Visit `/hazo_auth/user_management`
4. Verify the "Scope Hierarchy", "Scope Labels", and "User Scopes" tabs appear
5. Visit `/hazo_auth/scope_test` to test scope access checking

**HRBAC Checklist:**
- [ ] `enable_hrbac = true` in config
- [ ] HRBAC permissions added to defaults
- [ ] All HRBAC tables created:
  - [ ] `hazo_scopes` (unified scope hierarchy with branding)
  - [ ] `hazo_user_scopes` (user-scope-role assignments)
  - [ ] `hazo_invitations` (user invitation flow)
- [ ] System scopes exist:
  - [ ] Default System scope (00000000-0000-0000-0000-000000000001)
- [ ] Migration 020 run: Super Admin scope retired, `hazo_org_global_admin` permission granted
- [ ] Grants applied (PostgreSQL)
- [ ] HRBAC tabs visible in User Management
- [ ] Scope test page works

---

## Phase 8: Tenant-Aware Authentication Setup (Optional)

Tenant-aware authentication adds scope context to authentication results, enabling multi-tenant applications where users can access multiple organizations/scopes.

**Skip this phase if:**
- Your app doesn't need multi-tenancy
- Users don't switch between different scopes/organizations

### Step 8.1: Ensure Slug Column Exists

The tenant auth feature uses the `slug` column for URL-friendly scope identifiers. If you didn't complete Step 7.5, do so now:

```bash
npm run migrate migrations/012_add_slug_to_hazo_scopes.sql
```

### Step 8.2: Use Tenant Auth in API Routes

Replace `hazo_get_auth` with `hazo_get_tenant_auth` in your API routes:

**Before (standard auth):**
```typescript
import { hazo_get_auth } from "hazo_auth/server-lib";

export async function GET(request: NextRequest) {
  const auth = await hazo_get_auth(request);
  // No tenant context
}
```

**After (tenant auth):**
```typescript
import { hazo_get_tenant_auth } from "hazo_auth/server-lib";

export async function GET(request: NextRequest) {
  const auth = await hazo_get_tenant_auth(request);

  if (auth.authenticated && auth.organization) {
    // auth.organization contains tenant details
    // auth.user_scopes contains all scopes user can access (for UI switcher)
    const data = await getTenantData(auth.organization.id);
  }
}
```

**Or use strict mode with error handling:**
```typescript
import { require_tenant_auth, HazoAuthError } from "hazo_auth/server-lib";

export async function GET(request: NextRequest) {
  try {
    const auth = await require_tenant_auth(request);
    // auth.organization is guaranteed non-null
    return NextResponse.json(await getData(auth.organization.id));
  } catch (error) {
    if (error instanceof HazoAuthError) {
      return NextResponse.json(
        { error: error.message, code: error.code },
        { status: error.status_code }
      );
    }
    throw error;
  }
}
```

**Or use `withAuth` wrapper to eliminate boilerplate:**
```typescript
import { withAuth } from "hazo_auth/server-lib";

// Auth + params + error handling all automatic
export const GET = withAuth<{ id: string }>(
  async (request, auth, { id }) => {
    const data = await getData(auth.organization.id, id);
    return NextResponse.json(data);
  },
  { require_tenant: true }
);
```

See the README for full `withAuth` / `withOptionalAuth` documentation.

### Step 8.3: Set Scope Context from Frontend

The frontend needs to send the current scope ID via header or cookie.

**Option A: Header (recommended - per-request):**
```typescript
const response = await fetch("/api/dashboard", {
  headers: {
    "X-Hazo-Scope-Id": selectedScopeId,
  },
});
```

**Option B: Cookie (persistent - set once):**
```typescript
// Set cookie when user selects a scope
document.cookie = `hazo_auth_scope_id=${selectedScopeId}; path=/`;

// Then all requests include the scope automatically
const response = await fetch("/api/dashboard");
```

**Custom Configuration (optional):**
```typescript
// Use custom header or cookie names
const auth = await hazo_get_tenant_auth(request, {
  scope_header_name: "X-Tenant-Id",         // Custom header
  scope_cookie_name: "my_app_tenant_id",    // Custom cookie
});
```

### Step 8.4: Build Scope Switcher UI (Optional)

Use the `user_scopes` array to build a scope switcher dropdown:

```typescript
const auth = await hazo_get_tenant_auth(request);

// Return available scopes to frontend
return NextResponse.json({
  current_scope: auth.organization,
  available_scopes: auth.user_scopes.map(s => ({
    id: s.id,
    name: s.name,
    slug: s.slug,
    level: s.level,
    branding: {
      logo_url: s.logo_url,
      primary_color: s.primary_color,
    },
  })),
});
```

**Frontend Scope Switcher:**
```tsx
function ScopeSwitcher({ scopes, currentScopeId }) {
  const handleScopeChange = (scopeId: string) => {
    // Option 1: Set cookie
    document.cookie = `hazo_auth_scope_id=${scopeId}; path=/`;
    window.location.reload();

    // Option 2: Update state and send header on all requests
    setCurrentScope(scopeId);
  };

  return (
    <select value={currentScopeId} onChange={e => handleScopeChange(e.target.value)}>
      {scopes.map(scope => (
        <option key={scope.id} value={scope.id}>
          {scope.name} ({scope.level})
        </option>
      ))}
    </select>
  );
}
```

### Step 8.5: Test Tenant Auth

**Test with cURL:**
```bash
# No scope context - should return error or empty organization
curl -H "Cookie: hazo_auth_session=YOUR_TOKEN" \
  http://localhost:3000/api/dashboard | jq

# With scope context via header
curl -H "Cookie: hazo_auth_session=YOUR_TOKEN" \
  -H "X-Hazo-Scope-Id: SCOPE_UUID" \
  http://localhost:3000/api/dashboard | jq

# With scope context via cookie
curl -H "Cookie: hazo_auth_session=YOUR_TOKEN; hazo_auth_scope_id=SCOPE_UUID" \
  http://localhost:3000/api/dashboard | jq
```

**Tenant Auth Checklist:**
- [ ] `slug` column added to `hazo_scopes`
- [ ] API routes updated to use `hazo_get_tenant_auth` or `require_tenant_auth`
- [ ] Frontend sends scope context via header or cookie
- [ ] Tenant auth returns organization details when scope is valid
- [ ] Tenant auth returns user_scopes for building scope switcher
- [ ] Error handling in place for missing/invalid scope context
- [ ] Scope switcher UI built (optional but recommended)

---

## Troubleshooting

### Issue: "User is inactive" or "auth_utility_fetch_user_failed" errors

**Symptoms:** Users can't log in, error logs show `auth_utility_fetch_user_failed` or "User is inactive" even for newly created users.

**Cause:** Your database has an `is_active` column instead of the required `status` column. The code expects `status TEXT` with value `'ACTIVE'`.

**Solutions:**

1. **Run the status migration** (recommended):
   ```bash
   npm run migrate migrations/011_fix_status_case_to_uppercase.sql
   ```

2. **Or manually add the status column:**

   **SQLite:**
   ```sql
   -- Add status column
   ALTER TABLE hazo_users ADD COLUMN status TEXT DEFAULT 'ACTIVE';

   -- Migrate is_active values to status
   UPDATE hazo_users SET status = CASE
     WHEN is_active = 1 OR is_active = TRUE THEN 'ACTIVE'
     ELSE 'BLOCKED'
   END WHERE status IS NULL;
   ```

   **PostgreSQL:**
   ```sql
   -- Add status column
   ALTER TABLE hazo_users ADD COLUMN status TEXT NOT NULL DEFAULT 'ACTIVE';

   -- Migrate is_active values to status
   UPDATE hazo_users SET status = CASE
     WHEN is_active = TRUE THEN 'ACTIVE'
     ELSE 'BLOCKED'
   END;

   -- Create index
   CREATE INDEX IF NOT EXISTS idx_hazo_users_status ON hazo_users(status);
   ```

**Note:** The `status` column uses text values: `'PENDING'`, `'ACTIVE'`, or `'BLOCKED'`.

### Issue: Email not sending

**Symptoms:** Registration succeeds but no email received.

**Solutions:**
1. Check `ZEPTOMAIL_API_KEY` is set in `.env.local`
2. Verify `from_email` in `hazo_notify_config.ini` is authorized in Zeptomail
3. Check server console for error messages
4. Verify `emailer_module` is set to `zeptoemail_api`

### Issue: Profile pictures not showing

**Symptoms:** Avatar shows fallback initials, library photos empty.

**Solutions:**
1. **Run init command** (copies library photos automatically):
   ```bash
   npx hazo_auth init
   ```

2. **Or manually copy library photos:**
   ```bash
   mkdir -p public/profile_pictures/library
   cp -r node_modules/hazo_auth/public/profile_pictures/library/* public/profile_pictures/library/
   ```

3. **Check API response source:**
   ```bash
   curl -s http://localhost:3000/api/hazo_auth/library_photos | jq '.source'
   ```
   - If `"node_modules"` - Photos are being served from the package (slower but works)
   - If `"project"` - Photos are in your public folder (optimal)

4. Verify `library_photos` and `library_photo` API routes exist
5. Check file permissions on `public/profile_pictures/`

### Issue: Database connection failed

**Symptoms:** API returns 500 errors, "database connection failed" in logs.

**Solutions:**
1. Verify `hazo_auth_config.ini` has correct database settings
2. For SQLite: ensure `data/` directory exists and is writable
3. For PostgreSQL: verify connection string and credentials
4. Check `HAZO_CONNECT_POSTGREST_API_KEY` is set

### Issue: "Module not found" errors

**Symptoms:** Import errors when running the app.

**Solutions:**
1. Ensure `hazo_auth` is installed: `npm install hazo_auth`
2. Clear node_modules and reinstall: `rm -rf node_modules && npm install`
3. Verify you're importing from correct paths (see Phase 5)

### Issue: API routes return 404

**Symptoms:** Calls to `/api/hazo_auth/*` return 404.

**Solutions:**
1. Run `npx hazo_auth generate-routes` to create routes
2. Verify route files exist in `app/api/hazo_auth/`
3. Check Next.js is detecting routes: `npm run dev` and watch console
4. Ensure route files export correct HTTP methods

### Issue: Authentication not persisting

**Symptoms:** User logs in but immediately shows as logged out.

**Solutions:**
1. Verify `JWT_SECRET` is set in `.env.local`
2. Check cookies are being set (inspect browser devtools > Application > Cookies)
3. Ensure API routes are on same domain (no CORS issues)

### Issue: 404 after Google OAuth login

**Symptoms:** User completes Google sign-in but gets redirected to 404 page (typically `/hazo_auth/create_firm`).

**Solutions:**
1. **If not using invitations system:** Set `skip_invitation_check = true` in `[hazo_auth__oauth]`:
   ```ini
   [hazo_auth__oauth]
   skip_invitation_check = true
   no_scope_redirect = /
   ```

2. **If using invitations system:** Run the migration to create `hazo_invitations` table:
   ```bash
   npm run migrate migrations/009_scope_consolidation.sql
   ```

3. **If using custom paths:** Set `create_firm_url` to your app's create firm page:
   ```ini
   [hazo_auth__oauth]
   create_firm_url = /my-app/create-organization
   ```

4. **Check logs** for `invitation_table_missing` warnings - this indicates the table doesn't exist

### Issue: Navbar logo or company name not showing

**Symptoms:** Navbar appears but logo and/or company name are missing.

**Solutions:**
1. **Configure logo and company name in `hazo_auth_config.ini`:**
   ```ini
   [hazo_auth__navbar]
   logo_path = /logo.png
   company_name = My Company
   ```

2. **Ensure logo file exists:**
   - Place your logo in the `public/` folder
   - The path should be relative to public (e.g., `/logo.png` for `public/logo.png`)

3. **Verify config is being read:**
   - Config file must be in project root (where `process.cwd()` points)
   - Restart the dev server after changing config

4. **Check if navbar is enabled:**
   ```ini
   [hazo_auth__navbar]
   enable_navbar = true
   ```

**Note:** By default, `logo_path` and `company_name` are empty strings, so nothing displays until you configure them.

### Issue: Migrating from v4 to v5 schema

**Symptoms:** Permissions resolve to `[]`, "Access Denied" on all admin pages, 403 responses from withAuth, despite user having roles assigned.

**Cause:** v5 replaced `hazo_user_roles` with `hazo_user_scopes` (scope-based role assignments) and requires several new tables. If your database still has v4 schema, the permission lookup finds no role assignments.

**Key schema differences (v4 vs v5):**
- `hazo_user_roles` replaced by `hazo_user_scopes` (with `root_scope_id` and `role_id` columns)
- `hazo_org` replaced by `hazo_scopes` (unified hierarchical scope model)
- All ID columns must be TEXT (UUID), not INTEGER
- `hazo_role_permissions` uses composite PK `(role_id, permission_id)` with no `id` column

**Solution:**
1. Run `npx hazo_auth validate` to identify schema issues
2. Run migration 009: `npm run migrate migrations/009_scope_consolidation.sql`
3. Migrate existing role assignments:
   ```sql
   -- Example: migrate hazo_user_roles data to hazo_user_scopes
   INSERT INTO hazo_user_scopes (user_id, scope_id, root_scope_id, role_id, status)
   SELECT
     ur.user_id,
     '00000000-0000-0000-0000-000000000001',  -- default system scope
     '00000000-0000-0000-0000-000000000001',  -- default system scope
     ur.role_id,
     'ACTIVE'
   FROM hazo_user_roles ur;
   ```
4. Run `npx hazo_auth init-permissions` to ensure all admin permissions exist

### Issue: Understanding permission configuration

**Symptoms:** Confusion about which config sections control permissions and how they relate.

**Explanation:** Permissions are configured in three related places:

1. **`[hazo_auth__app_permissions]`** - Declares app-level permission names with descriptions (for documentation/UI display):
   ```ini
   app_permission_1 = can_view_reports:View analytical reports
   app_permission_2 = can_export_data:Export data to CSV
   ```

2. **`[hazo_auth__user_management] application_permission_list_defaults`** - Comma-separated list of permission names to create in the DB when running `npx hazo_auth init-permissions`:
   ```ini
   application_permission_list_defaults = admin_user_management,admin_role_management,admin_permission_management,can_view_reports,can_export_data
   ```

3. **Built-in admin permissions** (required by `UserManagementLayout` component): `admin_user_management`, `admin_role_management`, `admin_permission_management`, `admin_scope_hierarchy_management`, `admin_user_scope_assignment`, `admin_system`, `admin_test_access`. These are available programmatically via:
   ```typescript
   import { HAZO_AUTH_PERMISSIONS, ALL_ADMIN_PERMISSIONS } from "hazo_auth/client";
   ```

### Issue: Database ID type mismatches (silent permission failures)

**Symptoms:** User is authenticated but permissions array is always empty. No errors in console. `hazo_get_auth` returns `permissions: []` even though roles and permissions exist in the database.

**Cause:** SQLite drivers may return numeric values for columns defined as `INTEGER PRIMARY KEY`. v5 uses strict string comparison for role_id and permission_id matching. If the DB returns `role_id: 1` (number) but the code expects `"1"` (string), the comparison fails silently.

**Solutions:**
1. **Ensure all ID columns are TEXT type** in your schema. v5 requires TEXT (UUID) IDs:
   ```sql
   -- CORRECT (v5)
   CREATE TABLE hazo_roles (id TEXT PRIMARY KEY, ...);

   -- WRONG (v4 legacy)
   CREATE TABLE hazo_roles (id INTEGER PRIMARY KEY, ...);
   ```
2. Run `npx hazo_auth validate` — the schema check will flag INTEGER ID columns
3. If you cannot change the schema, v5.1.28+ applies `String()` normalization to role_id and permission_id comparisons, which handles mixed types

**Note for PostgreSQL users:** PostgREST typically returns UUID values as strings, so this issue primarily affects SQLite with INTEGER PKs.

### Issue: CLI commands fail with "server-only" import error

**Symptoms:** Running `npx hazo_auth validate`, `npx hazo_auth init-permissions`, etc. throws:
```
Error: This module cannot be imported from a Client Component module.
It should only be used from a Server Component.
```

**Cause:** The `server-only` package throws when imported outside a React Server Component context. CLI commands run in plain Node.js.

**Solution (v5.1.28+):** Fixed automatically — the CLI wrapper now sets `--conditions react-server` in NODE_OPTIONS.

**Workaround for older versions:** Set the condition manually:
```bash
NODE_OPTIONS='--conditions react-server' npx hazo_auth validate
```

---

## Final Checklist

**Configuration:**
- [ ] `hazo_auth_config.ini` configured
- [ ] `cookie_prefix` set in `[hazo_auth__cookies]` section (REQUIRED)
- [ ] `HAZO_AUTH_COOKIE_PREFIX` env var matches `cookie_prefix` (REQUIRED)
- [ ] `hazo_notify_config.ini` configured
- [ ] `.env.local` with all required variables (HAZO_AUTH_COOKIE_PREFIX, JWT_SECRET, ZEPTOMAIL_API_KEY)
- [ ] Navbar configured with logo and company name (see Step 1.4)

**Database:**
- [ ] Database created and accessible
- [ ] All 6 tables exist

**API Routes:**
- [ ] All 16 API routes created
- [ ] `/api/hazo_auth/me` returns `{"authenticated": false}`

**Pages:**
- [ ] All 6 auth pages created
- [ ] Pages render without errors
- [ ] Navbar displays with logo and company name

**Features:**
- [ ] Registration works
- [ ] Email verification sends
- [ ] Login works
- [ ] Profile pictures display
- [ ] Settings page accessible

---

## Quick Setup Command

For AI assistants, here's a single command to verify setup status:

```bash
npx hazo_auth validate
```

This will check all configuration and report any issues.

