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
waitlistStatusto be set toapproved. The auth and guest middlewares exempt/invite/acceptfrom waitlist redirects, and anapproveByInvitationmutation 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
| Role | Capabilities |
|---|---|
owner | Full access, can delete team, transfer ownership |
admin | Full access, can manage members (not owners) |
editor | Create/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.