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
9 changes: 5 additions & 4 deletions apps/blocks/e2e-live/constructive-graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const SIGN_IN = /* GraphQL */ `
const SIGN_UP = /* GraphQL */ `
mutation ConsoleKitProofSignUp($input: SignUpInput!) {
signUp(input: $input) {
result { id userId accessToken accessTokenExpiresAt }
clientMutationId
}
}
`;
Expand Down Expand Up @@ -180,12 +180,13 @@ export async function signUp(
tenant: ProofTenant,
credentials: ProofCredentials
): Promise<LiveSession> {
const data = await graphQL<Record<string, unknown>>(
await graphQL<Record<string, unknown>>(
endpointUrl(tenant, 'auth'),
SIGN_UP,
{ input: { email: credentials.email, password: credentials.password, rememberMe: false } }
{ input: { email: credentials.email, password: credentials.password } }
);
return sessionFrom(data, 'signUp');
// sign_up never signs anyone in; the password just set does
return signIn(tenant, credentials);
}

export async function signOut(tenant: ProofTenant, token: string): Promise<void> {
Expand Down
15 changes: 4 additions & 11 deletions apps/blocks/src/blocks/console-runtime/standalone-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,7 @@ describe('database-scoped standalone session', () => {

it('requests a distinct CSRF bootstrap for sign-up', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(response({
data: {
signUp: {
result: {
id: 'session-1',
userId: 'user-1',
accessToken: 'secret-token',
accessTokenExpiresAt: '2030-01-01T00:00:00.000Z'
}
}
}
data: { signUp: { clientMutationId: null } }
}));
const csrfTokenProvider = vi.fn().mockResolvedValue('signup-csrf-token');
const session = createDatabaseScopedStandaloneSession({
Expand All @@ -160,7 +151,9 @@ describe('database-scoped standalone session', () => {
storage: { session: memoryStorage(), local: memoryStorage() }
});

await session.signUp({ email: 'person@example.com', password: 'password' });
await expect(session.signUp({ email: 'person@example.com', password: 'password' }))
.resolves.toEqual({ status: 'check-email' });
expect(session.getSnapshot().status).toBe('anonymous');

expect(csrfTokenProvider).toHaveBeenCalledWith(expect.objectContaining({
operation: 'signUp'
Expand Down
24 changes: 14 additions & 10 deletions apps/blocks/src/blocks/console-runtime/standalone-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,7 @@ const SIGN_IN_MUTATION = /* GraphQL */ `
const SIGN_UP_MUTATION = /* GraphQL */ `
mutation ConsoleSignUp($input: SignUpInput!) {
signUp(input: $input) {
result {
id
userId
accessToken
accessTokenExpiresAt
}
clientMutationId
}
}
`;
Expand Down Expand Up @@ -70,6 +65,10 @@ export type ConsoleAuthenticationOutcome =
| Readonly<{
status: 'mfa-required';
challengeToken: string;
}>
| Readonly<{
/** sign_up never signs anyone in; the tenant mails a sign-in link. */
status: 'check-email';
}>;

export type ConsoleAuthenticationOperation = 'signIn' | 'signUp';
Expand Down Expand Up @@ -577,8 +576,9 @@ export function createDatabaseScopedStandaloneSession(
input: {
email: credentials.email.trim(),
password: credentials.password,
rememberMe: credentials.rememberMe === true,
credentialKind: 'bearer',
...(field === 'signIn'
? { rememberMe: credentials.rememberMe === true, credentialKind: 'bearer' }
: {}),
...(csrfToken ? { csrfToken } : {})
}
},
Expand All @@ -593,8 +593,12 @@ export function createDatabaseScopedStandaloneSession(
) {
throw supersededAuthenticationError();
}
const result = mutationRecord(payload, field);
if (field === 'signIn' && result?.mfaRequired === true) {
if (field === 'signUp') {
setAnonymous();
return { status: 'check-email' };
}
const result = mutationRecord(payload, 'signIn');
if (result?.mfaRequired === true) {
const challengeToken = nonEmptyString(result.mfaChallengeToken);
if (!challengeToken) {
throw new ConsoleSessionOperationError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export function AuthEntryPanel({
password,
rememberMe
});
onAuthenticated?.();
setFeedback('Check your email for a link to confirm your address and sign in.');
} else if (mode === 'recover-password') {
if (!actions?.recoverPassword) return;
await actions.recoverPassword({ email: email.trim() });
Expand Down
3 changes: 1 addition & 2 deletions packages/sheets/src/auth/auth-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,13 @@ export interface SignInMutationResult {
export interface SignUpInput {
email: string;
password: string;
rememberMe?: boolean;
}

export type SignUpMutationVariables = MutationVariables<SignUpInput>;

export interface SignUpMutationResult {
signUp?: {
result?: AuthResultRecord | null;
clientMutationId?: string | null;
} | null;
}

Expand Down
28 changes: 6 additions & 22 deletions packages/sheets/src/auth/hooks/use-register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { RegisterFormData } from '../utils/schemas';
import { useSheetsContext } from '../../context/sheets-context';
import { useSheetsStoreApi } from '../../store/sheets-store';
import { setStoredToken } from '../utils/token-store';
import { executeAuthMutation } from '../auth-execute';
import { toAuthToken, type SignUpMutationResult, type SignUpMutationVariables } from '../auth-types';
import { type SignUpMutationResult, type SignUpMutationVariables } from '../auth-types';

const SIGN_UP_MUTATION = /* GraphQL */ `
mutation SignUp($input: SignUpInput!) {
signUp(input: $input) {
result {
id
userId
accessToken
accessTokenExpiresAt
isVerified
totpEnabled
}
clientMutationId
}
}
`;
Expand All @@ -30,29 +22,21 @@ export function useSheetsRegister() {
return useMutation({
mutationKey: ['sheets', 'auth', 'signUp'],
mutationFn: async (formData: RegisterFormData) => {
const result = await executeAuthMutation<SignUpMutationResult>(
// sign_up never signs anyone in: the address is mailed a link that does
await executeAuthMutation<SignUpMutationResult>(
config,
SIGN_UP_MUTATION,
{
input: {
email: formData.email,
password: formData.password,
rememberMe: formData.rememberMe ?? true,
},
} satisfies SignUpMutationVariables,
);

const signUpResult = result.signUp?.result;
const token = signUpResult ? toAuthToken(signUpResult) : null;

return { token, email: formData.email, identityKey: signUpResult?.userId ?? null };
return { email: formData.email };
},
onSuccess: ({ token, identityKey }) => {
if (token) {
const databaseId = config.databaseId || 'default';
setStoredToken(databaseId, token.accessToken, token.expiresAt, identityKey);
storeApi.getState().setAuthenticated(token.accessToken, token.expiresAt, identityKey);
}
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sheets'] });
},
onError: () => {
Expand Down
42 changes: 21 additions & 21 deletions packages/sheets/src/auth/ui/register-form.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useRef, useState } from 'react';
import { useForm } from '@tanstack/react-form';
import { CheckCircleIcon } from 'lucide-react';

import { parseGraphQLError } from '../utils/auth-errors';
import type { RegisterFormData } from '../utils/schemas';
import { registerSchema } from '../utils/schemas';
import { Button } from '@constructive-io/ui/button';
import { Checkbox } from '@constructive-io/ui/checkbox';

import { AuthErrorAlert } from './auth-error-alert';
import { AuthLoadingButton } from './auth-loading-button';
Expand All @@ -20,14 +20,14 @@ export interface SheetsRegisterFormProps {
export function SheetsRegisterForm({ onRegister, onShowLogin, defaultEmail = '' }: SheetsRegisterFormProps) {
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const passwordInputRef = useRef<HTMLInputElement>(null);

const form = useForm({
defaultValues: {
email: defaultEmail,
password: '',
confirmPassword: '',
rememberMe: true,
},
onSubmit: async ({ value }) => {
setError(null);
Expand All @@ -36,6 +36,7 @@ export function SheetsRegisterForm({ onRegister, onShowLogin, defaultEmail = ''
try {
const validatedData = registerSchema.parse(value);
await onRegister(validatedData);
setIsSuccess(true);
} catch (err) {
setError(parseGraphQLError(err).message);
} finally {
Expand All @@ -44,6 +45,24 @@ export function SheetsRegisterForm({ onRegister, onShowLogin, defaultEmail = ''
},
});

if (isSuccess) {
return (
<div className='space-y-5 text-center'>
<div className='flex justify-center'>
<div className='bg-success/10 flex h-12 w-12 items-center justify-center rounded-full'>
<CheckCircleIcon className='text-success h-6 w-6' />
</div>
</div>
<div className='space-y-1.5'>
<h3 className='text-lg font-semibold'>Check your email</h3>
<p className='text-muted-foreground text-sm'>
We&apos;ve sent a link to confirm your address and sign in.
</p>
</div>
</div>
);
}

return (
<div className='space-y-5'>
<form
Expand Down Expand Up @@ -89,25 +108,6 @@ export function SheetsRegisterForm({ onRegister, onShowLogin, defaultEmail = ''
)}
</form.Field>

<form.Field name='rememberMe'>
{(field) => (
<div className='flex items-center space-x-2'>
<Checkbox
id='rememberMe'
checked={field.state.value}
onCheckedChange={(checked) => {
field.handleChange(checked === true);
}}
/>
<label
htmlFor='rememberMe'
className='text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
>
Remember me
</label>
</div>
)}
</form.Field>

<div className='pt-2'>
<AuthLoadingButton
Expand Down
1 change: 0 additions & 1 deletion packages/sheets/src/auth/utils/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export const registerSchema = z
email: emailSchema,
password: z.string().min(1, 'Password is required'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
rememberMe: z.boolean().optional(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
Expand Down
Loading