Skip to main content
CharleOS uses Better Auth for authentication with separate systems for team members and clients. This page provides an overview of the auth architecture.

Two Auth Systems

CharleOS has two completely separate authentication systems:

Team Auth

Internal team members (developers, PMs, CSMs, managers)
  • Google OAuth only
  • Invite-only (no public signup)
  • Role-based access control
  • Session-based authentication

Client Portal Auth

Client employees accessing the client portal
  • Email/password authentication
  • Invitation workflow
  • Per-client access control
  • Separate database tables
These systems are completely isolated. A team member cannot log into the client portal with their team credentials, and vice versa.

Architecture Overview

Team Authentication Flow

User visits charle.agency

"Sign in with Google" button

Google OAuth consent screen

Better Auth verifies @charle.co.uk email

Session created → Dashboard

Client Portal Authentication Flow

Client visits clients.charle.agency

Email + password sign-in

Better Auth verifies credentials

Session created → Client Portal

Database Tables

Team Auth Tables

TablePurpose
userTeam member profiles
sessionActive team sessions
accountOAuth provider connections
user_preferenceUI preferences

Client Portal Auth Tables

TablePurpose
client_userClient portal users
client_sessionActive client sessions
client_accountClient credentials
client_verificationPassword reset tokens
client_user_preferenceClient UI preferences

Access Control

Team Member Access Levels

CharleOS uses a two-tier access system:

Access Levels (System Permissions)

LevelWhoPermissions
AdminLukeFull system access, all features
ManagerSimon, Andre, Ben, NicManagement features, reports, admin tools
StaffEveryone elseStandard access based on work type

Work Types (Role-Based Features)

Work TypeDashboardKey Features
developmentIC DashboardMy Tasks, My Schedule, Time Tracking
designIC DashboardMy Tasks, My Schedule, Time Tracking
qaIC DashboardMy Tasks, My Schedule, Time Tracking
pmPM DashboardTask scheduling, client blocks
csmCSM DashboardQuotes, clients, help desk
sdrStaff DashboardLimited access
Combined Logic:
  • accessLevel controls what management features you see
  • workType controls which dashboard and features you get
  • Example: A developer with manager access level sees the Manager Dashboard (not IC Dashboard)

Client Portal Access

Client users have simpler access control:
RolePermissions
adminCan manage other client users, approve quotes
memberCan view tasks, submit tickets, view quotes

Authentication Features

Team Authentication

  • Only authentication method (no password)
  • Must use @charle.co.uk email
  • Profile picture synced from Google
  • Auto-uploaded to Cloudflare R2
  • No public signup page
  • Admin creates user accounts
  • Invitation email sent
  • User signs in with Google
  • 7-day session expiry
  • Automatically refreshed on activity
  • Stored in PostgreSQL
  • Tracks IP and user agent
  • New users start as “pending”
  • See waiting screen until admin assigns role
  • Prevents access to app features
  • Admin updates status to “active”

Client Portal Authentication

  • Password-based authentication
  • Passwords hashed with bcrypt
  • Password reset via email token
  • No Google OAuth
  • CSM creates client user
  • Invitation email sent with token
  • Client sets password on first login
  • Token expires after 7 days
  • Users belong to one client
  • Can only see their client’s data
  • No cross-client access
  • Enforced at database level

Environment Variables

Team Auth

VariableDescription
BETTER_AUTH_SECRETSecret for encrypting sessions (32+ chars)
BETTER_AUTH_URLBase URL (http://localhost:3000 local, https://charle.agency prod)
NEXT_PUBLIC_BETTER_AUTH_URLPublic-facing auth URL (client-side)
GOOGLE_CLIENT_IDGoogle OAuth client ID
GOOGLE_CLIENT_SECRETGoogle OAuth client secret

Client Portal Auth

Uses the same Better Auth instance but with client-specific context:
VariableDescription
NEXT_PUBLIC_CLIENT_PORTAL_URLClient portal URL (http://clients.localhost:3000 local, https://clients.charle.agency prod)

Code Structure

Team Auth

lib/
├── auth.ts              # Better Auth config (team)
├── auth-client.ts       # Client-side auth hooks (team)
└── permissions.ts       # Permission checks

app/
├── (auth)/              # Auth pages (team login, pending)
├── (dashboard)/         # Protected team routes
└── api/auth/[...all]/   # Better Auth API handler

Client Portal Auth

lib/
├── client-auth.ts       # Better Auth config (client portal)
└── client-auth-client.ts # Client-side auth hooks (portal)

app/client-portal/
├── sign-in/             # Client portal login
├── invite/              # Client invitation flow
├── (authenticated)/     # Protected client routes
└── callback/            # Auth callback

Security Features

Better Auth includes built-in CSRF protection for all auth requests
  • Sessions stored server-side in PostgreSQL
  • Tokens are random, not predictable
  • IP address and user agent tracked
  • Automatic session cleanup on expiry
  • Passwords hashed with bcrypt (cost factor 10)
  • Never stored in plain text
  • Password reset requires email verification
  • Tokens expire after 7 days
  • Team auth: Only @charle.co.uk emails
  • Client portal: Per-client isolation
  • No cross-origin session sharing

Session Management

Team Sessions

// Check if user is authenticated
const { data: session } = await auth.api.getSession({
  headers: headers(),
});

if (!session) {
  redirect("/auth");
}

// Access user data
const user = session.user;
console.log(user.name, user.email, user.accessLevel);

Client Portal Sessions

// Check if client user is authenticated
const { data: session } = await clientAuth.api.getSession({
  headers: headers(),
});

if (!session) {
  redirect("/client-portal/sign-in");
}

// Access client user data
const clientUser = session.user;
console.log(clientUser.name, clientUser.clientId);

Common Auth Patterns

Protected Route (Server Component)

// app/(dashboard)/tasks/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export default async function TasksPage() {
  const { data: session } = await auth.api.getSession({
    headers: headers(),
  });

  if (!session) {
    redirect("/auth");
  }

  // Check permissions
  if (!canViewTasks(session.user)) {
    redirect("/dashboard");
  }

  return <TasksList />;
}

Protected API Route

// app/api/tasks/route.ts
import { auth } from "@/lib/auth";
import { headers } from "next/headers";

export async function GET(req: Request) {
  const { data: session } = await auth.api.getSession({
    headers: headers(),
  });

  if (!session) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Fetch tasks...
}

Client-Side Auth Check

"use client";

import { useSession } from "@/lib/auth-client";

export function ProfileButton() {
  const { data: session, isPending } = useSession();

  if (isPending) return <Skeleton />;
  if (!session) return <SignInButton />;

  return <UserDropdown user={session.user} />;
}