Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/Exceptionless.Web/Api/Handlers/AuthHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ private async Task<Result<TokenResult>> ExternalLoginAsync(ExternalAuthInfo auth
User? user;
try
{
user = await FromExternalLoginAsync(userInfo, httpContext);
user = await FromExternalLoginAsync(userInfo, authInfo.InviteToken, httpContext);
}
catch (ApplicationException ex)
{
Expand All @@ -554,7 +554,7 @@ private async Task<Result<TokenResult>> ExternalLoginAsync(ExternalAuthInfo auth
return new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) };
}

private async Task<User> FromExternalLoginAsync(UserInfo userInfo, HttpContext httpContext)
private async Task<User> FromExternalLoginAsync(UserInfo userInfo, string? inviteToken, HttpContext httpContext)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.Id);
ArgumentException.ThrowIfNullOrWhiteSpace(userInfo.ProviderName);
Expand All @@ -563,7 +563,7 @@ private async Task<User> FromExternalLoginAsync(UserInfo userInfo, HttpContext h
var existingUser = await userRepository.GetUserByOAuthProviderAsync(userInfo.ProviderName, userInfo.Id);
using var _ = logger.BeginScope(new ExceptionlessState().Tag("External Login").Property("User Info", userInfo).Property("ExistingUser", existingUser).SetHttpContext(httpContext));

if (httpContext.User.IsUserAuthType())
if (String.IsNullOrWhiteSpace(inviteToken) && httpContext.User.IsUserAuthType())
{
var currentUser = httpContext.Request.GetUser();
if (existingUser is not null)
Expand Down Expand Up @@ -599,7 +599,7 @@ private async Task<User> FromExternalLoginAsync(UserInfo userInfo, HttpContext h
var user = !String.IsNullOrEmpty(userInfo.Email) ? await userRepository.GetByEmailAddressAsync(userInfo.Email) : null;
if (user is null)
{
if (!authOptions.EnableAccountCreation)
if (!await IsAccountCreationEnabledAsync(inviteToken))
throw new ApplicationException("Account Creation is currently disabled.");

user = new User { FullName = userInfo.GetFullName()!, EmailAddress = userInfo.Email };
Expand Down
8 changes: 8 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ export class E2EApiClient {
return toToken(await readJson(response));
}

async inviteOrganizationUser(token: string, organizationId: string, email: string): Promise<void> {
const response = await this.request.post(this.url(`organizations/${organizationId}/users/${encodeURIComponent(email)}`), {
headers: this.authHeaders(token)
});

await expectStatus(response, [200], 'invite organization user');
}

async login(email = this.environment.email, password = this.environment.password): Promise<string> {
const token = await this.loginIfExists(email, password);
if (!token) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ test('invited user can accept an organization invitation @signup', async ({ brow

try {
await invitedPage.goto(`/next/signup?token=${encodeURIComponent(inviteToken)}`);
await expect(invitedPage.getByRole('link', { name: 'Log In' })).toHaveAttribute('href', `/next/login?token=${encodeURIComponent(inviteToken)}`);
await invitedPage.getByLabel('Name', { exact: true }).fill(`Invited User ${e2eScenario.run}`);
await invitedPage.getByLabel('Email', { exact: true }).fill(invitedEmail);
await waitForEmailValidation(invitedPage);
Expand Down Expand Up @@ -81,3 +82,72 @@ test('invited user can accept an organization invitation @signup', async ({ brow
throwIfCleanupFailed(cleanupErrors);
}
});

test('existing invited user can accept an organization invitation when logging in @signup', async ({ browser, e2eApi, e2eScenario }) => {
const invitedEmail = `existing-invited-${e2eScenario.run}@exceptionless.test`.toLowerCase();
let invitedUserToken: string | undefined;

try {
await test.step('create an invitation for a new address', async () => {
await e2eApi.inviteOrganizationUser(e2eScenario.userToken, e2eScenario.organizationId, invitedEmail);
});

const inviteToken = await e2eApi.pollForMailToken(invitedEmail, 'signup');
const existingUserToken = await e2eApi.signup(`Existing Invited User ${e2eScenario.run}`, invitedEmail, E2E_TEST_PASSWORD);
invitedUserToken = existingUserToken;
const invitedContext = await browser.newContext({ baseURL: e2eApi.environment.appUrl, ignoreHTTPSErrors: true });
await invitedContext.addInitScript((token) => window.localStorage.setItem('satellizer_token', token), existingUserToken);
const invitedPage = await invitedContext.newPage();

try {
const logoutResponse = invitedPage.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === 'GET' && url.pathname.endsWith('/api/v2/auth/logout');
});

await invitedPage.goto(`/next/login?token=${encodeURIComponent(inviteToken)}`);
expect((await logoutResponse).ok()).toBe(true);
await expect(invitedPage.getByRole('link', { name: 'Start a free trial' })).toHaveAttribute(
'href',
`/next/signup?token=${encodeURIComponent(inviteToken)}`
);
await invitedPage.getByLabel('Email', { exact: true }).fill(invitedEmail);
await invitedPage.getByPlaceholder('Enter password').fill(E2E_TEST_PASSWORD);

const loginResponse = invitedPage.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === 'POST' && url.pathname.endsWith('/api/v2/auth/login');
});

await invitedPage.getByRole('button', { exact: true, name: 'Login' }).click();
const response = await loginResponse;
const requestBody = response.request().postDataJSON() as { invite_token?: string };
expect(requestBody.invite_token).toBe(inviteToken);
expect(response.ok()).toBe(true);

invitedUserToken = await getUserToken(invitedPage);
await e2eApi.waitForOrganizationListed(invitedUserToken, e2eScenario.organizationId, 60_000);
await expect(invitedPage.getByRole('button').filter({ hasText: e2eScenario.organizationName }).filter({ visible: true }).first()).toBeVisible();
} finally {
await invitedContext.close();
}
} finally {
const cleanupErrors: Error[] = [];

if (invitedUserToken) {
const token = invitedUserToken;

await runCleanupStep(cleanupErrors, 'remove existing invited user from organization', async () => {
await e2eApi.deleteOrganizationUser(e2eScenario.userToken, e2eScenario.organizationId, invitedEmail);
await e2eApi.waitForOrganizationNotListed(token, e2eScenario.organizationId);
});

await runCleanupStep(cleanupErrors, 'delete existing invited user', async () => {
await e2eApi.deleteCurrentUser(token);
await e2eApi.waitForCurrentUserDeleted(token);
});
}

throwIfCleanupFailed(cleanupErrors);
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ export async function isEmailAddressTaken(email: string) {
return response.status === 201;
}

export async function login(email: string, password: string) {
export async function login(email: string, password: string, inviteToken?: null | string) {
const data: Login = {
email,
invite_token: inviteToken,
password
};
const client = useFetchClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export { accessToken } from './state.svelte';
export { validateEmailAvailability } from './validators';

export interface OAuthLoginOptions extends OAuthPopupOptions {
inviteToken?: null | string;
redirectUrl?: string;
}

Expand Down Expand Up @@ -56,28 +57,30 @@ export const enableOAuthLogin = facebookClientId || gitHubClientId || googleClie

// OAuth login functions (interactive popup-based, not pure API calls)

export async function facebookLogin(redirectUrl?: string) {
export async function facebookLogin(redirectUrl?: string, inviteToken?: null | string) {
if (!facebookClientId) {
throw new Error('Facebook client id not set');
}

await oauthLogin({
authUrl: 'https://www.facebook.com/v2.5/dialog/oauth',
clientId: facebookClientId,
inviteToken,
provider: 'facebook',
redirectUrl,
scope: 'email'
});
}

export async function githubLogin(redirectUrl?: string) {
export async function githubLogin(redirectUrl?: string, inviteToken?: null | string) {
if (!gitHubClientId) {
throw new Error('GitHub client id not set');
}

await oauthLogin({
authUrl: 'https://github.com/login/oauth/authorize',
clientId: gitHubClientId,
inviteToken,
popupOptions: {
height: 618,
width: 1020
Expand All @@ -88,7 +91,7 @@ export async function githubLogin(redirectUrl?: string) {
});
}

export async function googleLogin(redirectUrl?: string) {
export async function googleLogin(redirectUrl?: string, inviteToken?: null | string) {
if (!googleClientId) {
throw new Error('Google client id not set');
}
Expand All @@ -103,6 +106,7 @@ export async function googleLogin(redirectUrl?: string) {
service: 'lso',
state: encodeURIComponent(Math.random().toString(36).substring(2))
},
inviteToken,
provider: 'google',
redirectUrl,
scope: 'openid profile email'
Expand All @@ -118,7 +122,7 @@ export async function gotoLogin() {
});
}

export async function liveLogin(redirectUrl?: string) {
export async function liveLogin(redirectUrl?: string, inviteToken?: null | string) {
if (!microsoftClientId) {
throw new Error('Live client id not set');
}
Expand All @@ -129,6 +133,7 @@ export async function liveLogin(redirectUrl?: string) {
extraParams: {
display: 'popup'
},
inviteToken,
provider: 'live',
redirectUrl,
scope: 'wl.emails'
Expand Down Expand Up @@ -166,6 +171,7 @@ async function oauthLogin(options: OAuthLoginOptions) {
const response = await client.postJSON<TokenResult>(`auth/${options.provider}`, {
clientId: options.clientId,
code: data.code,
inviteToken: options.inviteToken,
Comment thread
niemyjski marked this conversation as resolved.
redirectUri: window.location.origin,
state: data.state
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { Spinner } from '$comp/ui/spinner';
import { login } from '$features/auth/api.svelte';
import {
accessToken,
enableAccountCreation,
enableOAuthLogin,
facebookClientId,
Expand All @@ -26,26 +27,41 @@
googleClientId,
googleLogin,
liveLogin,
logout,
microsoftClientId
} from '$features/auth/index.svelte';
import { type LoginFormData, LoginSchema } from '$features/auth/schemas';
import { getSafeRedirectUrl } from '$features/shared/url';
import { ariaInvalid, getFormErrorMessages, mapFieldErrors, problemDetailsToFormErrors } from '$shared/validation';
import { createForm } from '@tanstack/svelte-form';
import { onMount } from 'svelte';

const defaultRedirect = resolve('/');
const redirectUrl = getSafeRedirectUrl(page.url.searchParams.get('redirect'), defaultRedirect);
const inviteToken = page.url.searchParams.get('token');
const canSignup = enableAccountCreation || !!inviteToken;
const signupUrl = inviteToken ? `${resolve('/(auth)/signup')}?token=${encodeURIComponent(inviteToken)}` : resolve('/(auth)/signup');

onMount(async () => {
if (accessToken.current) {
try {
await logout();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release the auth form when logout rejects

When a user opens this page with a stored access token and the logout request rejects because the API is unavailable, times out, or returns an unexpected error, this catch leaves accessToken.current unchanged. The container therefore remains inert={!!accessToken.current}, making every login control unusable despite the comment saying the flow can continue; the signup page has the same failure mode. Clear the local session on failure or track logout progress separately from the access token.

Useful? React with 👍 / 👎.

} catch {
// The login flow can continue when an existing session cannot be ended.
}
}
});

const form = createForm(() => ({
defaultValues: {
email: '',
invite_token: page.url.searchParams.get('token'),
invite_token: inviteToken,
password: ''
} as LoginFormData,
validators: {
onSubmit: LoginSchema,
onSubmitAsync: async ({ value }) => {
const response = await login(value.email, value.password);
const response = await login(value.email, value.password, value.invite_token);
Comment thread
niemyjski marked this conversation as resolved.
Comment thread
niemyjski marked this conversation as resolved.
if (response.ok) {
await goto(redirectUrl);
return null;
Expand All @@ -62,7 +78,7 @@
}
</script>

<div class="mx-auto flex w-[calc(100vw-2rem)] max-w-lg flex-col items-center">
<div class="mx-auto flex w-[calc(100vw-2rem)] max-w-lg flex-col items-center" inert={!!accessToken.current}>
<Card.Root class="w-full">
<Card.Header>
<Logo />
Expand Down Expand Up @@ -137,16 +153,16 @@
</form.Field>
<form.Subscribe selector={(state) => state.isSubmitting}>
{#snippet children(isSubmitting)}
<div class={enableAccountCreation ? 'mt-4 grid grid-cols-2 gap-3' : 'mt-4'}>
<div class={canSignup ? 'mt-4 grid grid-cols-2 gap-3' : 'mt-4'}>
<Button type="submit" class="w-full" tabindex={3} disabled={isSubmitting}>
{#if isSubmitting}
<Spinner /> Logging in...
{:else}
Login
{/if}
</Button>
{#if enableAccountCreation}
<Button variant="secondary" href={resolve('/(auth)/signup')} class="w-full" tabindex={4}>Signup</Button>
{#if canSignup}
<Button variant="secondary" href={signupUrl} class="w-full" tabindex={4}>Signup</Button>
{/if}
</div>
{/snippet}
Expand All @@ -161,38 +177,38 @@
</div>
<div class="grid grid-flow-col grid-cols-2 grid-rows-2 gap-4">
{#if microsoftClientId}
<Button aria-label="Login with Microsoft" tabindex={4} onclick={() => liveLogin(redirectUrl)}>
<Button aria-label="Login with Microsoft" tabindex={4} onclick={() => liveLogin(redirectUrl, inviteToken)}>
<MicrosoftIcon class="size-4" /> Microsoft
</Button>
{/if}
{#if googleClientId}
<Button aria-label="Login with Google" tabindex={4} onclick={() => googleLogin(redirectUrl)}>
<Button aria-label="Login with Google" tabindex={4} onclick={() => googleLogin(redirectUrl, inviteToken)}>
<GoogleIcon class="size-4" /> Google
</Button>
{/if}
{#if facebookClientId}
<Button aria-label="Login with Facebook" tabindex={4} onclick={() => facebookLogin(redirectUrl)}>
<Button aria-label="Login with Facebook" tabindex={4} onclick={() => facebookLogin(redirectUrl, inviteToken)}>
<FacebookIcon class="size-4" /> Facebook
</Button>
{/if}
{#if gitHubClientId}
<Button aria-label="Login with GitHub" tabindex={4} onclick={() => githubLogin(redirectUrl)}>
<Button aria-label="Login with GitHub" tabindex={4} onclick={() => githubLogin(redirectUrl, inviteToken)}>
<GitHubIcon class="size-4" /> GitHub
</Button>
{/if}
</div>
{/if}

{#if enableAccountCreation}
{#if canSignup}
<P class="text-center text-sm">
Not a member?
<A href={resolve('/(auth)/signup')} tabindex={5}>Start a free trial</A>
<A href={signupUrl} tabindex={5}>Start a free trial</A>
</P>
{/if}
</Card.Content>
</Card.Root>

{#if enableAccountCreation}
{#if canSignup}
<P class="text-muted-foreground mt-3! px-4 text-center text-sm">
By signing up, you agree to our <A href="https://exceptionless.com/privacy" target="_blank">Privacy Policy</A>
and
Expand Down
Loading
Loading