Authentication

Owlat uses BetterAuth with the Convex adapter for authentication and organization (team) management.

Owlat uses BetterAuth with the Convex adapter for authentication and organization (team) management.

Overview

  • BetterAuth handles user registration, login, and sessions
  • Organization Plugin manages teams and membership
  • Session-based context provides team ID without explicit parameters

Architecture

┌─────────────────────────────────────────────────────────┐
│                    Frontend                             │
│  ┌─────────────────┐  ┌────────────────────────────┐   │
│  │    useAuth()    │  │    useOrganization()       │   │
│  │  - session      │  │  - members                 │   │
│  │  - user         │  │  - invitations             │   │
│  │  - orgId        │  │  - role management         │   │
│  └─────────────────┘  └────────────────────────────┘   │
└─────────────────────────┬───────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│                   BetterAuth (Convex)                   │
│  - User table (auto-created)                            │
│  - Session table (auto-created)                         │
│  - Organization table (auto-created)                    │
│  - Member table (auto-created)                          │
│  - Invitation table (auto-created)                      │
└─────────────────────────────────────────────────────────┘

Configuration

Server (auth.ts)

import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

export const auth = betterAuth({
    // Convex adapter provided by @convex-dev/better-auth
    trustedOrigins: [process.env.SITE_URL || ''],
    plugins: [
        organization({
            allowUserToCreateOrganization: true,
            creatorRole: 'owner',
            membershipLimit: 50,
            invitationExpiresIn: 7 * 24 * 60 * 60, // 7 days

            // Email invitations
            async sendInvitationEmail({ email, organization, inviter, invitation }) {
                await resend.emails.send({
                    from: 'Owlat <noreply@mail.owlat.app>',
                    to: email,
                    subject: `You're invited to join ${organization.name}`,
                    html: `<p>${inviter.user.name} invited you to join ${organization.name}.</p>
                 <a href="${process.env.SITE_URL}/invite/accept?id=${invitation.id}">
                   Accept Invitation
                 </a>`,
                });
            },
        }),
    ],
});

Client (auth-client.ts)

import { createAuthClient } from 'better-auth/vue';
import { organizationClient } from 'better-auth/client/plugins';

export const authClient = createAuthClient({
    baseURL: import.meta.env.VITE_CONVEX_SITE_URL,
    plugins: [organizationClient()],
});

// Export methods for use in components
export const {
    signIn,
    signUp,
    signOut,
    useSession,
    // Organization methods
    organization: {
        create: createOrganization,
        listOrganizations,
        setActive: setActiveOrganization,
        inviteMember,
        removeMember,
        updateMemberRole,
        cancelInvitation,
        // Hooks
        useListOrganizations,
        useActiveOrganization,
    },
} = authClient;

Frontend Composables

useAuth()

Main authentication composable.

const {
    user, // Current user
    session, // Session data
    isAuthenticated, // Boolean
    isLoading, // Loading state
    activeOrganizationId, // Current team ID from session
    organizationRole, // User's role in organization
    hasActiveOrganization, // Boolean
    signIn, // Login function
    signUp, // Register function
    signOut, // Logout function
} = useAuth();

useCurrentTeam()

Team context with legacy support.

const {
    teamId, // ID for Convex queries
    team, // Team data
    role, // owner | admin | editor
    isLoading,
} = useCurrentTeam();

useOrganization()

Organization membership management.

const {
    members, // Ref<Member[]>
    invitations, // Ref<Invitation[]>
    isLoading,
    error,
    loadMembers,
    loadInvitations,
    inviteMember, // (email, role) => Promise
    removeMember, // (memberId) => Promise
    updateMemberRole, // (memberId, role) => Promise
    cancelInvitation, // (invitationId) => Promise
    update, // (name, slug) => Promise
} = useOrganization();

Authentication Flow

Registration

<script setup>
const { signUp } = useAuth();
const { isWaitlistEnabled } = useWaitlist();

async function register() {
    // 1. Create BetterAuth user
    const result = await signUp({
        email: form.email,
        password: form.password,
        name: form.name,
    });

    // 2. Create user profile (sets waitlistStatus when waitlist is enabled)
    await createUserProfile({
        authUserId: result.user.id,
        email: form.email,
        name: form.name,
    });

    // 3. If waitlist is enabled, redirect to holding page and skip org creation
    if (isWaitlistEnabled.value) {
        await navigateTo('/waitlist');
        return;
    }

    // 4. Create organization (normal flow)
    const org = await createOrganization({
        name: form.teamName,
        slug: generateSlug(form.email),
    });

    // 5. Set as active organization
    await setActiveOrganization({ organizationId: org.id });

    // Redirect to dashboard
    await navigateTo('/dashboard');
}
</script>

When the waitlist is enabled, the userProfiles.create mutation checks the WAITLIST_AUTO_APPROVE_EMAILS Convex env var. Emails in that list are set to approved immediately; all others are set to pending.

Login

<script setup>
const { signIn } = useAuth();

async function login() {
    await signIn.email({
        email: form.email,
        password: form.password,
    });
    await navigateTo('/dashboard');
}
</script>

Protected Routes

<!-- In page component -->
<script setup>
definePageMeta({
    middleware: 'auth',
});
</script>

Invite Acceptance

<!-- pages/invite/accept.vue -->
<script setup>
const { isAuthenticated } = useAuth();
const invitationId = useRoute().query.id;

async function acceptInvitation() {
    await authClient.organization.acceptInvitation({
        invitationId,
    });
    await navigateTo('/dashboard');
}
</script>

<template>
    <div v-if="!isAuthenticated">
        Please <NuxtLink to="/auth/login">log in</NuxtLink> to accept this invitation.
    </div>
    <div v-else>
        <UiButton @click="acceptInvitation"> Accept Invitation </UiButton>
    </div>
</template>

Waitlist integration: When the waitlist is enabled, invited users bypass it automatically. At profile creation, a pending BetterAuth invitation for the user's email causes waitlistStatus to be set to approved. The auth and guest middlewares exempt /invite/accept from waitlist redirects, and an approveByInvitation mutation serves as a safety net to approve users after they accept the invitation.

Backend Session Context

Getting Team from Session

import { getTeamIdFromSession, getMutationContext } from './lib/sessionOrganization';

// In queries
export const list = query({
    handler: async (ctx) => {
        const teamId = await getTeamIdFromSession(ctx);
        // Use teamId for queries
    },
});

// In mutations (includes role)
export const create = mutation({
    handler: async (ctx, args) => {
        const { teamId, userId, role } = await getMutationContext(ctx);
        // Check role, create record with teamId
    },
});

Session Organization Helpers

// lib/sessionOrganization.ts

// Get full session context
export async function getSessionOrganization(ctx: QueryCtx | MutationCtx) {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity?.sessionId) return null;

    const session = await components.betterAuth.adapter.findOne({
        model: 'session',
        where: [{ field: 'id', value: identity.sessionId }],
    });

    return session?.activeOrganizationId
        ? {
                userId: session.userId,
                activeOrganizationId: session.activeOrganizationId,
            }
        : null;
}

// Convenience helper for queries
export async function getTeamIdFromSession(ctx: QueryCtx) {
    const session = await getSessionOrganization(ctx);
    if (!session?.teamId) throw new Error('No active organization');
    return session.teamId;
}

// For mutations with role checking
export async function getMutationContext(ctx: MutationCtx) {
    const session = await getSessionOrganization(ctx);
    if (!session) throw new Error('Not authenticated');

    const role = await getUserRole(ctx, session.teamId, session.userId);

    return {
        teamId: session.teamId,
        userId: session.userId,
        role,
    };
}

Role-Based Access

Roles

RoleCapabilities
ownerFull access, can delete team, transfer ownership
adminFull access, can manage members (not owners)
editorCreate/edit content, no team management

Permission Checks

import { isAdminRole, requirePermission } from './lib/permissions';

// Check if user has admin-level access
if (!isAdminRole(role)) {
    throw new Error('Admin access required');
}

// Require specific permission
requirePermission(role, 'admin'); // Throws if not admin/owner
requirePermission(role, 'owner'); // Throws if not owner

API Key Authentication

For REST API endpoints, use API keys instead of sessions:

// In HTTP handler
import { authenticateApiRequest } from './apiAuth';

const handleRequest = httpAction(async (ctx, request) => {
    const auth = await authenticateApiRequest(ctx, request);
    if (!auth.authenticated) {
        return new Response(auth.error, { status: 401 });
    }

    const { teamId, apiKeyId } = auth;

    // Process request with teamId context
});

API keys are created and managed in Settings > API Keys.