Skip to content
This repository has been archived by the owner on Nov 27, 2024. It is now read-only.

Commit

Permalink
reset
Browse files Browse the repository at this point in the history
  • Loading branch information
syhner committed Mar 27, 2024
1 parent b9415ea commit b7d10f0
Show file tree
Hide file tree
Showing 7 changed files with 301 additions and 0 deletions.
5 changes: 5 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { app } from '~/routes';

export default function Page(request: Request) {
return app.handle(request);
}
116 changes: 116 additions & 0 deletions src/routes/api/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { generateState, OAuth2RequestError } from 'arctic';
import { generateId } from 'lucia';
import { parseCookies, serializeCookie } from 'oslo/cookie';
import { z } from 'zod';
import { users } from '~/db/schemas/auth';
import { env } from '~/env';
import { github, lucia } from '~/lib/auth';
import { db } from '~/lib/db';
import { createElysia } from '~/util/elysia';

// const githubUserSchema = z.object({
// id: z.string(),
// login: z.string(),
// });

export const routes = createElysia({ prefix: '/auth' })
.get('/login/github', async (ctx) => {
const state = generateState();
const url = await github.createAuthorizationURL(state);
return new Response(null, {
status: 302,
headers: {
Location: url.toString(),
'Set-Cookie': serializeCookie('github_oauth_state', state, {
httpOnly: true,
secure: env.NODE_ENV === 'production', // set `Secure` flag in HTTPS
maxAge: 60 * 10, // 10 minutes
path: '/',
}),
},
});
})
.get('/login/github/callback', async (ctx) => {
const cookies = parseCookies(ctx.request.headers.get('Cookie') ?? '');
const stateCookie = cookies.get('github_oauth_state') ?? null;

const url = new URL(ctx.request.url);
const state = url.searchParams.get('state');
const code = url.searchParams.get('code');

// verify state
if (!state || !stateCookie || !code || stateCookie !== state) {
return new Response(null, {
status: 400,
});
}

try {
const tokens = await github.validateAuthorizationCode(code);
const githubUserResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
});
const githubUser = await githubUserResponse.json();

const existingUser = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.githubId, githubUser.id),
});

if (existingUser) {
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
return new Response(null, {
status: 302,
headers: {
Location: '/',
'Set-Cookie': sessionCookie.serialize(),
},
});
}

const userId = generateId(15);
await db.insert(users).values({
id: userId,
githubId: githubUser.id,
});

const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
return new Response(null, {
status: 302,
headers: {
Location: '/',
'Set-Cookie': sessionCookie.serialize(),
},
});
} catch (e) {
console.log(e);
if (e instanceof OAuth2RequestError) {
// bad verification code, invalid credentials, etc
return new Response(null, {
status: 400,
});
}
return new Response(null, {
status: 500,
});
}
})
.post('/logout', async (ctx) => {
if (!ctx.session) {
return new Response(null, {
status: 401,
});
}

await lucia.invalidateSession(ctx.session.id);
return new Response(null, {
status: 302,
headers: {
Location: '/',
'Set-Cookie': lucia.createBlankSessionCookie().serialize(),
},
});
});
12 changes: 12 additions & 0 deletions src/routes/api/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'bun:test';
import { app } from '~/routes';

describe('api', () => {
it('ping endpoint returns pong', async () => {
const response = await app
.handle(new Request('http://localhost/api/ping'))
.then((res) => res.text());

expect(response).toBe('pong');
});
});
6 changes: 6 additions & 0 deletions src/routes/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createElysia } from '~/util/elysia';
import { routes as authRoutes } from './auth';

export const routes = createElysia({ prefix: '/api' })
.use(authRoutes)
.get('/ping', () => 'pong');
38 changes: 38 additions & 0 deletions src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { html } from '@elysiajs/html';
import { swagger } from '@elysiajs/swagger';
import { SignIn, SignOut } from '~/components/auth';
import { Layout } from '~/components/Layout';
import { createElysia } from '~/util/elysia';
import { routes as apiRoutes } from './api';
import { routes as todosRoutes } from './todos';

export const app = createElysia()
// Plugins on all routes
.use(swagger())

// Non-page routes
.use(apiRoutes)

// Plugins on all page routes
.use(html())

// Page routes
.use(todosRoutes)
.get('/', async (ctx) => {
return (
<Layout>
<div class='px-6 py-6'>
{ctx.session ? (
<SignOut></SignOut>
) : (
<SignIn>Sign in to modify todos</SignIn>
)}
<div class='py-3'></div>
<div hx-get='/todos' hx-trigger='load' hx-swap='innerHTML'></div>
</div>
</Layout>
);
})
.get('/health', (ctx) => 'ok');

export type App = typeof app;
69 changes: 69 additions & 0 deletions src/routes/todos/components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Todo } from '~/db/schemas/todo';

type TodoItemProps = { todo: Todo; enabled: boolean };
export const TodoItem: Html.Component<TodoItemProps> = (props) => {
return (
<div class='flex flex-row space-x-3'>
<input
name='completed'
type='checkbox'
checked={props.todo.completed}
hx-patch={`/todos/${props.todo.id}`}
hx-swap='outerHTML'
hx-target='closest div'
disabled={!props.enabled}
/>
<p safe>{props.todo.task}</p>
<button
class='text-red-500'
hx-delete={`/todos/${props.todo.id}`}
hx-swap='outerHTML'
hx-target='closest div'
disabled={!props.enabled}
>
X
</button>
</div>
);
};

type TodoListProps = { todos: Todo[]; enabled: boolean };
export const TodoList: Html.Component<TodoListProps> = (props) => {
return (
<div id='todo-list'>
{props.todos.toReversed().map((todo) => (
<TodoItem todo={todo} enabled={props.enabled} />
))}
</div>
);
};

type TodoFormProps = { enabled: boolean };
export const TodoForm: Html.Component<TodoFormProps> = (props) => {
return (
<form
class='flex flex-row space-x-3'
hx-post='/todos'
hx-target='#todo-list'
hx-swap='afterbegin'
>
<select
name='task'
class='rounded-sm border border-black dark:text-black'
>
<option value='Go shopping' selected>
Go shopping
</option>
<option value='Buy bread'>Buy bread</option>
<option value='Make dinner'>Make dinner</option>
</select>
<button
type='submit'
class='disabled:opacity-50 disabled:pointer-events-none rounded-sm bg-primary text-primary-foreground hover:bg-primary/90 px-3'
disabled={!props.enabled}
>
Add
</button>
</form>
);
};
55 changes: 55 additions & 0 deletions src/routes/todos/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { eq } from 'drizzle-orm';
import { insertTodoSchema, patchTodoSchema, todos } from '~/db/schemas/todo';
import { isAuthenticated } from '~/hooks/isAuthenticated';
import { db, idParamsSchema } from '~/lib/db';
import { createElysia } from '~/util/elysia';
import { TodoForm, TodoItem, TodoList } from './components';

export const routes = createElysia({ prefix: '/todos' })
.get('/', async (ctx) => {
const allTodos = await db.select().from(todos).all();
return (
<div class='flex flex-col'>
<TodoForm enabled={!!ctx.session} />
<TodoList todos={allTodos} enabled={!!ctx.session} />
</div>
);
})
.post(
'/',
async (ctx) => {
const newTodo = await db.insert(todos).values(ctx.body).returning().get();
return <TodoItem todo={newTodo} enabled={true} />;
},
{
body: insertTodoSchema,
// beforeHandle: [isAuthenticated],
}
)
.patch(
'/:id',
async (ctx) => {
const patchedTodo = await db
.update(todos)
.set({ completed: ctx.body.completed === 'on' })
.where(eq(todos.id, ctx.params.id))
.returning()
.get();
return <TodoItem todo={patchedTodo} enabled={true} />;
},
{
body: patchTodoSchema,
params: idParamsSchema,
// beforeHandle: [isAuthenticated],
}
)
.delete(
'/:id',
async (ctx) => {
await db.delete(todos).where(eq(todos.id, ctx.params.id)).run();
},
{
params: idParamsSchema,
// beforeHandle: [isAuthenticated],
}
);

0 comments on commit b7d10f0

Please sign in to comment.