nyxcore-systems
9 min read

Shipping a Smorgasbord: Progressive Auth, Smart Invitations, and Hard-Won Lessons from the Trenches

A deep dive into a recent development sprint covering progressive authentication with social logins, a robust invitation system, a new access request flow, and critical lessons learned from integrating third-party APIs like Resend and OpenAI.

nextjsnextauthprismatrpcresendopenaiauthonboardingdevopsapi-integration

The life of a developer is a cycle of building, fixing, and iterating. Sometimes, you get those sessions where everything just clicks, and you ship a whole suite of interconnected features that fundamentally improve the user experience and streamline internal workflows. This past week was one of those sessions. We pushed a significant set of updates to production, touching everything from core authentication to user onboarding and even some crucial API integrations.

Let's break down what went live and, more importantly, the hard-won lessons we picked up along the way.

The Big Picture: A Leap Forward in User Onboarding

Our primary goal was to enhance how users join and interact with our platform. This meant a multi-pronged attack:

  • Progressive Authentication (Phase 1): Integrating social logins (Google) to offer more flexible sign-up/login options.
  • Robust Invitation System: Making it easier and more reliable to invite new users.
  • Streamlined Access Request Flow: Providing a clear path for new users to request access if they don't have an invitation.
  • Critical API Fixes: Addressing quirks with OpenAI's new GPT-5 model and Resend's email tracking.

All these features are now live, and we've already sent out our first few Clarait invitations!

Deep Dive: Building a Better User Journey

Progressive Authentication: Beyond Magic Links (Phase 1)

For a while, we relied heavily on Magic Links for authentication. While great for simplicity, users increasingly expect the convenience of "Sign in with Google" or GitHub. This sprint introduced Google OAuth as our first social login provider, laying the groundwork for a more progressive authentication strategy.

Here's how we integrated it:

  1. NextAuth Configuration: We added Google to our src/server/auth.ts setup, making it conditional on environment variables.

    typescript
    // src/server/auth.ts
    Google({
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
      allowDangerousEmailAccountLinking: true, // Crucial for progressive auth!
    }),
    GitHub({
      clientId: env.GITHUB_CLIENT_ID,
      clientSecret: env.GITHUB_CLIENT_SECRET,
      allowDangerousEmailAccountLinking: true, // Also added here for consistency
    }),
    

    The allowDangerousEmailAccountLinking: true flag is key here. It enables users to link a social account to an existing email-based account (or vice-versa) if the emails match, providing a smoother experience for users who might have previously signed up with a Magic Link. This is a common pattern in progressive authentication, allowing users to choose their preferred login method without creating duplicate accounts.

  2. Login Page Refresh: The src/app/(auth)/login/page.tsx now prominently features Google and GitHub buttons, with Magic Link as a fallback. The callbackUrl is also preserved across these methods, ensuring users land where they expect after authentication.

  3. User Preference Tracking: To understand user behavior and potentially guide future authentication prompts, we added a preferredAuthMethod field to our User model in prisma/schema.prisma. This is populated during the NextAuth JWT callback.

    prisma
    // prisma/schema.prisma
    enum AuthMethod {
      MAGIC_LINK
      GITHUB
      GOOGLE
      // ... future methods like PASSKEY
    }
    
    model User {
      // ... existing fields
      preferredAuthMethod AuthMethod?
      // ...
    }
    

The Invitation System: From Flimsy to Robust

Our previous invitation system was functional but had a few rough edges. We tackled them head-on:

  1. Extended Expiry: Invitations now last 24 hours (up from 15 minutes), giving users ample time to respond without feeling rushed.
  2. Branded Email Sending via Resend: The biggest improvement was integrating Resend to send beautifully branded invitation emails automatically when an invitation is generated.
    typescript
    // src/server/services/invitation-service.ts (simplified)
    async function generateInvitation(email: string, tenantId: string) {
      const invitation = await prisma.invitation.create({ /* ... */ });
      // ... logic to send email
      return invitation;
    }
    
  3. Magic Link Email Customization: We also customized NextAuth's sendVerificationRequest callback to use our branded Resend template for Magic Links, replacing the generic text email.

A Critical Lesson: Resend's Link Tracking Gotcha

One significant hurdle we hit was Resend's default link tracking. Their bots would pre-click our Magic Links and invitation links, invalidating them before the user even had a chance to open the email! This is a common feature in email sending services for analytics and link safety, but it's a nightmare for single-use authentication links.

The fix was to explicitly disable link tracking for these sensitive emails by adding the X-Entity-Ref-ID header. This header tells Resend not to track or modify the links within the email.

typescript
// Custom Resend provider for NextAuth (simplified)
const sendVerificationRequest = async (params: { identifier: string; url: string; provider: NextAuthEmailProvider }) => {
  const { identifier: email, url } = params;
  await resend.emails.send({
    from: 'no-reply@ourdomain.com',
    to: email,
    subject: 'Sign in to Our App',
    html: render(MagicLinkEmail({ url })), // Our custom React email template
    headers: {
      'X-Entity-Ref-ID': crypto.randomUUID(), // DISABLES RESEND LINK TRACKING for this email!
    },
  });
};

This was a critical discovery and a prime example of how third-party API defaults can sometimes work against your intended user flow. Always be wary of automated link processing when dealing with sensitive, single-use URLs.

The Request Access Flow: A Guided Path for New Users

What happens when a user lands on our site without an invitation or an existing account? Previously, they'd hit a generic "No workspace" message. Now, they're gracefully redirected to a new /request-access page.

This flow is designed for discoverability and controlled access:

  1. AccessRequest Model: A new Prisma model AccessRequest tracks incoming requests, storing GDPR-compliant details like name, email, company, and reason.
  2. tRPC API: We built a new tRPC router (src/server/trpc/routers/access-requests.ts) for submitting requests, checking their status, and for superadmins to manage them.
  3. Superadmin UI: Our superadmin dashboard now has a "Requests" tab with a pending count badge. Superadmins can review requests, approve them (assigning a tenant and role), or reject them with a note.
  4. Automated Invitation on Approval: When an admin approves a request, the system automatically generates and sends an invitation email to the user, seamlessly transitioning them into the standard onboarding flow.

This closes a significant gap in our user acquisition strategy, turning "dead ends" into active leads.

Smaller Victories & Quality of Life

Beyond the major features, we also shipped several important fixes and improvements:

  • Batch URL Import for Axiom: A batchFetchUrls tRPC mutation and UI were added to quickly import multiple URLs into our Axiom integration, speeding up data ingestion and developer productivity.
  • OpenAI GPT-5 Fix: This was another fun one. GPT-5 introduced some breaking changes in its API.
    • max_tokens was replaced by max_completion_tokens.
    • It no longer supports custom temperature or top_p parameters (it forces them to 1). We updated our API calls to use max_completion_tokens for all models and added GPT-5 to our isReasoningModel() check to skip temperature/top_p for it. This ensures our AI integrations continue to function correctly with the latest models.
  • formatRelativeTime for Future Dates: Our utility function now correctly displays "in X hours/days" instead of "just now" for future timestamps, which was crucial for displaying invitation expiry.
  • Workflow Persona Assignments: Corrected 7 persona assignments in our clarait-auth workflow (335a4785) to align with our BRauth Design Spec, ensuring internal processes are accurate.
  • nyxCore Description: Added a ~200-word description for Clarait, improving internal documentation and clarity.

Lessons from the Trenches: My "Pain Log" Reframed

Every developer knows that shipping features isn't always smooth sailing. Here are some of the critical "gotchas" and how we overcame them, turning pain into actionable insights:

1. The Case of the Pre-Clicked Magic Links (Resend API)

  • Problem: Magic Links and invitation links were being invalidated before the user clicked them.
  • Root Cause: Resend's default behavior includes a link-tracking bot that pre-clicks links in emails to verify them or gather metrics. For single-use links, this is disastrous.
  • The Fix: Use the X-Entity-Ref-ID HTTP header in your email sending request. This header tells Resend (and other similar services) to treat the email as a unique entity and disable link tracking.
    typescript
    // Example for Resend API call
    headers: {
      'X-Entity-Ref-ID': crypto.randomUUID(), // Crucial for single-use links!
    },
    
  • Takeaway: Always investigate default behaviors of third-party email services, especially when dealing with sensitive, single-use links. Look for options to disable link tracking or other automated interactions.

2. Taming OpenAI GPT-5's Quirks

  • Problem: When integrating with OpenAI's new GPT-5 model, we encountered 400 errors related to unsupported parameters.
  • Root Cause: GPT-5 deprecated max_tokens in favor of max_completion_tokens and no longer allowed custom temperature or top_p values (it forces them to 1).
  • The Fix:
    1. Update all OpenAI API calls to use max_completion_tokens.
    2. Implement a check (e.g., isReasoningModel()) to conditionally omit temperature and top_p parameters when interacting with GPT-5 specifically.
  • Takeaway: New API versions, especially for rapidly evolving AI models, often come with breaking changes. Always consult the latest documentation and build in conditional logic or abstraction layers to handle model-specific parameter differences gracefully.

3. Time Travel with formatRelativeTime()

  • Problem: Our formatRelativeTime() utility function showed "just now" for future dates (e.g., "invitation expires in 23 hours").
  • Root Cause: The function was designed primarily for past dates and incorrectly treated negative time differences (future dates) as very small positive differences, falling into the "less than 60 seconds" bucket.
  • The Fix: Added a specific branch to handle negative differences, correctly formatting them with "in X time" prefixes.
  • Takeaway: Edge cases, especially around time and dates, are common. Always test utility functions with a full range of inputs, including past, present, and future values, and zero.

What's Next?

With these features deployed, our immediate focus shifts to:

  1. Progressive Auth Phase 2: Passkeys (WebAuthn): The design spec is ready; now it's time for the implementation plan. This will be a significant step towards a truly passwordless future.
  2. User Verification: Ensuring the initial Clarait invites successfully onboarded users into their respective tenants.
  3. Superadmin Enhancements: Considering a "Resend Invitation" button for superadmins to handle missed emails.
  4. API Credits & Workflows: Topping up Anthropic API credits and running our clarait-auth workflow with new compliance docs.

Conclusion

This sprint was a testament to the power of focused development. By tackling core authentication, onboarding, and critical API integrations, we've significantly improved our platform's usability and laid robust foundations for future growth. Every "pain" became a "lesson," strengthening our systems and our understanding of the tools we use. Onwards to Passkeys!

json
{
  "thingsDone": [
    "Progressive Auth (Google OAuth, `allowDangerousEmailAccountLinking`, `preferredAuthMethod` in Prisma)",
    "Updated login page UI (Google + GitHub buttons)",
    "Increased invitation expiry to 24 hours",
    "Implemented branded invitation email sending via Resend API",
    "Custom Resend `sendVerificationRequest` for branded Magic Link emails",
    "Implemented `X-Entity-Ref-ID` header to disable Resend link tracking",
    "New `AccessRequest` Prisma model and `access_requests` table",
    "New tRPC router for access requests (submit, status, list, approve, reject)",
    "New `/request-access` page (GDPR-compliant form)",
    "Dashboard redirect for users without tenant to `/request-access`",
    "Superadmin UI for managing access requests (approve/reject with invitation auto-generation)",
    "Batch URL Import for Axiom (`batchFetchUrls` tRPC mutation + UI)",
    "OpenAI GPT-5 fix (`max_tokens` -> `max_completion_tokens`, skip `temperature`/`top_p` for GPT-5)",
    "`formatRelativeTime` fix for future dates",
    "Workflow persona assignments fixed for `clarait-auth`",
    "Added nyxCore description for Clarait"
  ],
  "pains": [
    "Resend's link-tracking bot pre-clicking magic links",
    "OpenAI GPT-5 API parameter incompatibility (`max_tokens`, `temperature`, `top_p`)",
    "`formatRelativeTime()` not handling future dates correctly"
  ],
  "successes": [
    "Seamless social login integration",
    "Robust and user-friendly invitation system",
    "Controlled and discoverable access request flow",
    "Critical third-party API integration issues resolved",
    "Improved developer productivity tools (batch import)",
    "Enhanced user experience across multiple touchpoints"
  ],
  "techStack": [
    "Next.js",
    "NextAuth.js",
    "Prisma",
    "tRPC",
    "Resend API",
    "OpenAI API",
    "TypeScript",
    "React",
    "PostgreSQL"
  ]
}