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

email is not in the default scopes. Requesting it asks the user to share their address at the consent screen: on a computer their Flowsta Vault shows its own dialog and nothing is typed; on phones and browsers that cannot reach a Vault they type it and Flowsta checks it against the hash it holds. If they share, /oauth/userinfo returns email and email_verified (verified addresses only); if they decline or later revoke your app, there is no email. Design your app to work without one.

New apps created on dev.flowsta.com start with openid and display_name only - tick email in the Permissions step (or under Edit Settings) before requesting it, or /oauth/authorize answers invalid_scope.

Supported Frameworks ​

FrameworkImport PathStatus
React@flowsta/login-button/react✅ React 18 or 19
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 ​

dark-pilldark-rectangleneutral-pillneutral-rectangle
light-pilllight-rectangle

Top row: dark-pill, dark-rectangle, neutral-pill, neutral-rectangle. Bottom row (on a dark background): light-pill, light-rectangle. Downloadable SVG and PNG files are on the Button Downloads page.

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
clientIdstring✅Your Flowsta app's client ID
redirectUristring✅Where the authorization code is delivered after login
scopesFlowstaScope[]❌Scopes array (default: ['openid', 'display_name'])
variantButtonVariant❌Button style (default: 'dark-pill')
onClick() => void❌Called when the button is clicked, just before the redirect
onError(error) => void❌Called if the authorization URL could not be built
loginUrlstring❌Flowsta login URL (default: 'https://login.flowsta.com')
classNamestring❌Custom CSS class
disabledboolean❌Disable 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-idstring✅Your Flowsta app's client ID
redirect-uristring✅Where the authorization code is delivered after login
:scopesFlowstaScope[]❌Scopes array (default: ['openid', 'display_name'])
variantstring❌Button style (default: 'dark-pill')
login-urlstring❌Flowsta login URL (default: 'https://login.flowsta.com')
class-namestring❌Custom CSS class
:disabledboolean❌Disable 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
clientIdstring✅Your Flowsta app's client ID
redirectUristring✅Where the authorization code is delivered after login
scopesFlowstaScope[]❌Scopes array (default: ['openid', 'display_name'])
variantButtonVariant❌Button 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)
loginUrlstring❌Flowsta login URL (default: 'https://login.flowsta.com')
classstring❌Custom CSS class
disabledboolean❌Disable 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
clientIdstring✅Your Flowsta app's client ID
redirectUristring✅Where the authorization code is delivered after login
scopesFlowstaScope[]❌Scopes array (default: ['openid', 'display_name'])
variantstring❌Button style (default: 'dark-pill')
onClickfunction❌Click callback (fires just before the redirect)
onErrorfunction❌Called if the authorization URL could not be built
loginUrlstring❌Flowsta login URL (default: 'https://login.flowsta.com')
classNamestring❌Custom CSS class
disabledboolean❌Disable 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. Both helpers clear what they read from sessionStorage, so call each once per callback.
  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 ​

Current evergreen browsers. The button needs the Web Crypto API (crypto.subtle, for the PKCE challenge) and sessionStorage, both of which every maintained desktop and mobile browser provides. Internet Explorer is not supported.

Bundle Size ​

Measured on the published dist/ of 0.1.6. Every entry imports one shared chunk that inlines the six button images as data URIs (77.9 KB minified / 6.9 KB gzipped); the framework entry itself adds 1-3 KB.

EntryMinifiedGzipped
React~81 KB~8.3 KB
Vue~80 KB~8.1 KB
Qwik~80 KB~7.9 KB
Vanilla JS~80 KB~8.1 KB

Framework runtimes (React, Vue, Qwik) are peer dependencies and not included.

Next Steps ​

Need Help? ​

Documentation licensed under CC BY-SA 4.0.