Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
18 changes: 18 additions & 0 deletions app/(auth)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Stack } from 'expo-router';
import { colors } from '../../constants/theme';

export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerTintColor: colors.text,
headerStyle: { backgroundColor: colors.background },
headerShadowVisible: false,
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="login" options={{ title: 'Login' }} />
<Stack.Screen name="register" options={{ title: 'Register' }} />
</Stack>
);
}
85 changes: 85 additions & 0 deletions app/(auth)/api/login+api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import { createSessionInsecure } from '../../../database/sessions';
import { getUserWithPasswordHashInsecure } from '../../../database/users';
import { ExpoApiResponse } from '../../../ExpoApiResponse';
import {
type User,
userSchemaLogin,
} from '../../../migrations/00002-createTableUsers';
import { createSerializedSessionTokenCookie } from '../../../util/cookies';
import { getCombinedErrorMessage } from '../../../util/validation';

export type LoginResponseBodyPost =
| {
user: User;
}
| {
error: string;
};

export async function POST(
request: Request,
): Promise<ExpoApiResponse<LoginResponseBodyPost>> {
const requestBody = await request.json();
const result = userSchemaLogin.safeParse(requestBody);

if (!result.success) {
return ExpoApiResponse.json(
{ error: getCombinedErrorMessage(result.error.issues) },
{ status: 400 },
);
}

const userWithPasswordHash = await getUserWithPasswordHashInsecure(
result.data.user.username,
);

if (!userWithPasswordHash) {
return ExpoApiResponse.json(
{ error: 'Username or password invalid' },
{ status: 401 },
);
}

const isPasswordValid = await bcrypt.compare(
result.data.user.password,
userWithPasswordHash.passwordHash,
);

userWithPasswordHash.passwordHash = '';

Comment on lines +50 to +51
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

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

Because getUserWithPasswordHashInsecure is wrapped in react cache(), mutating the returned object (userWithPasswordHash.passwordHash = '') can have surprising effects if the cached value is reused within the same request. Prefer not mutating cached DB results; instead, avoid returning the password hash from this function (eg map/alias in SQL) or create a separate object for the response without modifying the fetched record.

Suggested change
userWithPasswordHash.passwordHash = '';

Copilot uses AI. Check for mistakes.
if (!isPasswordValid) {
return ExpoApiResponse.json(
{ error: 'Username or password invalid' },
{ status: 401 },
);
}

const sessionToken = crypto.randomBytes(100).toString('base64');
const session = await createSessionInsecure(
sessionToken,
userWithPasswordHash.id,
);

if (!session) {
return ExpoApiResponse.json(
{ error: 'Session creation failed' },
{ status: 500 },
);
}

return ExpoApiResponse.json(
{
user: {
id: userWithPasswordHash.id,
username: userWithPasswordHash.username,
},
},
{
headers: {
'Set-Cookie': createSerializedSessionTokenCookie(session.token),
},
},
);
}
37 changes: 37 additions & 0 deletions app/(auth)/api/logout+api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { parse } from 'cookie';
import { deleteSession } from '../../../database/sessions';
import { ExpoApiResponse } from '../../../ExpoApiResponse';
import { deleteSerializedSessionTokenCookie } from '../../../util/cookies';

export type LogoutResponseBodyPost =
| {
success: true;
}
| {
error: string;
};

export async function POST(
request: Request,
): Promise<ExpoApiResponse<LogoutResponseBodyPost>> {
const cookies = parse(request.headers.get('cookie') || '');
const sessionToken = cookies.sessionToken;

if (!sessionToken) {
return ExpoApiResponse.json(
{ error: 'Authentication required' },
{ status: 401 },
);
}

await deleteSession(sessionToken);

return ExpoApiResponse.json(
{ success: true },
{
headers: {
'Set-Cookie': deleteSerializedSessionTokenCookie(),
},
},
);
}
72 changes: 72 additions & 0 deletions app/(auth)/api/register+api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import { createSessionInsecure } from '../../../database/sessions';
import {
createUserInsecure,
getUserInsecure,
} from '../../../database/users';
import { ExpoApiResponse } from '../../../ExpoApiResponse';
import {
type User,
userSchemaRegister,
} from '../../../migrations/00002-createTableUsers';
import { createSerializedSessionTokenCookie } from '../../../util/cookies';
import { getCombinedErrorMessage } from '../../../util/validation';

export type RegisterResponseBodyPost =
| {
user: User;
}
| {
error: string;
};

export async function POST(
request: Request,
): Promise<ExpoApiResponse<RegisterResponseBodyPost>> {
const requestBody = await request.json();
const result = userSchemaRegister.safeParse(requestBody);

if (!result.success) {
return ExpoApiResponse.json(
{ error: getCombinedErrorMessage(result.error.issues) },
{ status: 400 },
);
}

if (await getUserInsecure(result.data.user.username)) {
return ExpoApiResponse.json(
{ error: 'Username already exists' },
{ status: 400 },
);
}

const passwordHash = await bcrypt.hash(result.data.user.password, 12);
const user = await createUserInsecure(result.data.user.username, passwordHash);

if (!user) {
return ExpoApiResponse.json(
{ error: 'Creating user failed' },
{ status: 500 },
);
}

const sessionToken = crypto.randomBytes(100).toString('base64');
const session = await createSessionInsecure(sessionToken, user.id);

if (!session) {
return ExpoApiResponse.json(
{ error: 'Session creation failed' },
{ status: 500 },
);
}

return ExpoApiResponse.json(
{ user },
{
headers: {
'Set-Cookie': createSerializedSessionTokenCookie(session.token),
},
},
);
}
Loading
Loading