Skip to content

Login Button Widget

The @flowsta/login-button package provides beautiful, pre-styled "Sign in with Flowsta" buttons for all major frameworks.

Installation

bash
npm install @flowsta/login-button

How the Button Works

Clicking the button performs a full-page redirect to https://login.flowsta.com/login with PKCE and state parameters. The user approves the sign-in there with their Flowsta Vault, and Flowsta redirects the browser back to your redirectUri with ?code=...&state=... - a fresh page load.

That means:

  • There is no success callback. The page that rendered the button is gone by the time authorization completes. Handle the authorization code on a callback page at your redirectUri (see Handling the Callback).
  • onClick fires just before the redirect (useful for analytics or a loading state).
  • onError fires only if the authorization URL could not be built - not for authorization failures, which arrive as error parameters on your callback page.
  • The button stores the PKCE code_verifier and state in sessionStorage automatically; the package exports helpers to retrieve them on your callback page.

Email Scope Caveat

email is not in the default scopes, and requesting it won't return one - Flowsta's server stores only a hash. If your app needs an email address, ask the user directly after sign-in. (New apps on dev.flowsta.com don't have the email scope enabled by default; enable it there before requesting it, or authorize calls fail with invalid_scope.)

Supported Frameworks

FrameworkImport PathStatus
React@flowsta/login-button/react✅ React 16.8+
Vue 3@flowsta/login-button/vue✅ Composition API
Qwik@flowsta/login-button/qwik✅ Resumable
Vanilla JS@flowsta/login-button/vanilla✅ Framework-agnostic

Button Variants

The widget comes with 6 pre-designed variants:

VariantDescriptionBest For
dark-pillDark background, pill shapeLight backgrounds
dark-rectangleDark background, rectangleLight backgrounds, formal UI
light-pillWhite background, pill shapeDark backgrounds
light-rectangleWhite background, rectangleDark backgrounds, formal UI
neutral-pillGray background, pill shapeAny background
neutral-rectangleGray background, rectangleAny background

Visual Preview

Button Variants

React

Basic Usage

tsx
import { FlowstaLoginButton } from '@flowsta/login-button/react';

function App() {
  return (
    <FlowstaLoginButton
      clientId="your_client_id"
      redirectUri="https://yourapp.com/auth/callback"
      scopes={['openid', 'display_name']}
      variant="dark-pill"
      onError={(error) => console.error('Could not start login:', error)}
    />
  );
}

Props

PropTypeRequiredDescription
clientIdstringYour Flowsta app's client ID
redirectUristringWhere the authorization code is delivered after login
scopesFlowstaScope[]Scopes array (default: ['openid', 'display_name'])
variantButtonVariantButton style (default: 'dark-pill')
onClick() => voidCalled when the button is clicked, just before the redirect
onError(error) => voidCalled if the authorization URL could not be built
loginUrlstringFlowsta login URL (default: 'https://login.flowsta.com')
classNamestringCustom CSS class
disabledbooleanDisable the button

Full Example

tsx
// Login page - renders the button. The result arrives at /auth/callback.
import { useState } from 'react';
import { FlowstaLoginButton } from '@flowsta/login-button/react';

function LoginPage() {
  const [error, setError] = useState<string | null>(null);

  return (
    <div className="login-page">
      <h1>Welcome to My App</h1>

      {error && (
        <div className="error-message">
          {error}
        </div>
      )}

      <FlowstaLoginButton
        clientId={import.meta.env.VITE_FLOWSTA_CLIENT_ID}
        redirectUri={`${window.location.origin}/auth/callback`}
        scopes={['openid', 'display_name']}
        variant="dark-pill"
        onClick={() => setError(null)}
        onError={(err) => setError(err.errorDescription || err.error)}
      />
    </div>
  );
}
tsx
// Callback page - route this at /auth/callback
import { useEffect, useState } from 'react';
import {
  handleCallback,
  validateState,
  retrieveCodeVerifier,
} from '@flowsta/login-button';

function AuthCallbackPage() {
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function completeSignIn() {
      const { code, state, error, errorDescription } = handleCallback();

      if (error) throw new Error(errorDescription || error);
      if (!code) throw new Error('No authorization code received');
      if (!state || !validateState(state)) throw new Error('State mismatch');

      const codeVerifier = retrieveCodeVerifier(state);
      if (!codeVerifier) throw new Error('Missing PKCE code verifier');

      // Exchange on your backend (recommended) or directly via /oauth/token
      const response = await fetch('/api/auth/exchange', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ code, code_verifier: codeVerifier }),
      });

      if (!response.ok) throw new Error('Authentication failed');

      window.location.href = '/dashboard';
    }

    completeSignIn().catch((err) => setError(err.message));
  }, []);

  if (error) return <div className="error-message">{error}</div>;
  return <div>Completing sign-in...</div>;
}

Vue 3

Basic Usage

vue
<script setup lang="ts">
import { FlowstaLoginButton } from '@flowsta/login-button/vue';

const handleError = (error: { error: string; errorDescription?: string }) => {
  console.error('Could not start login:', error);
};
</script>

<template>
  <FlowstaLoginButton
    client-id="your_client_id"
    redirect-uri="https://yourapp.com/auth/callback"
    :scopes="['openid', 'display_name']"
    variant="dark-pill"
    @error="handleError"
  />
</template>

Props

PropTypeRequiredDescription
client-idstringYour Flowsta app's client ID
redirect-uristringWhere the authorization code is delivered after login
:scopesFlowstaScope[]Scopes array (default: ['openid', 'display_name'])
variantstringButton style (default: 'dark-pill')
login-urlstringFlowsta login URL (default: 'https://login.flowsta.com')
class-namestringCustom CSS class
:disabledbooleanDisable the button

Events

EventPayloadDescription
@click-Emitted when the button is clicked, just before the redirect
@error{ error: string, errorDescription?: string }Emitted if the authorization URL could not be built

Full Example

vue
<!-- Login page - renders the button. The result arrives at /auth/callback. -->
<script setup lang="ts">
import { ref } from 'vue';
import { FlowstaLoginButton } from '@flowsta/login-button/vue';

const error = ref<string | null>(null);

const handleError = (err: { error: string; errorDescription?: string }) => {
  error.value = err.errorDescription || err.error;
};
</script>

<template>
  <div class="login-page">
    <h1>Welcome to My App</h1>

    <div v-if="error" class="error-message">
      {{ error }}
    </div>

    <FlowstaLoginButton
      client-id="your_client_id"
      redirect-uri="https://yourapp.com/auth/callback"
      :scopes="['openid', 'display_name']"
      variant="dark-pill"
      @click="error = null"
      @error="handleError"
    />
  </div>
</template>
vue
<!-- Callback page - route this at /auth/callback -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import {
  handleCallback,
  validateState,
  retrieveCodeVerifier,
} from '@flowsta/login-button';

const error = ref<string | null>(null);

onMounted(async () => {
  try {
    const { code, state, error: authError, errorDescription } = handleCallback();

    if (authError) throw new Error(errorDescription || authError);
    if (!code) throw new Error('No authorization code received');
    if (!state || !validateState(state)) throw new Error('State mismatch');

    const codeVerifier = retrieveCodeVerifier(state);
    if (!codeVerifier) throw new Error('Missing PKCE code verifier');

    const response = await fetch('/api/auth/exchange', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ code, code_verifier: codeVerifier }),
    });

    if (!response.ok) throw new Error('Authentication failed');

    window.location.href = '/dashboard';
  } catch (err) {
    error.value = err instanceof Error ? err.message : 'Unknown error';
  }
});
</script>

<template>
  <div v-if="error" class="error-message">{{ error }}</div>
  <div v-else>Completing sign-in...</div>
</template>

Qwik

Basic Usage

tsx
import { component$, $ } from '@builder.io/qwik';
import { FlowstaLoginButton } from '@flowsta/login-button/qwik';

export default component$(() => {
  const handleError = $((error: { error: string; errorDescription?: string }) => {
    console.error('Could not start login:', error);
  });

  return (
    <FlowstaLoginButton
      clientId="your_client_id"
      redirectUri="https://yourapp.com/auth/callback"
      scopes={['openid', 'display_name']}
      variant="dark-pill"
      onError$={handleError}
    />
  );
});

Props

PropTypeRequiredDescription
clientIdstringYour Flowsta app's client ID
redirectUristringWhere the authorization code is delivered after login
scopesFlowstaScope[]Scopes array (default: ['openid', 'display_name'])
variantButtonVariantButton style (default: 'dark-pill')
onClick$QRL<() => void>Click callback (QRL wrapped, fires just before the redirect)
onError$QRL<(error) => void>Called if the authorization URL could not be built (QRL wrapped)
loginUrlstringFlowsta login URL (default: 'https://login.flowsta.com')
classstringCustom CSS class
disabledbooleanDisable the button

Qwik QRLs

Qwik requires event handlers to be wrapped with $() for lazy loading. Use onClick$ and onError$ (with dollar sign).

Full Example

tsx
// Callback route - src/routes/auth/callback/index.tsx
import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik';
import {
  handleCallback,
  validateState,
  retrieveCodeVerifier,
} from '@flowsta/login-button';

export default component$(() => {
  const error = useSignal<string | null>(null);

  // eslint-disable-next-line qwik/no-use-visible-task
  useVisibleTask$(async () => {
    try {
      const { code, state, error: authError, errorDescription } = handleCallback();

      if (authError) throw new Error(errorDescription || authError);
      if (!code) throw new Error('No authorization code received');
      if (!state || !validateState(state)) throw new Error('State mismatch');

      const codeVerifier = retrieveCodeVerifier(state);
      if (!codeVerifier) throw new Error('Missing PKCE code verifier');

      const response = await fetch('/api/auth/exchange', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ code, code_verifier: codeVerifier }),
      });

      if (!response.ok) throw new Error('Authentication failed');

      window.location.href = '/dashboard';
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Unknown error';
    }
  });

  return (
    <div class="login-page">
      {error.value ? (
        <div class="error-message">{error.value}</div>
      ) : (
        <div>Completing sign-in...</div>
      )}
    </div>
  );
});

Vanilla JavaScript

Basic Usage

html
<!DOCTYPE html>
<html>
<head>
  <title>Sign in with Flowsta</title>
</head>
<body>
  <div id="login-button"></div>

  <script type="module">
    import { initFlowstaLoginButton } from '@flowsta/login-button/vanilla';

    initFlowstaLoginButton('#login-button', {
      clientId: 'your_client_id',
      redirectUri: 'https://yourapp.com/auth/callback.html',
      scopes: ['openid', 'display_name'],
      variant: 'dark-pill',

      onError: (error) => {
        console.error('Could not start login:', error);
      },
    });
  </script>
</body>
</html>

Options

OptionTypeRequiredDescription
clientIdstringYour Flowsta app's client ID
redirectUristringWhere the authorization code is delivered after login
scopesFlowstaScope[]Scopes array (default: ['openid', 'display_name'])
variantstringButton style (default: 'dark-pill')
onClickfunctionClick callback (fires just before the redirect)
onErrorfunctionCalled if the authorization URL could not be built
loginUrlstringFlowsta login URL (default: 'https://login.flowsta.com')
classNamestringCustom CSS class
disabledbooleanDisable the button

Functions

javascript
import { createFlowstaLoginButton, initFlowstaLoginButton } from '@flowsta/login-button/vanilla';

// Create and append to a container (recommended)
initFlowstaLoginButton('#login-button', options);
initFlowstaLoginButton(document.getElementById('login-button'), options);

// Create a button element (manual DOM management)
const button = createFlowstaLoginButton(options);
document.body.appendChild(button);

Full Example (Callback Page)

html
<!-- callback.html - served at your redirectUri -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Completing sign-in...</title>
</head>
<body>
  <div id="status">Completing sign-in...</div>
  <div id="error-message" style="display: none; color: #dc2626;"></div>

  <script type="module">
    import {
      handleCallback,
      validateState,
      retrieveCodeVerifier,
    } from '@flowsta/login-button';

    const errorDiv = document.getElementById('error-message');

    async function completeSignIn() {
      const { code, state, error, errorDescription } = handleCallback();

      if (error) throw new Error(errorDescription || error);
      if (!code) throw new Error('No authorization code received');
      if (!state || !validateState(state)) throw new Error('State mismatch');

      const codeVerifier = retrieveCodeVerifier(state);
      if (!codeVerifier) throw new Error('Missing PKCE code verifier');

      const response = await fetch('/api/auth/exchange', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ code, code_verifier: codeVerifier }),
      });

      if (!response.ok) throw new Error('Authentication failed');

      window.location.href = '/dashboard.html';
    }

    completeSignIn().catch((err) => {
      document.getElementById('status').style.display = 'none';
      errorDiv.textContent = `Sign-in failed: ${err.message}`;
      errorDiv.style.display = 'block';
    });
  </script>
</body>
</html>

Handling the Callback

Whatever framework you use, the pattern is the same:

  1. Create a page at your redirectUri. Flowsta delivers ?code=...&state=... there on a fresh page load.
  2. Parse and validate. handleCallback() reads the URL parameters; validateState(state) checks the CSRF state the button stored; retrieveCodeVerifier(state) returns the PKCE verifier (and clears it from sessionStorage).
  3. Exchange the code. POST code + code_verifier to https://auth-api.flowsta.com/oauth/token - from your backend (recommended) or directly from the browser. See the API Reference.

All three helpers are exported from the package root:

javascript
import {
  handleCallback,      // parse code/state/error from the callback URL
  validateState,       // verify the state matches what the button stored
  retrieveCodeVerifier // fetch (and clear) the stored PKCE verifier
} from '@flowsta/login-button';

Prefer a Full SDK?

The @flowsta/auth package wraps the whole flow - auth.login() to start (or this button widget), and auth.handleCallback() on the callback page to exchange the code and fetch the user in one call. From v2.3.1, handleCallback() completes button-initiated logins too.

Advanced Usage

PKCE and State (Automatic)

The button widget automatically handles PKCE and CSRF state for you:

  • Generates a random code_verifier and code_challenge per login
  • Generates a random state parameter
  • Stores both in sessionStorage (the verifier is keyed by state) so your callback page can retrieve them after the redirect

Dynamic Redirect URI

Use environment variables for different environments:

javascript
const redirectUri = import.meta.env.DEV
  ? 'http://localhost:3000/auth/callback'
  : 'https://yourapp.com/auth/callback';

<FlowstaLoginButton
  clientId="your_client_id"
  redirectUri={redirectUri}
  scopes={['openid', 'display_name']}
/>

Programmatic Login

For programmatic login without a button, use the @flowsta/auth SDK instead:

javascript
import { FlowstaAuth } from '@flowsta/auth';

const auth = new FlowstaAuth({
  clientId: 'your_client_id',
  redirectUri: 'https://yourapp.com/auth/callback',
  scopes: ['openid', 'display_name'],
});

// Redirect to Flowsta login
auth.login();

// ...and on your callback page:
const user = await auth.handleCallback();

Styling

The button widget uses inline styles and doesn't require external CSS. However, you can wrap it in a container for additional styling:

css
.login-button-container {
  display: flex;
  justify-content: center;
  margin: 40px 0;
}

.login-button-container button {
  cursor: pointer;
  transition: transform 0.2s;
}

.login-button-container button:hover {
  transform: translateY(-2px);
}

TypeScript Support

The package includes full TypeScript definitions:

typescript
import { FlowstaLoginButton, ButtonVariant } from '@flowsta/login-button/react';

interface Props {
  variant?: ButtonVariant; // 'dark-pill' | 'dark-rectangle' | etc.
}

function CustomLogin({ variant = 'dark-pill' }: Props) {
  return (
    <FlowstaLoginButton
      clientId="your_client_id"
      redirectUri="https://yourapp.com/auth/callback"
      scopes={['openid', 'display_name']}
      variant={variant}
    />
  );
}

Browser Support

  • ✅ Chrome/Edge 90+
  • ✅ Firefox 88+
  • ✅ Safari 14+
  • ✅ Opera 76+
  • ❌ IE 11 (not supported)

Bundle Size

PackageMinifiedGzipped
React~8 KB~3 KB
Vue~7 KB~2.5 KB
Qwik~6 KB~2 KB
Vanilla JS~5 KB~2 KB

Next Steps

Need Help?

Documentation licensed under CC BY-SA 4.0.